diff --git a/CHANGELOG.md b/CHANGELOG.md index 208c44baf..d9931e121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,78 @@ # Changelog +## [1.91.359.0] - 2026-10-31 + +**New `/verify` skill: run the repo's own checks and the tests `/ship`'s pre-gate would pick, fix what breaks, and repeat until green, then hand off to `/ship`.** `/verify` is the loop an agent runs while building. It never calls a change green when it cannot tell which tests cover it, and it asks once before running any repo command. It never reviews, bumps a version, pushes or opens a PR. GST-116, PR 1 of 2; moving `/health` onto the same detector is PR 2. + +### Added +- **`gstack-checks`** (`bin/gstack-checks`, `lib/checks.ts`) with four verbs. `detect` lists the checks the repo declares (a `gstack:verify:` line, then a `## Health Stack` section in CLAUDE.md, then package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml and go.mod) and never makes one up. `run` runs them plus the tests pregate's own selector picks, under pregate's supervision, and prints one `CHECK ` line each with at most 40 failing lines. `trust` records consent. `status` prints `READY_FOR_SHIP tree=` from the last run and reruns nothing. +- **No false green.** A touched file with no test (`UNMAPPED`), a selection that ran zero tests (`ZERO_RUN`), more than 300 selected test files (`TOO_BROAD`), a timeout and a missing tool or service (`ENV`) are all not green. A tree that changes during a run reads `INCOMPLETE`. `status` is `NOT_READY` once the tree, the trusted commands, the base commit or the pregate policy moves. Platform lanes pregate owes remotely are listed as `not_covered`. +- **Consent covers every command a run could execute:** the checks, every package.json script, the pregate runner (or what it would detect) and each selection manifest command. Any change asks again, and nothing runs before consent. +- **`/verify` skill** (lightest preamble): `detect`, ask before `trust`, `run` after each change, fix reds in the code under test, stop after 5 runs, a repeated failure or an environment failure, send a stubborn red to `/investigate`, and finish on `READY_FOR_SHIP`. + +### Tests +- `test/gstack-checks.test.ts` covers detection, the verify-gate parity of the `gstack:verify:` parser, consent (including a changed selection command, refused before it runs), reds, unmapped and zero-run selections, partial runs, a tree moved mid-run and argument errors. +- `test/fixtures/skill-evals/verify/` adds six eval tasks (build error, failing test, lint only, a test that needs a missing service, a repo with no checks, a green tree) with shell postconditions. `test/verify-skill-eval.test.ts` checks for free that each seed gives the expected verdict, that a real fix passes, and that two decoys (a disabled check, a claim of green with no fix) fail. +- Catalog budget ceiling raised from 1,393 to 1,423 for the new skill row, by the new-skill ratchet in `test/catalog-budget.test.ts`. + +## [1.91.358.0] - 2026-10-31 + +**Diffs that touch money, gate, auth or secret code now get one more reviewer, a targeted reader with a short brief for that kind of code, and the /ship receipt records whether it ran (GST-257, part 1 of 2).** The merge queue reports the result and refuses nothing yet; enforcement comes in a later release. + +### Added +- **`lib/review-targeted.ts` names the classes a diff touches.** It reads added and deleted lines, so removing a spend cap, a check or a redaction counts. Money is a cost, spend, budget, ledger, billing, price, fee or invoice path, an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid model provider. Gate is a hunk the gate scan tagged. Auth is `SCOPE_AUTH` on an auth, session, jwt, permission or role path. Secret is a secrets, credentials, keychain or token path, or a line that reads a key-named environment variable or a credential store. Matching is token-bounded, paths review depth counts as docs or tests never count (agent prose such as skill sections and docs/review-policy.md counts as code there, so it counts here too), and an input the classifier cannot read gives all four classes. +- **`gstack-review-plan` prints `TARGETED: classes=… rules=… brief=… targeted_patch_id=…`** and, when a class hit, `DISPATCH: targeted targeted effort=full` at every depth, light included. `--targeted-ran ` skips the reader only when the whole committed diff, tests included, is the one it already read. +- **`review/specialists/targeted.md` is the reader's brief:** one block per class, the blocking floor in one sentence, and the checklist's Forced-CRITICAL rule on uncapped spend in the money block. The reader is read-only, and `NO FINDINGS` is a result. +- **`gstack-review-dispatch render --role targeted --classes …`** renders the brief with only the blocks that hit; `verify --targeted-out ` writes the record (done only when the reader's output was collected and the committed diff it was rendered on is unchanged) `gstack-ship-receipt write --review-targeted @` stores as `review_targeted`, bound to the receipt's `product_patch_id`. A carry keeps it while the product patch is unchanged. +- **`gstack-ship-queue ready` and `train build` print `REVIEW_TARGETED: required=… receipt=… verdict=ok|missing`.** Nothing is refused on it in this release. + +### Precision +- Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit 21, gate 18, auth 3 and secret 8. The gate class first used the gate paths, which hit 43 of 100, so it was narrowed to the gate scan's tagged hunks; those paths already get full review depth. + +### Tests +- `test/review-targeted.test.ts` covers each class with deleted lines, token boundaries, the docs and tests exclusions, failing closed, the dispatch at light, standard and full depth, `--targeted-ran`, the path from `render` through the receipt, the review-loop summary and the queue line. Its dispatch-superset check runs `gstack-review-plan` on 18 fixture diffs and requires every reviewer main dispatched at 8be7f21, unchanged, plus only the targeted reader. `test/review-packets-removed.test.ts` repins `lib/review-depth.ts` and `ship/sections/adversarial.md` and pins the new brief to that check. + +## [1.91.357.0] - 2026-10-31 + +**pregate `literals` no longer flags a version-history table that the branch extended.** A test that keeps one row per past version of a constant (`[45, ""]`, then `[46, ""]`) used to fail `literals` on every bump, because the old row still holds the old value. Truther turned the check down to a warning over it, which also hid real stale tests. + +### Fixed +- `lib/pregate/checks/literals.ts`: a row that starts with the old value is skipped when the same table holds a new-value row the branch added (the file at the merge base lacks it). Only the row itself is exempt (the old value after its closing bracket on the same line still fails), and the scan continues to the next line holding the old value, so `expect(PROOF_CHECKS).toBe(45)` below an extended table still fails. A table the branch did not extend, a file that already held a new-value row at the merge base (read through a rename git detects), and a merge base that cannot be read still fail. +- docs/pregate.md describes the history-row rule and the table shapes it recognizes. + +### Tests +- `test/pregate-selection.test.ts`: an extended table, a newest-first table and a new file pass; an unextended table, a stale assertion below an extended table, an unextended table beside an extended one, and a table that already held the new row fail at the right line; string-valued and quoted semver histories pass; a stale assertion on the same line as a history row fails; a 20,000-row table is checked in linear time; and an end-to-end `gstack-pregate --only literals` run fails before the table is extended, passes after, and still fails for a renamed file whose table already held the new row. + +## [1.91.356.0] - 2026-10-31 + +**A reviewer's install no longer upgrades the gstack checkout a lane shares.** On a Capy machine, an /autoplan reviewer runs `install.sh --for review-voice` against the same checkout as the lane that dispatched it. Once the lane's workflow lease was more than 120 minutes old, that install fast-forwarded the checkout and rewrote the install verdict as `for=review-voice`. The lane's next workflow was then refused with `INSTALL_UNVERIFIED`. + +### Fixed +- **`--for review-voice` never self-upgrades the checkout.** When origin is ahead, the session prints `SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: /bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED))`, leaves HEAD where it was, and the trailer stays ok with a `behind=` note. This holds with or without a lease, and `GSTACK_UPGRADE_FORCE=1` does not override it. If `bin/gstack-doctor-components.sh` cannot be read, the check still treats `review-voice` as a reviewer install. The bootstrapper still fast-forwards a checkout below the supported minimum. +- **`--check --for review-voice` names `bin/gstack-upgrade` as the fix** instead of the install rerun, which would not move a reviewer checkout. + +### Changed +- **The Captain's digest drops an archived thread from In progress in the same turn** (docs/captain.md, the DIGEST.md template and /captain). + +## [1.91.355.0] - 2026-10-31 + +**A driven `/ship` on Capy no longer pays one agent turn per finished reviewer, and it can count its own calls.** Replays of gstack-evals #34 and #36 (GSTA-167) made 30 to about 92 parent calls against a target of 12. In the 9-reviewer replay, 23 of 25 waits were the parent waking once per finished reviewer, because the driver told it to end its turn on `wait` while reviewers ran. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged. + +### Changed +- **Readers are polled inside the program** (`ship/DRIVER.md`, Capy skill). A program returns 200 s after its first line and the next call resumes the poll; the parent never ends its turn while readers run. Capy polls with one `tools.subagent.list` per tick, starts readers a create call rejected once slots free, and reports a reader stuck for 20 minutes instead of dropping it. `wait` (now 200 s) is only for tests, pregate and CI when no reader runs. Lanes, pregate and `finish` run in the background. +- **A program returns at most 32 KB**; longer reads such as `review-army.md` split at headings, each byte once, before first use. Phase 2 used to return about 98 KB of sections at once, and truncated reads cost extra calls. +- **A fix runs as one gated program**: the failing test, the fix, `--tests-only` and the next pass, each only if the last step passed. Step 7's audit, which may write tests, settles before the review pass reads the tree. +- **An unattended `push` defers the one-time pre-push guard offer** (`REDACT_PREPUSH: … deferred=unattended`) instead of stopping. It writes no marker and installs no hook, so the next interactive session still gets the offer. + +### Added +- **`gstack-ship-budget calls --events `** counts a run's parent calls from the host's transcript items: `CALLS: total= decision= keepalive= idle_gaps_over_300s= max_gap_s=`. A wait, or a program that only reads status (plus at most one bash for its note), counts as a keep-alive. + +### Fixed +- **`gstack-ship-budget note --step 9.1`** records under step 9 with the sub-step as its label. Before, it was refused, and replays lost two notes to that error. +- **`gstack-review-scope render`** accepts `{"deferred": [...]}` as well as the bare array. +- **`gstack-review-dispatch judge`** treats a judged line with no severity, or `"severity": null`, as invalid and prints `JUDGMENT_REJECTED`. Before, it crashed with a TypeError, and a replay spent three calls recovering. +- **The Capy skill passes a rendered brief as bash `cat` prints it.** A file read cut the long lines of a 231,589-byte outside-voice brief, so `sent` reported `BRIEF_MISMATCH` and the reader had to be sent again. + ## [1.91.354.0] - 2026-10-31 **On Claude Code, `/land-and-deploy` loads about 15 KB less skill text per session.** Step 7's canary instructions (the canary script, third-party dashboard actions, Aside setup and the browser fallback) moved out of `land-and-deploy/SKILL.md` into an on-demand `sections/canary.md`, which the skill reads only when it reaches the canary step or a browser or third-party site action. `SKILL.md` drops from 51,992 to 32,216 bytes. Codex and Factory renders inline every section, so they stay about the same size. In a paid before/after run (Opus 5.5, 10 tasks, 3 trials per arm; GSTA-161), mean loaded skill bytes fell from 109,334 to 93,828 (-14%), the pass rate held (after 97%, before 80%, a second before panel 87%), and mean cost per session stayed flat. Pass rates are graded on the transcript. These numbers are exploratory, from one eval suite. diff --git a/VERSION b/VERSION index 07ecb0cf6..afcab5d37 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.354.0 +1.91.359.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0be762b2f..7ac3efb2a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.354.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.359.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index a8c0b82f0..e269de2e2 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -29,7 +29,10 @@ # inside a workflow (/ship's docs step) must not change gstack under it. The age # bound matters on Capy, where the leased harness pid is the machine's # long-lived capy-server and a live lease alone would never end the deferral; -# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done -> ` | `skipped ()` +# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). +# An install profile (`--for review-voice`, a dispatched reviewer on a machine +# whose checkout a lane shares) never self-upgrades, lease or not, and +# GSTACK_UPGRADE_FORCE does not override it (UPGRADE_SKIPPED, GST-290). Lines: `SELF_UPGRADE: done -> ` | `skipped ()` # | `none (current)` | `disabled ()`. # --check never moves the checkout; behind origin it prints @@ -70,6 +73,8 @@ fi [ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only" # shellcheck source=bin/gstack-install-verdict.sh . "$ROOT/bin/gstack-install-verdict.sh" +# shellcheck source=bin/gstack-doctor-components.sh +. "$ROOT/bin/gstack-doctor-components.sh" 2>/dev/null || gstack_doctor_install_profile() { [ "$1" = review-voice ]; } MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_CAPY_MIN_WRAPPER) if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)" @@ -108,7 +113,8 @@ if [ "$CHECK" -eq 1 ]; then if behind; then # The rerun fast-forwards only what gstack-upgrade would move; otherwise # gstack-upgrade itself names the refusal and its fix. - if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN" + if gstack_doctor_install_profile "$SKILL"; then report_behind "$ROOT/bin/gstack-upgrade" + elif [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN" elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN" elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \ || [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \ @@ -123,6 +129,11 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)" elif ! behind; then echo "SELF_UPGRADE: none (current)" + elif gstack_doctor_install_profile "$SKILL"; then + REASON="$SKILL never self-upgrades a shared checkout; fix: $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)" + echo "SELF_UPGRADE: skipped ($REASON)" + report_behind "$ROOT/bin/gstack-upgrade" + export GSTACK_CAPY_BEHIND_BLOCKED="$REASON" elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN" export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)" elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \ diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index 9e4ea74ce..89a4b3dfe 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -7,7 +7,7 @@ * gstack-review-dispatch sent --ledger --role [--name ] --sent the prompt actually passed; mismatch fails * gstack-review-dispatch collect --ledger --role [--name ] --output --merged * gstack-review-dispatch judge --ledger --merged --judged no drop, no downgrade - * gstack-review-dispatch verify --ledger replay every hash and the judgment + * gstack-review-dispatch verify --ledger [--targeted-out ] replay every hash and the judgment * * Logic: lib/review-brief.ts (rendering from the sections) and lib/review-ledger.ts (ledger, collect, judge, verify). */ @@ -16,7 +16,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { BRIEF_ROLES, BriefError, briefFile, promptHash, renderBrief, sectionTexts, type BriefRole, type Slots } from '../lib/review-brief'; -import { collect, readJsonl, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine } from '../lib/review-ledger'; +import { collect, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger'; +import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedPatchId } from '../lib/review-targeted'; +import { resolveBaseBranch } from '../lib/ship-policy'; const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free collection for /ship @@ -26,6 +28,8 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free --learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team) --checklists (specialist-batched: the plan's checklists= names, or paths) --findings-file --specialists (red-team) --fix-list --hunks --tests [--callers ] (fix-check) + --classes (targeted: the plan's TARGETED classes; the brief is review/specialists/targeted.md; + --diff-base must be the merge base with --base, default origin/HEAD's branch, on every pass) writes /[-].prompt.md, records it in the ledger (default /review-ledger.jsonl) and prints BRIEF: reader= sha256= bytes= file=. Pass that file's content as the reader's prompt, unchanged. sent --ledger --role [--name ] --sent @@ -36,9 +40,10 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free judged lines {"id","severity","reason"?,"duplicate_of"?}: every merged id kept at its severity or above (a duplicate folds into one at least as severe); added and promoted findings need a reason. JUDGMENT: ok …, or JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1) - verify --ledger + verify --ledger [--targeted-out ] BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or - VERIFY: no brief records (exit 3: this run's ledger kept no prompts) + VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints + TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads ${exitTableHelp()}`; const args = process.argv.slice(2); @@ -55,8 +60,15 @@ function render(): number { if (!role || !BRIEF_ROLES.includes(role) || !out) { process.stderr.write(`gstack-review-dispatch render: needs --role <${BRIEF_ROLES.join('|')}> and --out \n`); return EXIT.usage; } const name = flag('--name'); const diffBase = flag('--diff-base'); - const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined); - if (checklist && (role === 'specialist' || role === 'red-team') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`); + const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'targeted' ? path.join(ROOT, 'review/specialists/targeted.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined); + if (checklist && (role === 'specialist' || role === 'red-team' || role === 'targeted') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`); + const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined; + if (role === 'targeted') { + const base = resolveBaseBranch(process.cwd(), flag('--base')); + const mb = [`origin/${base}`, base].map(ref => spawnSync('git', ['merge-base', ref, 'HEAD'], { encoding: 'utf8', timeout: 30_000 })).find(r => r.status === 0)?.stdout.trim(); + const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined; + if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`); + } const checklists = flag('--checklists')?.split(',').map(s => s.trim()).filter(Boolean).map(c => (c.includes('/') ? c : path.join(ROOT, 'review/specialists', `${c}.md`))); for (const c of checklists ?? []) if (!fs.existsSync(c)) throw new BriefError(`checklist not found: ${c}`); let diff: string | undefined; @@ -69,6 +81,7 @@ function render(): number { name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'), learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','), findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff, + classes: flag('--classes'), brief, fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'), }; const text = renderBrief(role, slots, sectionTexts(ROOT)); @@ -77,11 +90,29 @@ function render(): number { fs.writeFileSync(file, text, { mode: 0o600 }); const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'); const sha = promptHash(text); - appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text) }); + const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, patch_id: diffBase ? targetedPatchId(process.cwd(), diffBase) : 'none' }; + appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), ...targeted }); process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`); return EXIT.ok; } +/** + * The receipt's review_targeted record from the newest targeted dispatch: done only when its brief was sent verbatim, its output + * collected, and the committed diff it was rendered on is still the one at verify (targetedPatchId; uncommitted never proves it). + */ +function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string } | null { + const events = readLedger(ledger); + const render = events.filter(e => e.event === 'rendered' && e.reader === 'targeted').at(-1); + if (!render) return null; + const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === 'targeted').at(-1)?.reader; + const brief = rows.filter(r => r.kind === 'BRIEF' && r.reader === label).at(-1); + const output = rows.filter(r => r.kind === 'OUTPUT' && r.reader === label).at(-1); + const read = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && typeof render.diff_base === 'string' && targetedPatchId(process.cwd(), render.diff_base) === render.patch_id; + const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && read; + const ids = events.filter(e => e.event === 'output' && e.reader === 'targeted').at(-1)?.ids; + return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: done && Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted' }; +} + function main(): number { const cmd = args[0]; if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } @@ -119,6 +150,11 @@ function main(): number { const v = verify(ledger); for (const r of v.rows) process.stdout.write(`${r.kind} ${r.reader} ${r.status} ${r.detail}\n`); if (!v.briefs) { process.stdout.write('VERIFY: no brief records (this run\'s ledger kept no prompts; dispatched-vs-rendered cannot be checked)\n'); return EXIT.refused; } + const record = targetedRecord(ledger, v.rows); + if (record) { + process.stdout.write(`TARGETED_RECORD: classes=${record.classes.join(',')} status=${record.status} findings=${record.findings} brief=${record.brief_sha.slice(0, 12)} rules=${record.rules}\n`); + if (flag('--targeted-out')) fs.writeFileSync(flag('--targeted-out')!, JSON.stringify(record) + '\n'); + } process.stdout.write(`VERIFY: ${v.clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}\n`); return v.clean ? EXIT.ok : EXIT.fail; } diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index 5826f66f1..818d3c6a8 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -4,14 +4,16 @@ * * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] - * [--reviewed-numbers ] [--depth-floor standard|full] [--range-base ] [--pass1-depth light|standard|full] [--no-tiers] [--json] + * [--reviewed-numbers ] [--depth-floor standard|full] [--range-base ] [--pass1-depth light|standard|full] [--no-tiers] + * [--targeted-ran ] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * * Reads the diff against the merge base with (committed, tracked uncommitted * edits and untracked files, through lib/review-collect.ts collectLive), runs * gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints * REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and - * result codes), GATE_SCAN:, FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed + * result codes), GATE_SCAN:, TARGETED: (the money, gate, auth and secret classes, lib/review-targeted.ts), + * FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed * the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per * reviewer and one SKIP: per reviewer left out. A plan that fails closed to full * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts. @@ -23,6 +25,7 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; import { collectCommit, collectLive } from '../lib/review-collect'; import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth'; +import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth) @@ -49,9 +52,12 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --no-tiers specialists, audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude) --pass1-depth pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full + --targeted-ran the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted + reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files -Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). NEXT: full, scoped +Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money, +gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped (a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan from an error exits 0; exit 1 means no plan could be built (git could not list the diff). ${exitTableHelp()}`; @@ -131,18 +137,22 @@ function main(): number { : { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) }; } } + const targeted = targetedClasses({ ...collected, scope, gateTagged }); + const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none'; + const brief = targetedBriefSha(); const plan = planReview({ - ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', + ...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines }, }); const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); + process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); return EXIT.ok; } - const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`]; + const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, + `TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`]; if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`); out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`); for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`); diff --git a/bin/gstack-review-scope b/bin/gstack-review-scope index 569aba495..3d4c843b6 100755 --- a/bin/gstack-review-scope +++ b/bin/gstack-review-scope @@ -224,6 +224,8 @@ function cmdRender(rest: string[]): number { let noted: ScopedFinding[] = []; try { if (deferredFile) deferred = JSON.parse(fs.readFileSync(deferredFile, 'utf8')); + const wrapped = deferred as unknown as { deferred?: unknown }; + if (!Array.isArray(deferred) && Array.isArray(wrapped?.deferred)) deferred = wrapped.deferred as DeferredRecord[]; const findingsFile = flag(rest, '--findings'); if (findingsFile) noted = (readFindings(findingsFile, 'classified findings') as ScopedFinding[]).filter(f => f.scope === 'note'); } catch (e: any) { return refuse('render', 'REVIEW_SCOPE_INPUT', e.message); } diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget index 83831d97f..fd9c5119f 100755 --- a/bin/gstack-ship-budget +++ b/bin/gstack-ship-budget @@ -23,13 +23,14 @@ * resume --record |--run [--reading ] [--meter ] [--session ] [--token-cap --approved-by --words ] * [--rebuild-from-record] * close --run|--record [--reading ] [--approved-by --words ] + * calls --events [--since ] [--until ] (the host's transcript items: one CALLS: line, lib/ship-calls.ts) * * Exit: 0 continue/delegate · 3 stop (the next step does not start) · 2 usage * or validation (stderr). Logic: lib/ship-budget.ts, lib/ship-budget-cli.ts, * lib/ship-handoff.ts. */ import { BudgetError } from '../lib/ship-budget'; -import { cmdCheck, cmdClose, cmdNote, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli'; +import { cmdCalls, cmdCheck, cmdClose, cmdNote, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli'; import { cmdHandoff, cmdLaunch, cmdReconcile, cmdReturn, cmdVerifyHandoff } from '../lib/ship-handoff'; const [command, ...rest] = process.argv.slice(2); @@ -37,7 +38,7 @@ const ctx = { env: process.env, cwd: process.cwd() }; const commands: Record Outcome> = { start: () => cmdStart(rest, ctx), note: () => cmdNote(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true), handoff: () => cmdHandoff(rest, ctx), launch: () => cmdLaunch(rest, ctx), 'verify-handoff': () => cmdVerifyHandoff(rest, ctx), return: () => cmdReturn(rest, ctx), - reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), + reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest), }; if (!command || command === '--help' || command === '-h' || !commands[command]) { const lines = (await Bun.file(import.meta.path).text()).split('\n'); diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 59c64628e..f7b30d005 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -44,7 +44,7 @@ import { receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; -import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; +import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; @@ -89,6 +89,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({classes, rules, brief_sha, status, findings, raised_by}: gstack-review-dispatch verify + --targeted-out); recorded as review_targeted bound to the receipt's product_patch_id. Without it the receipt has no + review_targeted, which the queue reports as legacy. --approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the @@ -207,6 +210,12 @@ function cmdWrite(rest: string[], json: boolean): number { if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`); } const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head); + const product = reviewedProduct(repoRoot, base, head, pregateFile); + let targeted: ReturnType | undefined; + if (flag(rest, '--review-targeted') !== undefined) { + try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none'); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; } + } + if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; } const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') }); if (approval.kind === 'invalid') { for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n'); @@ -236,8 +245,8 @@ function cmdWrite(rest: string[], json: boolean): number { session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, - review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), - gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, + review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product, + gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined, }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } diff --git a/captain/SKILL.md b/captain/SKILL.md index 419f294fd..9cfb80418 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -329,7 +329,8 @@ turn, three sections in this order, each row with its thread, one line, and an a 2. **New**: what landed or changed since the last digest (merges with versions, READYs received, trains built, incidents), newest first. 3. **In progress**: every working lane, what it owns, its state (building, READY, in - train slot i, blocked on …) and how long since it last reported. + train slot i, blocked on …) and how long since it last reported. A thread the Captain + archives leaves this list in the same turn. Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting longer than `FILL_HOURS`, is the controller's next action. diff --git a/captain/SKILL.md.tmpl b/captain/SKILL.md.tmpl index 4ccf4b36e..6e895baaf 100644 --- a/captain/SKILL.md.tmpl +++ b/captain/SKILL.md.tmpl @@ -257,7 +257,8 @@ turn, three sections in this order, each row with its thread, one line, and an a 2. **New**: what landed or changed since the last digest (merges with versions, READYs received, trains built, incidents), newest first. 3. **In progress**: every working lane, what it owns, its state (building, READY, in - train slot i, blocked on …) and how long since it last reported. + train slot i, blocked on …) and how long since it last reported. A thread the Captain + archives leaves this list in the same turn. Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting longer than `FILL_HOURS`, is the controller's next action. diff --git a/captain/templates/DIGEST.md b/captain/templates/DIGEST.md index 81cf2d3d2..23cfc1357 100644 --- a/captain/templates/DIGEST.md +++ b/captain/templates/DIGEST.md @@ -27,5 +27,7 @@ ask format is gstack docs/captain.md, The ask format. |---|---|---|---| | | | | | +A thread the controller archives leaves this table in the same turn. + Stalls to act on: a lane with no report for longer than , a READY older than `FILL_HOURS` with no train, a slot call unanswered for longer than . diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 9b7e22232..8ef728bd1 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base ` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start ` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation ` from check's `RESERVATION_ID:` line); (3) `BEFORE`, the counter's tokens and credits; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, reasoning from its `effort=` (`full` the lane's, `medium` medium, `light` low), its brief copied verbatim from the section that dispatches it; audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s for at most 240 s, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); (6) `gstack-ship-budget note --step --reading --credits --dispatched-at --dispatched-credits --readers `; (7) return only each reader's final output, or what still runs. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. During tests, pregate, readers and CI, re-issue `wait` with `duration_seconds: 240` and the operation ids: a call after a longer gap bills the whole context fresh (about 40x a cached call), and a ping costs a few percent of that. A small waiting child saves nothing: the parent's cache expires during its wait too. +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base ` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start ` once per row the step logs (admission mode: `--reservation` from `RESERVATION_ID:`); (3) `BEFORE`; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, reasoning from its `effort=` (`full` the lane's, `medium` medium, `light` low), its brief copied verbatim from the section that dispatches it (a rendered file via bash `cat`, never a file read); audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll: one `tools.subagent.list` per ~20 s tick (a `tools.subagent.get` with `transcript: "none"` only on a status change), returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); start readers listed under `rejected` with `tools.subagent.start` as slots free; report one stuck 20 minutes; (6) `gstack-ship-budget note --record --step --reading --credits --dispatched-at --dispatched-credits --readers `; (7) return only each reader's final output. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the poll program. Otherwise re-issue `wait` with `duration_seconds: 200` and the operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship started. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/changelog.d/garrytan-gst-116-verify.md b/changelog.d/garrytan-gst-116-verify.md deleted file mode 100644 index d555820f5..000000000 --- a/changelog.d/garrytan-gst-116-verify.md +++ /dev/null @@ -1,12 +0,0 @@ -**New `/verify` skill: run the repo's own checks and the tests `/ship`'s pre-gate would pick, fix what breaks, and repeat until green, then hand off to `/ship`.** `/verify` is the loop an agent runs while building. It never calls a change green when it cannot tell which tests cover it, and it asks once before running any repo command. It never reviews, bumps a version, pushes or opens a PR. GST-116, PR 1 of 2; moving `/health` onto the same detector is PR 2. - -### Added -- **`gstack-checks`** (`bin/gstack-checks`, `lib/checks.ts`) with four verbs. `detect` lists the checks the repo declares (a `gstack:verify:` line, then a `## Health Stack` section in CLAUDE.md, then package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml and go.mod) and never makes one up. `run` runs them plus the tests pregate's own selector picks, under pregate's supervision, and prints one `CHECK ` line each with at most 40 failing lines. `trust` records consent. `status` prints `READY_FOR_SHIP tree=` from the last run and reruns nothing. -- **No false green.** A touched file with no test (`UNMAPPED`), a selection that ran zero tests (`ZERO_RUN`), more than 300 selected test files (`TOO_BROAD`), a timeout and a missing tool or service (`ENV`) are all not green. A tree that changes during a run reads `INCOMPLETE`. `status` is `NOT_READY` once the tree, the trusted commands, the base commit or the pregate policy moves. Platform lanes pregate owes remotely are listed as `not_covered`. -- **Consent covers every command a run could execute:** the checks, every package.json script, the pregate runner (or what it would detect) and each selection manifest command. Any change asks again, and nothing runs before consent. -- **`/verify` skill** (lightest preamble): `detect`, ask before `trust`, `run` after each change, fix reds in the code under test, stop after 5 runs, a repeated failure or an environment failure, send a stubborn red to `/investigate`, and finish on `READY_FOR_SHIP`. - -### Tests -- `test/gstack-checks.test.ts` covers detection, the verify-gate parity of the `gstack:verify:` parser, consent (including a changed selection command, refused before it runs), reds, unmapped and zero-run selections, partial runs, a tree moved mid-run and argument errors. -- `test/fixtures/skill-evals/verify/` adds six eval tasks (build error, failing test, lint only, a test that needs a missing service, a repo with no checks, a green tree) with shell postconditions. `test/verify-skill-eval.test.ts` checks for free that each seed gives the expected verdict, that a real fix passes, and that two decoys (a disabled check, a claim of green with no fix) fail. -- Catalog budget ceiling raised from 1,393 to 1,423 for the new skill row, by the new-skill ratchet in `test/catalog-budget.test.ts`. diff --git a/docs/captain.md b/docs/captain.md index a57c1066a..201ce60f6 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -377,7 +377,8 @@ field"). `DIGEST.md` is rewritten every controller turn: **Needs you** (decisions only the owner can make, in [the ask format](#the-ask-format); empty is the goal), **New** (what landed or changed, newest first), **In progress** (every lane, what it owns, its state, the age of -its last report). Ages turn stalls into the controller's next action. +its last report). Ages turn stalls into the controller's next action. When the controller +archives a thread, it drops that thread from **In progress** in the same turn. ## The daily flaky pass diff --git a/docs/capy-install.md b/docs/capy-install.md index 8187c7c34..d1f2f5a45 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -47,7 +47,13 @@ upgrade: a reviewer later asked to run a workflow runs the same installer install in place to --for (same checkout ...; no second clone)`), applies the project's pins, runs `./setup` and both host profiles, and writes the workflow's verdict over the reviewer's. Nothing is removed and nothing is -cloned twice. Every /autoplan reviewer prompt +cloned twice. A review-voice session never self-upgrades the checkout, because +the lane on the same machine may be in the middle of a workflow on it: when +origin is ahead it prints `SELF_UPGRADE: skipped (review-voice never +self-upgrades a shared checkout; …)`, certifies the installed revision and +stays ok with `behind= … (UPGRADE_SKIPPED); fix: /bin/gstack-upgrade`. +The bootstrapper still fast-forwards a checkout below the supported minimum. +Every /autoplan reviewer prompt ends with this command (`GSTACK TRAILER`); the warm run's budget, under 20 s, is measured by `test/capy-review-voice.test.ts`. @@ -101,6 +107,11 @@ there is refused with the usual `--for ` fix. note, and a project that needs the newer gstack says so with a floor (next section). `SELF_UPGRADE: none (current)` when nothing is ahead; + `--for review-voice` prints `SELF_UPGRADE: skipped (review-voice never + self-upgrades a shared checkout; fix: /bin/gstack-upgrade when no other + session uses this checkout (UPGRADE_SKIPPED))` whenever origin is ahead, + with or without a lease, and `GSTACK_UPGRADE_FORCE=1` does not change that + ([reviewer machines](#reviewer-machines---for-review-voice)); `GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints `SELF_UPGRADE: disabled ()`. `--check` never moves the checkout; behind origin it prints `gstack-capy-session: behind origin/: diff --git a/docs/pregate.md b/docs/pregate.md index 03de0f36a..944c3c921 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -169,7 +169,15 @@ Two stages with separate clocks, both printed: - `literals`: for every changed exported constant or named timeout/retry literal, the whole test tree (nightly- and Heavy-only files included) is searched for the old value; a consumer the branch did not update fails with - `file:line`. + `file:line`. A version-history row, a line that starts a bracketed row with + the old value (`[45, ""]`), is skipped when the same table (a run of + consecutive lines starting with `[`) also holds a row for the new value that + the branch added (the file at the merge base, read through a rename git + detects, has no new-value row). A table the branch did not extend, a file that already held a + new-value row at the merge base, a merge base that cannot be read, and every + other line holding the old value still fail. Only the row itself is exempt: + the old value after its closing bracket on the same line still fails. Rows in object form (`{ v: 45 }`) or spread over several + lines are not recognized as history rows. - `timing`: added or changed test lines that compare an elapsed wall-clock or CPU time against a bound, or sleep and then assert, print diff --git a/docs/review-policy.md b/docs/review-policy.md index 182d17bc5..71f5a70e7 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -230,7 +230,7 @@ and lockfiles stay runtime. To adopt it, commit an entry like this on the base b **Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`, `lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`, -`lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, +`lib/review-targeted.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, `ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or `scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns, @@ -285,6 +285,59 @@ the specialists, audits and watchers at the host default too part 4a). A binary image no code names is no longer a `binary_code_path` error that forces full depth; one that code names still is. +## Targeted pass on money, gate, auth and secret code + +Some code is costly to get wrong: code that spends money, code that decides +whether a check passes, code that decides who gets in, and code that handles +secrets. When a diff touches one of these, `gstack-review-plan` dispatches one +more reader, `targeted`, at every depth, light included. It reads the whole +diff, fixtures and tests included, with the blocks of +`review/specialists/targeted.md` for the classes that hit, and it runs in Step +11 beside the native adversarial pass and the outside voice (GST-257). + +`lib/review-targeted.ts` decides the classes from added and deleted lines, so +removing a cap, a check or a redaction counts. Docs and tests paths never count, +and matching is token-bounded: `costume` never reads as cost. + +| Class | Signal | +|---|---| +| money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices | +| gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check | +| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name | +| secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store | + +An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate +scan) fails closed to all four classes. `TARGETED_RULES` hashes the patterns. +The classifier and the brief are gate paths, so an edit that weakens them gets +full review. + +The plan prints `TARGETED: classes= rules= +brief= targeted_patch_id=` and, when a class hit, +`DISPATCH: targeted targeted effort=full`. The targeted reader always reads the whole diff: `render --role targeted` +refuses a `--diff-base` other than the merge base, so on pass 2 and later it +gets the merge base, not `RANGE_BASE`. After a fix, pass +`--targeted-ran ` from the line the reader ran on: the same +committed patch skips the reader, and a changed one, uncommitted edits or +`none` dispatch it again. `targeted_patch_id` is the patch-id of the whole +committed diff, tests included, so a test-only fix that adds a gate hunk +dispatches it again; it is not the receipt's `product_patch_id`. + +The brief states the blocking floor in one sentence, and its money block cites +the checklist's Forced-CRITICAL rule on uncapped spend, so a money finding can +block without new floor words. The reader is read-only, and `NO FINDINGS` is a +result. `gstack-review-dispatch render --role targeted --classes ` +renders its brief; `verify --targeted-out ` writes the record that +`gstack-ship-receipt write --review-targeted @` puts in the receipt. +`gstack-ship-queue ready` and `train build` print +`REVIEW_TARGETED: required= receipt= +verdict=ok|missing` and refuse nothing yet; enforcement comes in a later +release. + +Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit +21, gate 18, auth 3 and secret 8. The gate class first used the gate paths, +which hit 43 of 100; those paths already get full depth, so the gate class was +narrowed to the gate scan's tagged hunks. + ## What the receipt records `gstack-review-scope summary --since ` reads this invocation's @@ -300,8 +353,9 @@ puts in the receipt: | `review_loop.fixed` | findings fixed in this PR | | `review_loop.scope` | the `review_scope` the loop ran under | | `review_loop.fix_checks` | fix-checks this ship ran (never passes) | -| `review_loop.dispatches` | reviewer dispatches by role: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check` | +| `review_loop.dispatches` | reviewer dispatches by role: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`, `targeted` | | `review_loop.est_input_tokens` | prompt plus diff bytes the dispatches read, divided by 4: a relative estimate for comparing runs, not a bill | +| `review_targeted` | the [targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code): the `classes` it covered, `rules`, `brief_sha`, the receipt's own `product_patch_id`, `status` (`done` only with its output collected and the committed diff it was rendered on still the one at verify, else `failed`; absent when write got no record, which the queue reports as `legacy`), `findings` and `raised_by`; a carry keeps it while the product patch is unchanged | Each review row may carry `dispatches: [{role, effort, prompt_bytes, diff_bytes, status}]`, and each finding `raised_by` (the reviewer that raised it, diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 928132644..3de382f18 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -36,6 +36,17 @@ is written, so `gstack-review-log --start` takes no `--reservation`, and `close` unreserved dispatch. `close` prints `BUDGET_TIMELINE:`, one entry per note. The admission mode below is what `ship/SKILL.md` runs; it is unchanged. +A dotted `--step` such as `9.1` records under its parent step (`9`) with the sub-step as its label, +unless `--label` is given. + +**Counting calls.** `gstack-ship-budget calls --events [--since ] [--until ]` reads +the host's transcript items (a JSON array, or `{ "items": [...] }` as Capy's `tools.thread.get` +transcript returns) and prints +`CALLS: total= decision= keepalive= idle_gaps_over_300s= max_gap_s= first= last= source=`. Every tool +item is one parent call; a `wait`, or a program that only reads status (plus at most one bash for its +note), is a keep-alive, and a gap over 300 s between calls outlives the prompt cache +(`lib/ship-calls.ts`). + **Credits.** Every command that takes `--reading` also takes `--credits ` from the same `thread.get`. Tokens count a cached read the same as a fresh one; credits are what the run costs, including the full-context re-bill after a gap over five minutes (about 40x a cached call, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e5119eb6c..471282f4e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1525,7 +1525,8 @@ yourself; or change the pin in the project. **Meaning.** An install-mode session start above the supported minimum found the checkout behind origin and could not fast-forward it: the checkout is dirty, diverged or pinned (`UPGRADE_REFUSED`), another live session holds a -lease (`UPGRADE_BUSY`), or `bin/gstack-upgrade` is missing +lease (`UPGRADE_BUSY`), `bin/gstack-upgrade` is missing, or the session is a +`--for review-voice` reviewer, which never moves a checkout a lane may share (`UPGRADE_SKIPPED`). Nothing was changed, the doctor certified the installed revision, and the trailer stays `gstack: ok` with the notes above: a gap above the minimum is never a hard fail. The verdict records the installed diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index ab11fbbca..4192aaac6 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -111,6 +111,8 @@ export interface ShipReceipt { review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] }; /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; + /** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */ + review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string }; } export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; @@ -234,6 +236,7 @@ export const ARTIFACT_SCHEMAS = { carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, head_tree: str, product_patch_id: str, gstack_version: str, review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } }, + review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } }, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, diff --git a/lib/pregate/checks/literals.ts b/lib/pregate/checks/literals.ts index 034de66db..a769626e4 100644 --- a/lib/pregate/checks/literals.ts +++ b/lib/pregate/checks/literals.ts @@ -66,10 +66,21 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) : * English word in half the test tree, and a one-character old value (`2`) * sits in every test, so that value needs the constant's name (a wrapper * version 2 → 3 flagged 575 consumers through the stem alone). + * + * A version-history row (`[45, ""]`, the old value first in a + * bracketed row) is skipped when the same table, a run of consecutive + * `[…` lines, holds a new-value row and `baseText` (the file at the merge + * base; null for a file the base lacks, undefined when it cannot be read) + * has no new-value row: the branch extended the table. A table it did not + * extend, a file that already held a new-value row, and an unreadable base + * still count, and the scan goes on past skipped rows to the first real consumer. */ -export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral): string[] { +export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral, baseText?: (rel: string) => string | null | undefined): string[] { const value = unquote(change.old); const valueRe = /^-?\d/.test(value) ? new RegExp(`(? new RegExp(`^[ \\t]*\\[[ \\t]*${/^['"`]/.test(raw) ? `['"\`]${escape(unquote(raw))}['"\`]` : escape(raw)}[ \\t]*,`, 'm'); + const oldRowRe = row(change.old); + const newRowRe = row(change.new); const base = path.basename(change.file); const stem = base.replace(/\.[^.]+$/, ''); const moduleRe = new RegExp(`(?:(?:^|[/\\\\])${escape(stem)}|(? { + const line = lines[i]!; + if (!oldRowRe.test(line) || !tableHasNew[table[i]!] || valueRe.test(line.slice(line.indexOf(']') + 1))) return false; + if (extended === undefined) { const base = baseText?.(rel); extended = base === null || (base !== undefined && !newRowRe.test(base)); } + return extended; + }; + for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; } } return hits; } @@ -101,7 +127,13 @@ function run(ctx: PregateContext): CheckResult { const testFiles = listRepoFiles(ctx.repoRoot).filter(f => scansLiterals(f) && !f.startsWith('bin/')); const lines: string[] = []; for (const c of changes) { - const hits = consumersOf(ctx.repoRoot, testFiles, c); + const hits = consumersOf(ctx.repoRoot, testFiles, c, rel => { + const from = ctx.touched.find(t => t.path === rel)?.renamedFrom ?? rel; + const r = spawnSync('git', ['show', `${ctx.mergeBase}:${from}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, env: ctx.env }); + if (r.status === 0) return r.stdout; + const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', from], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, env: ctx.env }); + return ls.status === 0 && !ls.stdout.trim() ? null : undefined; + }); ctx.explain.push(`literals: ${c.file} ${c.name} ${c.old} → ${c.new}: ${hits.length} test file(s) still carry ${c.old}`); for (const h of hits) lines.push(`${h}: ${c.name} still ${c.old} (now ${c.new} in ${c.file})`); } diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 65e207403..81516ed96 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -12,13 +12,15 @@ * adversarial-native diff_base * outside-voice diff_base, diff (the branch diff, which the bin reads from git) * fix-check fix_list, hunks, tests, callers (scripts/resolvers/fix-check.ts) + * targeted classes, brief (review/specialists/targeted.md's text), checklist (its path), diff_base, stack, test_fw, learnings */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; +import { parseClassList, targetedBrief } from './review-targeted'; -export const BRIEF_ROLES = ['specialist', 'specialist-batched', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check'] as const; +export const BRIEF_ROLES = ['specialist', 'specialist-batched', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted'] as const; export type BriefRole = typeof BRIEF_ROLES[number]; export type Slots = Record; @@ -119,6 +121,14 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); return [body, '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); } + case 'targeted': { + const classes = parseClassList(need(slots, 'classes')); + if (!classes?.length) throw new BriefError(`slot classes must list money, gate, auth or secret, got ${JSON.stringify(slots.classes)}`); + let brief: string; + try { brief = targetedBrief(need(slots, 'brief'), classes); } catch (e) { throw new BriefError((e as Error).message); } + const body = fill(t.specialist, [['Read the checklist at {checklist path}', `Read the targeted brief above (${need(slots, 'checklist')}: ${classes.join(', ')})`]], role); + return `${brief}\n\n${common(body, '')}`; + } case 'fix-check': return renderFixCheckPrompt({ fixList: need(slots, 'fix_list'), touchedHunks: need(slots, 'hunks'), testResults: need(slots, 'tests'), callers: slots.callers ?? '' }); } diff --git a/lib/review-collect.ts b/lib/review-collect.ts index 1cfc02384..7fbfdae12 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -22,6 +22,8 @@ export interface Collected extends ClassInput { stampOnly: string[]; /** Added lines per path, for scanNumbers. */ added: Record; + /** Deleted lines per path, for the targeted classes (lib/review-targeted.ts). */ + removed: Record; diffLines: number; mergeBase: string | null; untracked: string[]; @@ -52,11 +54,11 @@ export function loadRegistryAt(root: string, sha: string): RegistryInfo { } /** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */ -function readDiff(root: string, range: string[], errors: DepthError[]): Pick { +function readDiff(root: string, range: string[], errors: DepthError[]): Pick { const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]); const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]); const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]); - const empty = { perPath: [], deleted: [], stampOnly: [], added: {} }; + const empty = { perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }; for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) { if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); return empty; } } @@ -69,6 +71,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p)); const added: Record = {}; + const removed: Record = {}; const changed: Record = {}; let cur = ''; let minus = ''; @@ -83,10 +86,11 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l))); - return { perPath, deleted, stampOnly, added }; + return { perPath, deleted, stampOnly, added, removed }; } /** More data candidates than this keep every data file code: the consumer scan is not run. */ @@ -154,7 +158,7 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C const mb = git(root, ['merge-base', baseSha, headSha]); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase, headSha], errors); @@ -172,7 +176,7 @@ export function collectLive(root: string, base: string): Collected { const mb = git(root, ['merge-base', ref, 'HEAD']); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); diff --git a/lib/review-depth.ts b/lib/review-depth.ts index a7af650d3..2cdbd8fd9 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -18,7 +18,7 @@ export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; export type Depth = 'light' | 'standard' | 'full'; export type Effort = 'light' | 'medium' | 'full' | 'host'; -export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check'; +export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted'; export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop'; export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const; @@ -38,7 +38,7 @@ export const BATCH_SCOPED: readonly Specialist[] = ['data-migration', 'api-contr * them; the Red Team, the native adversarial pass and the fix-check at full (the lane's reasoning); the outside voice at * its own provider's. Light's batched specialist carries the Red Team prompt, so it stays full. */ -export const ROLE_EFFORT = { specialist: 'medium', 'red-team': 'full', 'adversarial-native': 'full', 'fix-check': 'full', 'outside-voice': 'host' } as const satisfies Record; +export const ROLE_EFFORT = { specialist: 'medium', 'red-team': 'full', 'adversarial-native': 'full', 'fix-check': 'full', 'outside-voice': 'host', targeted: 'full' } as const satisfies Record; /** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */ export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)'; const CLASS_RANK: Record = { docs: 0, tests: 1, code: 2, gate: 3 }; @@ -53,7 +53,7 @@ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//]; /** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */ const GATE_PATH = [ /(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/, - /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, + /^lib\/review-(depth|collect|scope|targeted)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, ]; /** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */ @@ -291,6 +291,8 @@ export interface PlanInput extends ClassInput { floor?: Depth; /** false under --no-tiers or review_effort_tiers off: specialists run at the host's default effort. */ tiers?: boolean; + /** The targeted classes (lib/review-targeted.ts) and whether the targeted reader already read this product patch. */ + targeted?: { classes: readonly string[]; ran?: boolean }; } /** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */ export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string } @@ -432,6 +434,10 @@ export function planReview(i: PlanInput): ReviewPlan { add('fix-check', 'fix-check', 'full'); if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); } + if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check'].includes(next.step)) { + if (i.targeted.ran) skipped.push('targeted (already read this product patch)'); + else add('targeted', 'targeted', ROLE_EFFORT.targeted); + } return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped }; } diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index e8a5633a6..994a5a13d 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -43,7 +43,8 @@ export function recordSent(ledger: string, reader: string, sentText: string): { /** Severity ranks across the voices' vocabularies; a label with no severity (FIXABLE, INVESTIGATE) ranks 0. */ const RANKS: Record = { critical: 4, p0: 4, high: 3, p1: 3, medium: 2, p2: 2, informational: 2, low: 1, p3: 1, fixable: 0, investigate: 0 }; -export function rank(label: string): number | null { +export function rank(label: unknown): number | null { + if (typeof label !== 'string') return null; const r = RANKS[label.trim().replace(/^\[|\]$/g, '').toLowerCase()]; return r === undefined ? null : r; } diff --git a/lib/review-scope.ts b/lib/review-scope.ts index b514dd557..45c4c0165 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -35,10 +35,10 @@ export interface ScopeFinding { export interface ScopedFinding extends ScopeFinding { locus: Locus; kind: Kind; blocking: boolean; scope: ScopeAction; raised_by?: string } /** Who can raise a finding: the core checklist, QA probes, a specialist by name and the review-depth roles; anything else is `unknown`. */ -const RAISERS: readonly string[] = ['core', 'qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', ...SPECIALISTS]; +const RAISERS: readonly string[] = ['core', 'qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted', ...SPECIALISTS]; const RAISER_ALIASES: Record = { adversarial: 'adversarial-native' }; -/** Reviewers that read after pass 1 finished (Step 11's voices, a fix-check): only a blocking finding of theirs is ever `fix`. */ -const AFTER_PASS_RAISERS: readonly string[] = ['adversarial-native', 'outside-voice', 'fix-check']; +/** Reviewers that read after pass 1 finished (Step 11's voices and the targeted reader, a fix-check): only a blocking finding of theirs is ever `fix`. */ +const AFTER_PASS_RAISERS: readonly string[] = ['adversarial-native', 'outside-voice', 'fix-check', 'targeted']; /** The reviewer that raised a finding, for attributing passes later (GSTA-152 D6): its `raised_by` when known, else its `specialist` tag, else `unknown`. */ export function raisedBy(finding: ScopeFinding): string { @@ -297,7 +297,7 @@ export interface ReviewLoopSummary { passes: number; blocking_found: number; deferred: number; noted: number; fixed: number; scope: ReviewScopePolicy | 'unknown'; /** One-reviewer re-checks of a fix (docs/review-policy.md#transitions); never passes. */ fix_checks?: number; - /** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check). */ + /** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check, targeted). */ dispatches?: Record; /** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */ est_input_tokens?: number; diff --git a/lib/review-targeted.ts b/lib/review-targeted.ts new file mode 100644 index 000000000..ef037c683 --- /dev/null +++ b/lib/review-targeted.ts @@ -0,0 +1,132 @@ +/** + * review-targeted — the targeted review pass on money, gate, auth and secret code + * (docs/review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code; GST-257). + * A pure function from a collected diff (lib/review-collect.ts: changed paths with + * their added and deleted lines), gstack-diff-scope's flags and the gate scan to the + * classes the diff touches. bin/gstack-review-plan prints them and dispatches one + * `targeted` reader with the matching blocks of review/specialists/targeted.md; + * gstack-ship-receipt records the pass and gstack-ship-queue reports it. + * + * Matching reads added and deleted lines, so removing a cap, a check or a redaction + * counts. Path and identifier signals are token-bounded: `costume` never names cost. + * Docs and tests paths never trigger. Any input the classifier cannot read fails + * closed to all four classes, as a depth error fails the depth closed to full. + * + * The gate class is the gate scan's tagged hunks (a relaxed threshold, a skipped test, a + * loosened check), not GATE_PATH: over the last 100 non-release commits on main, GATE_PATH + * alone hit 43 (the precision gate allows 25), and those paths already get full depth. + */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { classifyPath } from './review-depth'; + +export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const; +export type TargetedClass = (typeof TARGETED_CLASSES)[number]; + +const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices']; +/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */ +const MONEY_LINE = [ + /\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i, + /\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/, + /\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/, +]; +const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles']; +const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz']; +const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i; +/** An environment name whose underscore-separated segments include a key word: OPENAI_API_KEY, GH_TOKEN; never MAX_TOKENS. */ +const KEY_NAME = String.raw`(?:[A-Z0-9]+_)*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS?)(?:_[A-Z0-9]+)*\b`; +const SECRET_LINE = [ + new RegExp(String.raw`\b(?:process\.env|Bun\.env|import\.meta\.env)(?:\.|\[\s*['"\x60])${KEY_NAME}`), + new RegExp(String.raw`\b(?:os\.environ(?:\.get\(|\[)|os\.getenv\(|ENV(?:\.fetch\(|\[))\s*['"]${KEY_NAME}`), + new RegExp(String.raw`\$\{?${KEY_NAME}`), + /\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/, +]; + +/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */ +export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({ + TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, + patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), +})).digest('hex').slice(0, 16); + +export interface TargetedInput { + paths: readonly string[]; + /** Added and deleted lines per path (lib/review-collect.ts). */ + added?: Record; + removed?: Record; + /** gstack-diff-scope output (SCOPE_* keys); missing or SCOPE_ERROR fails closed. */ + scope?: Record; + /** Gate hunks the gate scan tagged; null when it did not run, which fails closed once a code path changed. */ + gateTagged: number | null; + root?: string | readonly string[]; + /** Collector failures (a diff git could not list). */ + errors?: readonly { code: string; input: string }[]; +} +export interface TargetedResult { classes: TargetedClass[]; reasons: string[]; errors: string[]; rules: string } + +const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + +/** The classes a diff touches, in TARGETED_CLASSES order, with one reason per class; any input error returns all four. */ +export function targetedClasses(i: TargetedInput): TargetedResult { + const errors = (i.errors ?? []).map(e => `${e.code} (${e.input})`); + const s = i.scope; + if (!s) errors.push('scope unavailable (gstack-diff-scope did not run)'); + else if (s.SCOPE_ERROR) errors.push(`scope error: ${s.SCOPE_ERROR}`); + const code = i.paths.filter(p => classifyPath(p, i.root) === 'code'); + if (code.length && i.gateTagged === null) errors.push('gate scan unavailable'); + if (errors.length) return { classes: [...TARGETED_CLASSES], reasons: errors.map(e => `fail-closed: ${e}`), errors, rules: TARGETED_RULES }; + const why = new Map(); + const hit = (c: TargetedClass, reason: string) => { if (!why.has(c)) why.set(c, reason); }; + if ((i.gateTagged ?? 0) > 0) hit('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); + for (const p of code) { + const t = tokens(p); + if (s!.SCOPE_AUTH === 'true' && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', `auth path: ${p}`); + if (SECRET_PATH.test(p)) hit('secret', `secret path: ${p}`); + if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', `money path: ${p}`); + for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) { + if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', `money line: ${p}`); + if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', `secret line: ${p}`); + } + } + const classes = TARGETED_CLASSES.filter(c => why.has(c)); + return { classes, reasons: classes.map(c => why.get(c)!), errors: [], rules: TARGETED_RULES }; +} + +/** The static brief every targeted reader gets its blocks from; its sha256 is the `brief` the plan prints and the receipt records. */ +export const TARGETED_BRIEF_FILE = path.join(import.meta.dir, '..', 'review', 'specialists', 'targeted.md'); +export function targetedBriefSha(file = TARGETED_BRIEF_FILE): string { + try { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return 'missing'; } +} + +/** + * The patch the targeted reader reads: the patch-id of the whole committed diff from `mergeBase`, tests included, so a + * test-only fix that adds a gate hunk changes it. `uncommitted` with tracked edits or untracked files, `none` for an empty + * or unreadable diff; neither proves what a reader read. Not the receipt's product_patch_id (that leaves tests out). + */ +export function targetedPatchId(root: string, mergeBase: string): string { + const git = (args: string[], input?: string) => spawnSync('git', args, { cwd: root, input, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); + if (git(['diff', '--quiet', 'HEAD', '--']).status !== 0 || git(['ls-files', '--others', '--exclude-standard']).stdout?.trim()) return 'uncommitted'; + const diff = git(['diff', '--no-color', '--no-ext-diff', mergeBase, 'HEAD']); + if (diff.status !== 0 || !diff.stdout.trim()) return 'none'; + return git(['patch-id', '--verbatim'], diff.stdout).stdout?.trim().split(/\s+/)[0] || 'none'; +} + +/** A class list as printed and recorded: comma-joined, or `none`. */ +export const classList = (classes: readonly string[]) => (classes.length ? classes.join(',') : 'none'); + +/** Parse a printed class list (`money,gate` or `none`); an unknown name gives null. */ +export function parseClassList(text: string): TargetedClass[] | null { + const names = text.trim() === 'none' ? [] : text.split(',').map(s => s.trim()).filter(Boolean); + if (names.some(n => !(TARGETED_CLASSES as readonly string[]).includes(n))) return null; + return TARGETED_CLASSES.filter(c => names.includes(c)); +} + +/** The brief a targeted reader gets: review/specialists/targeted.md's preamble plus the `## ` block of each class that hit. */ +export function targetedBrief(text: string, classes: readonly TargetedClass[]): string { + const parts = text.replace(/\r\n/g, '\n').split(/\n(?=## )/); + const blocks = new Map(parts.slice(1).map(b => [/^## (\w+)/.exec(b)?.[1] ?? '', b.trimEnd()])); + const missing = classes.filter(c => !blocks.has(c)); + if (!classes.length || missing.length) throw new Error(`targeted brief: ${classes.length ? `no block for ${missing.join(', ')}` : 'no class to brief'}`); + return [parts[0]!.trimEnd(), ...classes.map(c => blocks.get(c)!)].join('\n\n'); +} diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index afab469e9..848450ef5 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -18,6 +18,7 @@ import { runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, type Ledger, type MeterKind, type Next, type Note, } from './ship-budget'; +import { callsLine, countCalls } from './ship-calls'; import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage'; export type Opts = Record; @@ -377,8 +378,10 @@ const noteText = (n: Note) => `${n.step}${n.label ? `:${n.label}` : ''} used=${n */ export function cmdNote(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['run', 'record', 'step', 'label', 'reading', 'credits', 'dispatched-at', 'dispatched-credits', 'readers']); - const step = parseStep(str(o, 'step')); - const label = str(o, 'label'); + const raw = str(o, 'step'); + const parent = raw && !(SHIP_STEPS as readonly string[]).includes(raw) ? /^(\d+)(?:\.\d+)+$/.exec(raw)?.[1] : undefined; + const step = parseStep(parent && (SHIP_STEPS as readonly string[]).includes(parent) ? parent : raw); + const label = str(o, 'label') ?? (step !== raw ? raw : undefined); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { if (l.closed_at) throw usageError(`run ${l.run} is closed`); if (l.stopped) return { stdout: [budgetLine(l, { call: null, next: l.stopped.next, projected: 0, verdict: 'stop' }), `STOP: reason=${l.stopped.reason} at Step ${l.stopped.step}; resume first: gstack-ship-budget resume --record ${l.record}`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`], exit: 3 }; @@ -396,6 +399,20 @@ export function cmdNote(argv: string[], ctx: Ctx): Outcome { }); } +/** calls: count parent calls from the host's transcript items (lib/ship-calls.ts). */ +export function cmdCalls(argv: string[]): Outcome { + const o = parseArgs(argv, ['events', 'since', 'until']); + const file = str(o, 'events'); + if (!file) throw usageError('calls needs --events '); + let doc: unknown; + try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { throw usageError(`calls --events ${file}: ${e.message}`); } + const d = doc as { items?: unknown; cursor?: unknown }; + const items = Array.isArray(doc) ? doc : Array.isArray(d.items) ? d.items : undefined; + if (!items) throw usageError(`calls --events ${file}: expected a JSON array of transcript items or { items: [...] }`); + const c = countCalls(items, { since: str(o, 'since'), until: str(o, 'until') }); + return { stdout: [callsLine(c, typeof d.cursor === 'string' ? d.cursor : 'none')], exit: 0 }; +} + /** resume: re-attach after a stop or a machine change; rebuild or restore a lost ledger; journal a cap raise. */ export function cmdResume(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['record', 'run', 'reading', 'credits', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words'], ['rebuild-from-record']); diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 11bcfcc12..5b7adb1a7 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -29,7 +29,7 @@ import { SMALL_DIFF_LINES, type Role } from './review-depth'; export const SHIP_STEPS = ['0.9', '1', '2', '3', '4', '5', '6', '6.5', '7', '8', '9', '10', '11', '11.5', '12', '13', '14', '14.5', '15', '16', '17', '18', '19', '20', '21', '22'] as const; /** Reviewer roles plus `docs`, the Step 14.5 documentation audit: each is one reader priced and reconciled the same way. */ -export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'docs'] as const satisfies readonly (Role | 'docs')[]; +export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted', 'docs'] as const satisfies readonly (Role | 'docs')[]; type MissingRole = Exclude; const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true; void ROLES_COMPLETE; diff --git a/lib/ship-calls.ts b/lib/ship-calls.ts new file mode 100644 index 000000000..159ce3c1c --- /dev/null +++ b/lib/ship-calls.ts @@ -0,0 +1,40 @@ +/** + * ship-calls — a driven /ship's parent calls, counted from the host's transcript events + * (Capy: tools.thread.get transcript items), so acceptance never rests on the agent's own + * count (GSTA-167 calls plan, C9). Every tool item is one parent call. A keep-alive is a + * wait, or a program that only reads status (subagent, operation or thread reads) plus at + * most one bash for its budget note; everything else is a decision call. A gap over 300 s + * between calls outlives the prompt cache, so the next call is billed fresh. + */ +export interface TranscriptItem { source?: string; tool?: string; calls?: Array<{ tool?: string }>; createdAt?: string } +export interface CallCount { total: number; decision: number; keepalive: number; idleGaps: number; maxGapS: number; first: string | null; last: string | null } + +const STATUS_READS = new Set(['subagent_get', 'subagent_list', 'operation_get', 'operation_list', 'thread_get']); +export const CACHE_TTL_S = 300; + +function isKeepalive(item: TranscriptItem): boolean { + if (item.tool === 'wait') return true; + if (item.tool !== 'execute') return false; + const calls = (item.calls ?? []).map(c => c.tool ?? ''); + const reads = calls.filter(t => STATUS_READS.has(t)).length; + const bash = calls.filter(t => t === 'bash').length; + return reads > 0 && reads + bash === calls.length && bash <= 1; +} + +export function countCalls(items: readonly TranscriptItem[], window: { since?: string; until?: string } = {}): CallCount { + const calls = items.filter(i => i.source === 'tool' && i.createdAt + && (!window.since || i.createdAt >= window.since) && (!window.until || i.createdAt <= window.until)) + .sort((a, b) => a.createdAt!.localeCompare(b.createdAt!)); + let idleGaps = 0, maxGapS = 0; + for (let k = 1; k < calls.length; k++) { + const gap = Math.round((Date.parse(calls[k]!.createdAt!) - Date.parse(calls[k - 1]!.createdAt!)) / 1000); + if (gap > CACHE_TTL_S) idleGaps++; + maxGapS = Math.max(maxGapS, gap); + } + const keepalive = calls.filter(isKeepalive).length; + return { total: calls.length, decision: calls.length - keepalive, keepalive, idleGaps, maxGapS, + first: calls[0]?.createdAt ?? null, last: calls[calls.length - 1]?.createdAt ?? null }; +} + +export const callsLine = (c: CallCount, source: string) => + `CALLS: total=${c.total} decision=${c.decision} keepalive=${c.keepalive} idle_gaps_over_300s=${c.idleGaps} max_gap_s=${c.maxGapS} first=${c.first ?? 'none'} last=${c.last ?? 'none'} source=${source}`; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index fedf5681e..11525cfb4 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -365,6 +365,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry } const shipped = bindShippedReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid]); if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); + lines.push(`${shipped.targetedLine} pr=${p.pr}`); const ready = loadReady(env.repo.keys, p.pr, info.headRefOid); if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); let screen = ready.screen; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 73c309fa0..ae3925ece 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -271,6 +271,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate gateLine(policy, attempt), receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), + ...(receipt.targetedLine ? [receipt.targetedLine] : []), ]; const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null; if (gate) lines.push(gate.line); diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index c3032a1f7..15d4bdbbf 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -11,7 +11,10 @@ * `review_loop_exception`: the owner's acceptance of a stop after a * fix-check edit under frozen scope (#3559), shaped like the docs exception. * The queue recomputes the depth the head needs from git objects - * (lib/review-collect.ts) and refuses a receipt below it. Kept beside + * (lib/review-collect.ts) and refuses a receipt below it. + * `review_targeted`: the targeted pass on money, gate, auth and secret code + * (lib/review-targeted.ts), which the queue reports beside the depth and does + * not refuse on yet (GST-257 PR 1). Kept beside * lib/ship-receipt.ts so that module stays under the size ratchet. */ import { spawnSync } from 'node:child_process'; @@ -19,6 +22,7 @@ import * as path from 'node:path'; import type { ShipReceipt } from './headless-artifacts'; import { collectCommit } from './review-collect'; import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth'; +import { TARGETED_CLASSES, classList, targetedClasses, type TargetedClass } from './review-targeted'; import { runScriptSync } from './script-invocation'; import { bindReceipt, type ReceiptBindingCode } from './ship-receipt'; import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs'; @@ -122,8 +126,46 @@ export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (co return out; } +export type ReviewTargeted = NonNullable; + +/** `write --review-targeted` input (gstack-review-dispatch verify --targeted-out), bound to the receipt's product patch-id here. */ +export function parseReviewTargeted(value: unknown, productPatchId: string): { ok: true; targeted: ReviewTargeted } | { ok: false; message: string } { + const bad = (message: string) => ({ ok: false as const, message }); + if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_targeted must be { classes, rules, brief_sha, status, findings, raised_by }'); + const v = value as Record; + for (const key of Object.keys(v)) if (!['classes', 'rules', 'brief_sha', 'status', 'findings', 'raised_by'].includes(key)) return bad(`review_targeted: unknown key ${JSON.stringify(key)}${key === 'product_patch_id' ? ' (write binds the pass to the receipt\'s product_patch_id)' : ''}`); + if (!Array.isArray(v.classes) || !v.classes.every(c => (TARGETED_CLASSES as readonly unknown[]).includes(c))) return bad(`review_targeted.classes must list ${TARGETED_CLASSES.join(', ')}`); + if (!text(v.rules) || !text(v.raised_by)) return bad('review_targeted.rules and raised_by must be non-empty one-line strings'); + if (typeof v.brief_sha !== 'string' || !/^[0-9a-f]{64}$/.test(v.brief_sha)) return bad('review_targeted.brief_sha must be the sha256 of review/specialists/targeted.md'); + if (!['done', 'failed', 'not-run'].includes(v.status as string)) return bad('review_targeted.status must be done, failed or not-run'); + if (typeof v.findings !== 'number' || !Number.isInteger(v.findings) || v.findings < 0) return bad('review_targeted.findings must be an integer >= 0'); + const classes = TARGETED_CLASSES.filter(c => (v.classes as string[]).includes(c)); + return { ok: true, targeted: { classes, rules: v.rules as string, brief_sha: v.brief_sha, product_patch_id: productPatchId, status: v.status as ReviewTargeted['status'], findings: v.findings, raised_by: (v.raised_by as string).trim() } }; +} + +/** The queue's report-only line: the classes the head needs against the classes a done targeted pass covered; never a refusal (GST-257 PR 1). */ +export function receiptTargetedLine(required: readonly TargetedClass[], r: ShipReceipt): string { + const rt = r.review_targeted; + const covered: readonly string[] = rt?.status === 'done' ? rt.classes : []; + const ok = required.every(c => covered.includes(c)); + return `REVIEW_TARGETED: required=${classList(required)} receipt=${rt ? classList(covered) : 'legacy'} verdict=${ok ? 'ok' : 'missing'}`; +} + export interface DepthVerdict { ok: boolean; line: string; message?: string } +/** The head's committed range against the base, read once for the depth verdict and the targeted line. */ +interface HeadInputs { baseSha: { ok: boolean; out: string }; c: ReturnType; scope: Record } +function headInputs(root: string, baseRef: string, head: string): HeadInputs { + const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); + const baseRev = baseSha.ok ? baseSha.out : baseRef; + return { baseSha, c: collectCommit(root, baseRev, head), scope: scopeOfRange(root, BIN_DIR, baseRev, head) }; +} +/** The receipt's recorded gate scan when it names this head; otherwise null (unavailable). */ +const boundScan = (r: ShipReceipt, head: string): number | null => { + const g = r.review_depth?.gate_scan; + return g && g.tagged !== null && /^[0-9a-f]{40,64}$/.test(g.head) && g.head === head.toLowerCase() ? g.tagged : null; +}; + /** * The depth the bound head needs against `baseRef`, recomputed from git * objects; the recorded gate scan counts only when it names the receipt's @@ -131,14 +173,13 @@ export interface DepthVerdict { ok: boolean; line: string; message?: string } * with no review_depth and no gstack_version predates depth receipts and is * accepted as legacy; one from a writer that records depth but lacks it is not. */ -export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string): DepthVerdict { +export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string, at?: HeadInputs): DepthVerdict { const rd = r.review_depth; if (!rd && !r.gstack_version) return { ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' }; - const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); - const baseRev = baseSha.ok ? baseSha.out : baseRef; - const c = collectCommit(root, baseRev, head); - const scanBound = !!rd && rd.gate_scan.tagged !== null && /^[0-9a-f]{40,64}$/.test(rd.gate_scan.head) && rd.gate_scan.head === head.toLowerCase(); - const need = depthOfCommitInput({ ...c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), gateTagged: scanBound ? rd!.gate_scan.tagged : null }); + const { baseSha, c, scope } = at ?? headInputs(root, baseRef, head); + const tagged = boundScan(r, head); + const scanBound = tagged !== null; + const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged }); const have = rd?.depth; const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`; const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`; @@ -148,17 +189,20 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` }; } -/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept. */ +/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. */ export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]): - | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string } - | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string } { + | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string } + | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } { const b = bindReceipt(body, pr, accepted); if (!b.ok) return b; const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok; const problems = exceptionProblems(body, b.receipt, ancestor); if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line }; - const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head); - if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line }; - return { ...b, depthLine: d.line }; + const at = headInputs(root, baseRef, b.head); + const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head, at); + const gateTagged = boundScan(b.receipt, b.head) ?? (gateScanNeeded(at.c.paths, at.c.root) ? null : 0); + const targetedLine = receiptTargetedLine(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt); + if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line, targetedLine }; + return { ...b, depthLine: d.line, targetedLine }; } diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 6cc75afa4..fd64130c5 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -19,7 +19,7 @@ const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', - 'review_loop_exception', 'carry', + 'review_loop_exception', 'review_targeted', 'carry', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ diff --git a/lib/ship-release.ts b/lib/ship-release.ts index 9e379303d..a1bf533fc 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -221,8 +221,9 @@ export function push(argv: readonly string[], cwd: string): Report { if (branch !== flag('--branch')) return r.stop(`HEAD is on ${branch}, not the branch Step 1 saved (${flag('--branch')}): check out the saved branch, then rerun push`); const guard = bin(cwd, 'gstack-config', ['get', 'redact_prepush_hook'], 30_000).stdout.trim() || 'false'; const prompted = fs.existsSync(path.join(resolveStateRoot(process.env), '.redact-prepush-prompted')); - r.add(`REDACT_PREPUSH: ${guard} prompted=${prompted ? 'yes' : 'no'}`); - if (guard !== 'true' && !prompted) return r.stop('Step 17\'s one-time pre-push guard offer is unanswered: run the guard block and ask it, then rerun push'); + const deferred = guard !== 'true' && !prompted && process.env.GSTACK_SESSION_KIND === 'unattended'; + r.add(`REDACT_PREPUSH: ${guard} prompted=${prompted ? 'yes' : 'no'}${deferred ? ' deferred=unattended' : ''}`); + if (guard !== 'true' && !prompted && !deferred) return r.stop('Step 17\'s one-time pre-push guard offer is unanswered: run the guard block and ask it, then rerun push'); const gateOpen = Number(flag('--gate-open') ?? '0'); if (gateOpen > 0) { if (!hostedOrigin(cwd)) return r.stop('open-PR lookup unavailable: origin is a local path, not a hosted remote gh can map; it must succeed before pushing open gate findings'); diff --git a/package.json b/package.json index 553138ea6..23a605a04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.354", + "version": "1.91.359", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/review/specialists/targeted.md b/review/specialists/targeted.md new file mode 100644 index 000000000..34455128e --- /dev/null +++ b/review/specialists/targeted.md @@ -0,0 +1,38 @@ +# Targeted Review Brief: money, gate, auth and secret code + +Scope: every diff whose `gstack-review-plan` TARGETED line names a class, at every depth; the reader gets the preamble and the block of each class that hit. +Output: JSON objects, one finding per line. Schema: +{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"money|gate|auth|secret","summary":"...","fix":"...","fingerprint":"path:line:category","specialist":"targeted"} +Optional: line, fix, fingerprint, evidence, test_stub, cause. +If no findings: output `NO FINDINGS` and nothing else. `NO FINDINGS` is a complete result, not a failure to look. + +--- + +Read-only: read the diff and the code it reaches, and edit, commit, post or run nothing that writes. +Read deleted lines as closely as added ones: a removed cap, check or redaction is the change under review. +Follow each changed value to where it is enforced: a cap, check or redaction that exists but is never reached is missing. + +CRITICAL is the blocking floor: a finding that would fail a test, break production, lose data or secrets, or open a security hole (injection, auth bypass, secret exposure). Everything else is INFORMATIONAL. + +## money +The checklist's Forced-CRITICAL line covers this block (review/checklist.md): uncapped spend, "a paid call, retry or loop with no cap", is "CRITICAL on sight", and no lane, specialist, dedup step or saved decision downgrades it. +- A paid call (a model API, a cloud VM, a paid search) reachable without a cap, or a retry, loop or fan-out around one with no bound on count or spend. +- A cap read but not enforced: checked after the spend, compared in the wrong unit (cents and dollars, tokens and USD), or a missing value read as unlimited. +- A cap check removed, loosened, or moved behind a flag that defaults to off. +- Spend recorded wrong: an estimate stored as actual cost, a failed call recorded as zero, a double count, or a ledger write that can be skipped. + +## gate +- A check that fails open: an error, timeout, missing input or unreadable file read as a pass, or an empty result read as green. +- A threshold, required check, allowlist or rule loosened, skipped or made optional; a test skipped or deleted together with the guard it covered. +- A verdict bound to the wrong subject: another commit, tree, branch, PR or run than the one being merged, or a stale result reused. +- A rule hash, pin or ratchet that detects such an edit changed in the same diff without the evidence it asks for. + +## auth +- A bypass: a route, command or path reachable without the identity check its siblings run, or a default that allows. +- A permission checked on the wrong subject: the caller instead of the owner, a role read from input, or a check made before the object is resolved. +- A session or token accepted after expiry, revocation or a role change, or a callback that skips its single-use state value. + +## secret +- A secret written where others can read it: logs, error messages, PR bodies, comments, artifacts, URLs, readable files, or a child process's arguments. +- A redaction, scrub, mask or file mode removed or bypassed, or a new output path that skips the existing redactor. +- A key read from a broader source than before, a fallback to a hardcoded value, or a real credential committed in code or fixtures. diff --git a/ship/DRIVER.md b/ship/DRIVER.md index 4221cc0b1..1e9c0cedc 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -7,7 +7,7 @@ Read this instead of `ship/SKILL.md` when a parent agent on any host runs /ship the `GSTACK_LAUNCH` assignments your installer printed (`. `), never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence. -**Rules.** Each phase below is one call: one program (Capy: an execute program; else a shell +**Rules.** Each phase below is one call: one program (Capy: an execute program; else a script) that runs the bins, reads, dispatches and waits, returning only result lines and reader outputs; print them verbatim. The model keeps only lead judgment, fixes, CHANGELOG and PR prose, the gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names @@ -29,17 +29,19 @@ file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` r small model at low effort; their findings go to the PR body. **Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's -section read or a fix-check. A pass that fixes something repeats Phases 3-4. +section read or a fix-check, plus a keep-alive per 200 s waited. A fix repeats Phases 3-4. **Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends -with `gstack-ship-budget note --step --reading R --credits C`, R and C read in that program -(Capy: your own jam's `cost.tokens` and `cost.credits`). No check per step, no reservation, no meter-only +with `gstack-ship-budget note --step --reading R --credits C --record `, R and C read in that program +(Capy: your jam's `cost.tokens`, `cost.credits`). No check per step, no reservation, no meter-only call, no handoff; under `--token-cap` a note past the cap prints `verdict=stop`, the one stop check. A dispatching program takes `BEFORE` (R and C) as its readers start and passes `--dispatched-at --dispatched-credits --readers ` with the reading after the last settles. -**Waits.** No call waits past 240 s: a program returns whatever still runs at 240 s; the next call resumes (Capy: `wait` 240 s on it). A small -waiting child saves nothing. Past 100 minutes, stop. +**Waits.** No call waits past 200 s: a program returns what still runs 200 s after its first line; the next +call resumes the poll. Lanes, pregate and `finish` run in background. Never end the turn while +readers run (each wakes it). A small waiting child saves nothing. Past 100 minutes, stop. Returns: +≤32 KB per program; split longer reads at headings, each byte once, before first use. **One program per pass.** Each review pass, Step 11 and the Step 14.5 audit is one program, and the parent makes no other call while it runs: (1) `gstack-review-plan --base ` with the pass's @@ -49,8 +51,7 @@ flags (its `DISPATCH:` lines; Step 14.5 has one reader, `docs`); (2) `gstack-rev (5) a poll until every reader settles (status `done` or `failed`, or a final summary; a fresh `idle` has not run); (6) `note` with the reading pair; a later batch (the Red Team, given the specialists' collected lines) repeats (3)-(6); (7) the collect: return only each reader's -final output and status, via `collect`. The next call applies the section's merge, judgment, fixes and persistence; -it never walks the dispatch steps call by call. +final output and status, via `collect`; it never walks the dispatch steps call by call. ## 1. Load: Step 0 @@ -60,16 +61,15 @@ The install check and this file. `gstack-ship-budget start --record --mode observe [--token-cap ] --reading R --credits C`, then `gstack-ship-preflight --base --lane