Files
gstack/lib/plan-reality-receipts.ts
Garry Tanandcapy-ai[bot] 8695938e74 v1.92.15.0 feat: plan reviews check facts and keep one task list; /office-hours hands its wireframe to design review (#340, #513, #973, #1048, #1791) (#3775)
* G1: gstack-plan-reality check opens every file:line receipt (#973)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G1: office-hours spec reviewer checks paths and flags through a bin-enforced probe (#973)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G2: plan-review reruns reconcile the last task list (gstack-tasks prior/supersede) (#1791)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G2: plan reviews write task lists through gstack-tasks start/add/close

The emit block shrinks to three calls: prior (now defaulting --dir and
--branch, and printing how to reconcile), start (creates the empty
branch-named list) and add (one validated row per task). close records a
done or dropped prior task. The plan-review result codes move to
lib/result-codes-plan.ts so lib/result-codes.ts stays under the module-size
ratchet; generated plan-review sections shrink, so their size pins go down.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* F2: office-hours saves the sketch beside the design doc; /plan-design-review reads it

Step 5 copies sketch.png/html to <PROJECT_DIR>/{user}-{branch}-sketch-{datetime}.*
and writes a Wireframe: line (state-root relative) or Wireframe: none (<reason>).
The design review section finds the design doc before Pass 1 and opens the
wireframe, text-only when the file is missing. G4's signals line rides along
in the same generated office-hours files; tests in planning-wave-prose.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G3: plan reviews tie storage to queries, not file count (#1048)

One bullet in each of plan-eng-review and plan-ceo-review: fields queried,
constrained or updated on their own get explicit columns or tables; JSON suits
a document read and written whole. Written fresh; the reporter's patch is not
ported.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* evals: GSTA-228 planning-wave cases with branch, main and no-gstack arms

planted facts (G1, behavior), data model (G3, behavior), task-list rerun (G2,
rule) and closing signals (G4, behavior). GSTACK_WAVE_ARM picks the arm; only
the branch arm asserts the preregistered bar, every arm writes a score row.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: GSTA-228 planning wave fragment

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G1: the probe takes subcommand words for fixed-subcommand CLIs

The planted-facts eval's one branch miss: the reviewer asked to probe
"git log", the probe refused it, and git's top-level help cannot show a log
flag, so the fabricated --since-commit stayed "unverified".
probe --help <command> [<subcommand> [<subcommand>]] now runs
<cli> <sub> --help for git, gh, docker, cargo, go, npm and uv (never node,
bun, python3, make, yarn, pnpm or a gstack bin, where the word could name a
script), in an empty temporary directory with pagers set to cat, falling back
to -h when --help has no usage line (a minimized system's missing man page).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G2: task text goes through a rows file, never a command line

The free suite's generated-bash lint (INV-3) refused the add command's
<title>/<finding> placeholders: free text reaches a command only as a file the
agent writes with its file-write tool. start now also creates an empty rows
file in the repository's git-excluded .gstack/tmp and records the run; the
agent writes task and disposition rows there; add validates every row,
stamps phase, run, branch and commit, appends tasks to the list and
dispositions to task-dispositions-<phase>-<branch>.jsonl, then supersedes the
branch's earlier lists. close is gone (dispositions are rows).

The new planning-wave cases get measured periodic walls in
scripts/paid-test-durations.json so the periodic census still packs into 40
slices, the schema case declares the preamble sources its eng-template owners
need, and the paid file count is 114.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G2: fold start into add so each plan review emits one bash fence

The rows file lives at .gstack/tmp/tasks-<phase>-rows.jsonl; add writes this
run's list (empty when there are no rows), appends dispositions, deletes the
rows file and supersedes the branch's earlier lists. Lowers 27 size pins.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G2 tests: give gstack-tasks a private HOME and state root

CI shard 13 home containment caught gstack-slug writing ~/.gstack/slug-cache
entries from the tasks-prior fixtures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: gstack-tasks has no start command

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G1/G2: tighten the probe and the rows file after ship review

The probe now accepts only each CLI's listed built-in subcommands (no uv run,
docker run, go test, npm exec or gh extensions), refuses gstack bins that do not
handle --help and never retries them with -h, and runs with a throwaway HOME and
an environment without keys. gstack-tasks resolves the slug through
runScriptSync (Windows) and prior deletes a rows file an unfinished run left.
Adds tests for the dropped disposition and the stale rows file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G2: add refuses while an earlier task is neither carried nor closed

Ship review (state matrix): a rerun that skipped RECONCILE silently retired
open tasks. add now names each undecided <run_id>/<id> and writes nothing.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* G1/G2: fixes from the ship fix-check and Step 11 voices

Probe: find the repository root without running git, drop relative and
in-repository PATH entries for lookup and for the child, keep HOME and
toolchain locations (rustup, mise, asdf) but no keys, drop cargo fmt/clippy
(external cargo-* programs), restore planted env in the test.
gstack-tasks: resolve the exclude path against the cwd (subdirectories),
resolve --dir, stamp an empty branch on a detached HEAD like the aggregator,
name the file from --branch, and refuse a same-second second list instead of
appending. Receipts: line 0, reversed ranges, dangling links and paths
through files are unresolved instead of crashing.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: probe limits and the reconcile refusal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: probe subcommand counts per CLI (ship docs audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 06:11:17 +00:00

110 lines
6.1 KiB
TypeScript

/**
* Receipt resolution for plan reality rows (G1, #973).
*
* `checkRealityRows` (lib/plan-reality.ts) stays pure: it only asks whether a
* row carries a receipt. This module asks whether each receipt points at
* something real, against explicit roots (the checkout a review is about), so
* only `gstack-plan-reality check` reads the filesystem.
*
* resolved the file exists inside a root and the cited lines exist,
* or the sha names a commit in a root's repository
* unresolved a relative path into a root's tree that is missing, a
* directory, a dangling link, line 0, a reversed range or a
* line past the end: the claim is false here
* unverifiable this checkout cannot judge it (outside every root, a bare
* name no root has, a symlink leaving the root, a file over
* the size cap, a sha a shallow clone lacks): reported only
*
* Reads are bounded: only regular files whose real path stays inside the
* root, at most `maxBytes`, and no file content ever appears in a result.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { RECEIPT_RE, type RealityLine } from './plan-reality';
export type ReceiptOutcome = 'resolved' | 'unresolved' | 'unverifiable';
export interface ReceiptResult { row: string; receipt: string; outcome: ReceiptOutcome; reason: string }
export interface ResolveOptions { roots: readonly string[]; maxBytes?: number }
const MAX_BYTES = 8 * 1024 * 1024;
const PATH_TOKEN_RE = /^(?<file>.+):(?<from>\d+)(?:-(?<to>\d+))?$/;
/** Every receipt token on a row; a `host:port` (no `/` and no `.` before the colon) is not a file receipt. */
export function receiptTokens(rest: string): string[] {
const re = new RegExp(RECEIPT_RE.source, 'g');
return [...rest.matchAll(re)].map(m => m[0].replace(/^`|`$/g, '').replace(/^commit /, ''))
.filter(t => { const m = PATH_TOKEN_RE.exec(t); return !m || /[./]/.test(m.groups!.file!); });
}
const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
function lineCount(file: string, size: number): number {
if (size === 0) return 0;
const buf = fs.readFileSync(file);
let n = 0;
for (const b of buf) if (b === 10) n++;
return buf[buf.length - 1] === 10 ? n : n + 1;
}
function resolvePath(token: string, roots: readonly string[], maxBytes: number): { outcome: ReceiptOutcome; reason: string } {
const m = PATH_TOKEN_RE.exec(token)!;
const file = m.groups!.file!.replace(/^\.\//, '');
const first = Number(m.groups!.from), last = Number(m.groups!.to ?? m.groups!.from);
if (first < 1 || last < first) return { outcome: 'unresolved', reason: `line range ${first}-${last} is not a range of lines` };
if (!roots.length) return { outcome: 'unverifiable', reason: 'no root to resolve against (not in a git checkout; pass --root)' };
if (/^[~$]/.test(file)) return { outcome: 'unverifiable', reason: 'path outside every root' };
let intoTree = false;
for (const root of roots) {
const candidate = path.resolve(root, file);
if (!inside(root, candidate)) continue;
const top = file.includes('/') ? path.join(root, file.split('/')[0]!) : candidate;
if (path.isAbsolute(file) || fs.existsSync(top)) intoTree = true;
let real: string;
try {
if (!fs.lstatSync(candidate, { throwIfNoEntry: false })) continue;
real = fs.realpathSync(candidate);
} catch (e) {
return { outcome: 'unresolved', reason: `cannot open (${(e as NodeJS.ErrnoException).code ?? 'error'})` };
}
if (!inside(fs.realpathSync(root), real)) return { outcome: 'unverifiable', reason: 'symlink leaves the root' };
const target = fs.statSync(real);
if (!target.isFile()) return { outcome: 'unresolved', reason: 'not a regular file' };
if (target.size > maxBytes) return { outcome: 'unverifiable', reason: `file over ${maxBytes} bytes` };
const lines = lineCount(real, target.size);
if (last > lines) return { outcome: 'unresolved', reason: `line ${last} is past the end (${lines} lines)` };
return { outcome: 'resolved', reason: path.relative(root, candidate) };
}
return intoTree ? { outcome: 'unresolved', reason: 'no such file' } : { outcome: 'unverifiable', reason: 'path outside every root' };
}
/** One `git cat-file --batch-check` per root for every sha token; a sha is resolved when any root's repository has the commit. */
function resolveShas(shas: string[], roots: readonly string[]): Map<string, { outcome: ReceiptOutcome; reason: string }> {
const out = new Map<string, { outcome: ReceiptOutcome; reason: string }>();
for (const sha of shas) out.set(sha, { outcome: 'unverifiable', reason: 'no root repository has this commit (shallow or another repository)' });
if (!shas.length) return out;
for (const root of roots) {
const r = spawnSync('git', ['cat-file', '--batch-check'], { cwd: root, input: shas.map(s => `${s}^{commit}`).join('\n') + '\n', encoding: 'utf8', timeout: 10_000 });
if (r.status !== 0) continue;
r.stdout.split('\n').filter(Boolean).forEach((line, i) => {
if (!/ missing$| ambiguous$/.test(line) && shas[i]) out.set(shas[i]!, { outcome: 'resolved', reason: 'commit' });
});
}
return out;
}
export function resolveReceipts(lines: readonly RealityLine[], o: ResolveOptions): ReceiptResult[] {
const maxBytes = o.maxBytes ?? MAX_BYTES;
const roots = o.roots.map(r => path.resolve(r));
const tokens = lines.filter(l => l.status !== 'n/a').flatMap(l => receiptTokens(l.rest).map(receipt => ({ row: l.id, receipt })));
const shas = [...new Set(tokens.filter(t => !PATH_TOKEN_RE.test(t.receipt)).map(t => t.receipt))];
const bySha = resolveShas(shas, roots);
return tokens.map(t => ({ ...t, ...(PATH_TOKEN_RE.test(t.receipt) ? resolvePath(t.receipt, roots, maxBytes) : bySha.get(t.receipt)!) }));
}
/** The git top level of `cwd`, or none outside a checkout. */
export function defaultRoots(cwd: string): string[] {
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 });
return r.status === 0 && r.stdout.trim() ? [r.stdout.trim()] : [];
}