v1.91.359.0 stamp: train gate.train-3076d34e2808-89f203300498-7e025e S1 (#3672)

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 82590dc1bb
56 files changed
+1255 -135

No files matched your search

+73
View File
@@ -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 <name> <verdict>` line each with at most 40 failing lines. `trust` records consent. `status` prints `READY_FOR_SHIP tree=<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 <targeted_patch_id>` 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 <file>` 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 @<file>` 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, "<digest>"]`, then `[46, "<digest>"]`) 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: <root>/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 <file>`** 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.
+1 -1
View File
@@ -1 +1 @@
1.91.354.0
1.91.359.0
+1 -1
View File
@@ -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,
+13 -2
View File
@@ -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 <old> -> <new>` | `skipped (<reason>)`
# 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 <old> -> <new>` | `skipped (<reason>)`
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
# --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" ] \
+43 -7
View File
@@ -7,7 +7,7 @@
* gstack-review-dispatch sent --ledger <f> --role <role> [--name <n>] --sent <file> the prompt actually passed; mismatch fails
* gstack-review-dispatch collect --ledger <f> --role <role> [--name <n>] --output <file> --merged <file>
* gstack-review-dispatch judge --ledger <f> --merged <file> --judged <file> no drop, no downgrade
* gstack-review-dispatch verify --ledger <f> replay every hash and the judgment
* gstack-review-dispatch verify --ledger <f> [--targeted-out <file>] 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 <f> --checklist <path> (default <root>/review/specialists/<name>.md; red-team.md for red-team)
--checklists <a,b> (specialist-batched: the plan's checklists= names, or paths) --findings-file <f> --specialists <n> (red-team)
--fix-list <f> --hunks <f> --tests <f> [--callers <f>] (fix-check)
--classes <money,gate,auth,secret> (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 <out>/<role>[-<name>].prompt.md, records it in the ledger (default <out>/review-ledger.jsonl) and prints
BRIEF: reader=<role/name> sha256=<h> bytes=<n> file=<path>. Pass that file's content as the reader's prompt, unchanged.
sent --ledger <f> --role <role> [--name <n>] --sent <file>
@@ -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 <f>
verify --ledger <f> [--targeted-out <file>]
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 <dir>\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;
}
+16 -6
View File
@@ -4,14 +4,16 @@
*
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers] [--json]
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
* [--targeted-ran <targeted_patch_id>] [--json]
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
*
* Reads the diff against the merge base with <ref> (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 <d> pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full
--targeted-ran <id> 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})` : ''}`);
+2
View File
@@ -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); }
+3 -2
View File
@@ -23,13 +23,14 @@
* resume --record <path>|--run <id> [--reading <n>] [--meter <m>] [--session <id>] [--token-cap <n> --approved-by <who> --words <text>]
* [--rebuild-from-record]
* close --run|--record [--reading <n>] [--approved-by <who> --words <text>]
* calls --events <file> [--since <iso>] [--until <iso>] (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<string, () => 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');
+12 -3
View File
@@ -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 <jso
"Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
unreadable input records full).
--review-targeted <json|@file> ({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 <autoplan run dir> (repeatable; resolves plan-sha citations) (W2: a class declared in origin/<base>'s
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
--out <file> --body <pr-body.md>. 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<typeof parseReviewTargeted> | 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; }
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
+2
View File
@@ -27,5 +27,7 @@ ask format is gstack docs/captain.md, The ask format.
|---|---|---|---|
| <code> | <files or area> | <building / READY / train slot i / blocked on …> | <age> |
A thread the controller archives leaves this table in the same turn.
Stalls to act on: a lane with no report for longer than <N hours>, a READY older than
`FILL_HOURS` with no train, a slot call unanswered for longer than <N minutes>.
+1 -1
View File
@@ -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 <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam 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 <n> --credits <c>`, 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 <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation <id>` 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 <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (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 <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` 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 <rec> --step <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (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 <file>` on your jam's transcript since /ship started. A small waiting child saves nothing.
## Plan, approve, ship
-12
View File
@@ -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 <name> <verdict>` line each with at most 40 failing lines. `trust` records consent. `status` prints `READY_FOR_SHIP tree=<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`.
+2 -1
View File
@@ -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
+12 -1
View File
@@ -47,7 +47,13 @@ upgrade: a reviewer later asked to run a workflow runs the same installer
install in place to --for <skill> (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=<n> … (UPGRADE_SKIPPED); fix: <root>/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 <skill>` 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: <root>/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 (<variable>)`. `--check` never moves the checkout;
behind origin it prints `gstack-capy-session: behind origin/<branch>:
+9 -1
View File
@@ -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, "<digest>"]`), 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
+56 -2
View File
@@ -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=<money,gate,auth,secret|none> rules=<hash>
brief=<sha256 of the brief> targeted_patch_id=<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 <targeted_patch_id>` 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 <classes>`
renders its brief; `verify --targeted-out <file>` writes the record that
`gstack-ship-receipt write --review-targeted @<file>` puts in the receipt.
`gstack-ship-queue ready` and `train build` print
`REVIEW_TARGETED: required=<classes|none> receipt=<classes|none|legacy>
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 <REVIEW_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,
+11
View File
@@ -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 <file> [--since <iso>] [--until <iso>]` 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 <cost.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,
+2 -1
View File
@@ -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
+3
View File
@@ -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' } } } } } },
},
+35 -3
View File
@@ -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, "<digest>"]`, 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(`(?<![\\w.])${escape(value)}(?![\\w.])`) : new RegExp(`['"\`]${escape(value)}['"\`]`);
const row = (raw: string) => 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)}|(?<![\\w-])${escape(base)})(?![\\w-])`, 'm');
@@ -81,7 +92,22 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
try { text = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
if (!(nameRe.test(text) || (!nameRequired && moduleRe.test(text)))) continue;
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!)) { hits.push(`${rel}:${i + 1}`); break; }
const table: number[] = [];
const tableHasNew: boolean[] = [];
for (let i = 0; i < lines.length; i++) {
if (!/^[ \t]*\[/.test(lines[i]!)) { table.push(-1); continue; }
if (table[i - 1] === undefined || table[i - 1] === -1) tableHasNew.push(false);
table.push(tableHasNew.length - 1);
if (newRowRe.test(lines[i]!)) tableHasNew[tableHasNew.length - 1] = true;
}
let extended: boolean | undefined;
const skip = (i: number) => {
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})`);
}
+11 -1
View File
@@ -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<string, string | undefined>;
@@ -119,6 +121,14 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/<base> 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 ?? '' });
}
+9 -5
View File
@@ -22,6 +22,8 @@ export interface Collected extends ClassInput {
stampOnly: string[];
/** Added lines per path, for scanNumbers. */
added: Record<string, string[]>;
/** Deleted lines per path, for the targeted classes (lib/review-targeted.ts). */
removed: Record<string, string[]>;
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<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'added'> {
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'added' | 'removed'> {
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 <base>) and rerun' }); return empty; }
}
@@ -69,6 +71,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
const statusParts = status.out.split('\0');
const deleted = statusParts.flatMap((s, k) => (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p));
const added: Record<string, string[]> = {};
const removed: Record<string, string[]> = {};
const changed: Record<string, string[]> = {};
let cur = '';
let minus = '';
@@ -83,10 +86,11 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
}
if (!cur || scratch(cur) || line.startsWith('@@')) continue;
if (line[0] === '+') (added[cur] ??= []).push(line.slice(1));
if (line[0] === '-') (removed[cur] ??= []).push(line.slice(1));
if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
}
const stampOnly = perPath.map(e => 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 <base>) 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);
+9 -3
View File
@@ -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<Role, Effort>;
export const ROLE_EFFORT = { specialist: 'medium', 'red-team': 'full', 'adversarial-native': 'full', 'fix-check': 'full', 'outside-voice': 'host', targeted: 'full' } as const satisfies Record<Role, Effort>;
/** 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<DiffClass, number> = { 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 };
}
+2 -1
View File
@@ -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<string, number> = { 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;
}
+4 -4
View File
@@ -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<string, string> = { 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<string, number>;
/** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */
est_input_tokens?: number;
+132
View File
@@ -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<string, readonly string[]>;
removed?: Record<string, readonly string[]>;
/** gstack-diff-scope output (SCOPE_* keys); missing or SCOPE_ERROR fails closed. */
scope?: Record<string, string>;
/** 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<TargetedClass, string>();
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 `## <class>` 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');
}
+19 -2
View File
@@ -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<string, string | true>;
@@ -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 <file of the host\'s transcript items>');
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']);
+1 -1
View File
@@ -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<Role, typeof DISPATCH_ROLES[number]>;
const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true;
void ROLES_COMPLETE;
+40
View File
@@ -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}`;
+1
View File
@@ -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 <pins>`, '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;
+1
View File
@@ -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);
+57 -13
View File
@@ -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<ShipReceipt['review_targeted']>;
/** `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<string, unknown>;
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<typeof collectCommit>; scope: Record<string, string> }
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 };
}
+1 -1
View File
@@ -19,7 +19,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'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. */
+3 -2
View File
@@ -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');
+1 -1
View File
@@ -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",
+38
View File
@@ -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.
+17 -18
View File
@@ -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 (`. <the GSTACK_LAUNCH_FILE it 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 <last 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 <last step> --reading R --credits C --record <rec>`, 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 <R> --dispatched-credits <C> --readers <n>` 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 <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 <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (the test commands AGENTS.md or
CLAUDE.md names): `STATUS:`, `DIFF:`, `PR:`, `CARRY:`, `SCOPE:`, `MERGE:`, `DISTRIBUTION:`,
`POLICY:`, `RUNTIME_LINES:`, `PREGATE:`, `LANE:`. `CARRY: carried` ends the run on its `GSTACK_RESULT:`
CLAUDE.md names). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
is `count unavailable`. In-branch failures stop the ship; after a repair rerun with `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the project's eval selector and pre-merge command (detached past 30 min); else
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Steps 6.5 and 10 run only if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map, the
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. The same program reads
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read
`sections/review-army.md`, `sections/adversarial.md`, `sections/documentation.md`, the todos and commits
files, and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is `needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
@@ -77,7 +77,7 @@ Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never re
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written), Step 9.2.1's QA reader (that step of
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Step 9's plan and start, Step 9.1's
specialists and Step 9.2's Red Team as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
@@ -88,7 +88,7 @@ Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
`JUDGMENT: ok` before any fix), fixes and persistence in
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
`scoped` reruns Step 5 (`--tests-only`) and Phases 3-4; `done`: the same program runs Step 11 from
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated; `done`: the same program runs Step 11 from
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
prints `ADVERSARIAL: skipped (review plan)`).
@@ -110,17 +110,16 @@ One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit features in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
Commit in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
(pushed or ALREADY_PUSHED). Its pre-push offer STOP runs Step 17's guard block (`HOOK_STATE:`,
`HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`); unattended takes the recommended install. Never force-push.
(pushed or ALREADY_PUSHED). Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block; with `CAPY_PR: on` (Step 19-C) What changed, Why, Blast radius, Approvals (`gstack-approvals render`); Summary, Test Coverage, Pre-Landing Review (`REVIEW_LOOP:`, `Gate edits:`, deferred, notes), Exploratory QA, Plan Completion, Verification Results, Issues, TODOS, Documentation, Test plan. `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; put both in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
+17 -18
View File
@@ -5,7 +5,7 @@ Read this instead of `ship/SKILL.md` when a parent agent on any host runs /ship
the `GSTACK_LAUNCH` assignments your installer printed (`. <the GSTACK_LAUNCH_FILE it 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
@@ -27,17 +27,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 <last 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 <last step> --reading R --credits C --record <rec>`, 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 <R> --dispatched-credits <C> --readers <n>` 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 <base>` with the pass's
@@ -47,8 +49,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
@@ -58,16 +59,15 @@ The install check and this file.
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (the test commands AGENTS.md or
CLAUDE.md names): `STATUS:`, `DIFF:`, `PR:`, `CARRY:`, `SCOPE:`, `MERGE:`, `DISTRIBUTION:`,
`POLICY:`, `RUNTIME_LINES:`, `PREGATE:`, `LANE:`. `CARRY: carried` ends the run on its `GSTACK_RESULT:`
CLAUDE.md names). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
is `count unavailable`. In-branch failures stop the ship; after a repair rerun with `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the project's eval selector and pre-merge command (detached past 30 min); else
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Steps 6.5 and 10 run only if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map, the
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. The same program reads
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read
`sections/review-army.md`, `sections/adversarial.md`, `sections/documentation.md`, the todos and commits
files, and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is `needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
@@ -75,7 +75,7 @@ Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never re
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
One program: the audit readers `READER:` names (Step 7 from `sections/test-coverage.md`, Step 8 from
`sections/plan-completion.md`, each run as written), Step 9.2.1's QA reader (that step of
`sections/plan-completion.md`, each run as written; Step 7 may write tests, so it settles first), Step 9.2.1's QA reader (that step of
`sections/review-army.md` as its brief) and the pass (above): Step 9's plan and start, Step 9.1's
specialists and Step 9.2's Red Team as written. `Coverage:` and Step 8's line come from `--audit` when no
reader ran.
@@ -86,7 +86,7 @@ Steps 8.1 and 8.2 (`Scope Check:`) on the todos and commits files, then the core
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
`JUDGMENT: ok` before any fix), fixes and persistence in
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
`scoped` reruns Step 5 (`--tests-only`) and Phases 3-4; `done`: the same program runs Step 11 from
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated; `done`: the same program runs Step 11 from
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
prints `ADVERSARIAL: skipped (review plan)`).
@@ -108,17 +108,16 @@ One re-audit at most.
## 7. Release: Steps 15, 16, 17
Commit features in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
Commit in bisectable chunks, then `gstack-ship-release finish --base <base> --reviewed-tree <t>
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
(pushed or ALREADY_PUSHED). Its pre-push offer STOP runs Step 17's guard block (`HOOK_STATE:`,
`HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`); unattended takes the recommended install. Never force-push.
(pushed or ALREADY_PUSHED). Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block; with `CAPY_PR: on` (Step 19-C) What changed, Why, Blast radius, Approvals (`gstack-approvals render`); Summary, Test Coverage, Pre-Landing Review (`REVIEW_LOOP:`, `Gate edits:`, deferred, notes), Exploratory QA, Plan Completion, Verification Results, Issues, TODOS, Documentation, Test plan. `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the ship metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; put both in the report), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
+2 -1
View File
@@ -4,7 +4,8 @@
`DISPATCH: adversarial-native` and the outside voice only on
`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0;
above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`;
with neither line, take decision 3 of Finish the adversarial phase. Under a token cap,
with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
and add `--reservation <id>` to the `--start adversarial-review` call below.
+2 -1
View File
@@ -2,7 +2,8 @@
`DISPATCH: adversarial-native` and the outside voice only on
`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0;
above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`;
with neither line, take decision 3 of Finish the adversarial phase. Under a token cap,
with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
and add `--reservation <id>` to the `--start adversarial-review` call below.
+8 -5
View File
@@ -150,16 +150,19 @@ describe('--for review-voice through the no-Drive brief', () => {
expect(warm.ms).toBeLessThan(WARM_BUDGET_MS);
}, 300_000);
test('origin moves: the same brief fast-forwards the checkout before it certifies', () => {
// GST-290: the reviewer's machine may share this checkout with a lane mid-workflow.
test('origin moves: the same brief never moves the checkout; it certifies the installed revision and reports behind', () => {
const head = git(['rev-parse', 'HEAD'], root);
fs.writeFileSync(path.join(origin, 'REVIEW_VOICE_FIXTURE.md'), 'moved\n');
git(['add', '-A'], origin);
git(['commit', '-qm', 'origin moves'], origin);
const tip = git(['rev-parse', 'HEAD'], origin);
const moved = brief();
expect(moved.status, moved.out + moved.stderr).toBe(0);
expect(moved.out).toContain(`SELF_UPGRADE: done ${VERSION} -> ${VERSION}`);
expect(git(['rev-parse', 'HEAD'], root)).toBe(tip);
expect(trailer(moved.out)).toBe(OK_TRAILER);
expect(moved.out).toContain(`SELF_UPGRADE: skipped (review-voice never self-upgrades a shared checkout; fix: ${root}/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED))`);
expect(moved.out).not.toContain('SELF_UPGRADE: done');
expect(git(['rev-parse', 'HEAD'], root)).toBe(head);
expect(trailer(moved.out)).toStartWith(`gstack: ok ${VERSION} for=review-voice (pins: skipped (helper runtime); behind=1 `);
expect(trailer(moved.out)).toContain('(UPGRADE_SKIPPED)');
}, 300_000);
test('a review-voice check never narrows an ok verdict a fuller install wrote for this HEAD and project', () => {
+53 -1
View File
@@ -85,7 +85,7 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) {
if ((opts.cloneAt ?? 'A') === 'A') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.1\n');
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', ...UPGRADE_DEPS]) {
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'bin/gstack-doctor-components.sh', ...UPGRADE_DEPS]) {
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
}
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
@@ -420,6 +420,58 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () =>
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
});
// GST-290: a dispatched reviewer's install ran with no lease of its own, fast-forwarded the
// shared checkout and rewrote the verdict to for=review-voice, so the lane's next autoplan
// admission refused (INSTALL_UNVERIFIED). A review-voice install never moves the checkout.
describe('--for review-voice never self-upgrades a shared checkout', () => {
const blocked = (root: string) => `review-voice never self-upgrades a shared checkout; fix: ${root}/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)`;
const behindFixture = () => {
const f = fixture({ cloneAt: 'B' });
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
return f;
};
test('install mode behind origin with no lease: skipped with the reason, HEAD stays, and the installer gets behind, gstack-upgrade as the fix and the blocked reason', () => {
const f = behindFixture();
const r = f.run(['--for', 'review-voice']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`);
expect(r.stdout).not.toContain('SELF_UPGRADE: done');
expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade blocked=${blocked(f.root)}`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(f.run(['--for', 'review-voice'], { GSTACK_UPGRADE_FORCE: '1' }).stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('install mode behind origin with bin/gstack-doctor-components.sh missing: still skipped (the profile check fails closed for review-voice)', () => {
const f = behindFixture();
f.git(['update-index', '--assume-unchanged', 'bin/gstack-doctor-components.sh']);
fs.rmSync(path.join(f.root, 'bin/gstack-doctor-components.sh'));
const r = f.run(['--for', 'review-voice']);
expect(r.stdout).toContain(`SELF_UPGRADE: skipped (${blocked(f.root)})\n`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
test('install mode on a current checkout: none (current), and no blocked reason', () => {
const f = fixture({ cloneAt: 'B' });
f.git(['fetch', '-q', 'origin', 'main']);
f.git(['merge', '--ff-only', '-q', 'FETCH_HEAD']);
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']);
const r = f.run(['--for', 'review-voice']);
expect(r.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m);
expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan behind=none to=none fix=none');
});
test('--check behind origin: the fix is gstack-upgrade, not the install rerun (which would not move a review-voice checkout)', () => {
const f = behindFixture();
const r = f.run(['--check', '--for', 'review-voice']);
expect(r.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 to=1.0.2 fix=${f.root}/bin/gstack-upgrade`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
});
test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => {
const f = fixture({ cloneAt: 'B' });
write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755);
+1 -1
View File
@@ -58,7 +58,7 @@ export function shipFixture(base: Record<string, string> = {}, feat: Record<stri
must(gitIn(work, ['add', '-A']), 'add feat'); must(gitIn(work, ['commit', '-qm', 'feat']), 'feat commit');
const { GSTACK_SESSION_KIND: _k, GSTACK_HEADLESS: _h, OPENCLAW_SESSION: _o, CI: _c, GITHUB_ACTIONS: _g, ...ambient } = process.env;
const env = { ...ambient, PATH: `${stub}:${process.env.PATH}`, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com',
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GSTACK_SESSION_KIND: 'interactive' };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const fx: ShipFx = {
root, work, origin, state,
+55
View File
@@ -223,6 +223,42 @@ describe('literals (pure)', () => {
expect(consumersOf(root, all, { name: 'TIMEOUT_MS', old: '5000', new: '9000', file: 'lib/retry.js' })).toEqual(['test/retry.test.ts:1', 'test/retry-path.test.ts:2', 'test/retry-bare.test.ts:2']);
fs.rmSync(root, { recursive: true, force: true });
});
test('consumersOf: a history row is skipped only when this branch added the new-value row to the same table; other old-value lines still count (GST-344)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-history-'));
const nl = String.fromCharCode(10);
const head = "import { PROOF_CHECKS } from '../src/proof-checks.ts';";
const rows = (...ns: number[]) => ns.map(n => ` [${n}, "digest-${n}"],`);
const files: Record<string, [string[], string[] | null]> = {
'test/history.test.ts': [[head, 'const versions = [', ...rows(44, 45, 46), '];', 'expect(PROOF_CHECKS).toBe(46);'], [head, 'const versions = [', ...rows(44, 45), '];', 'expect(PROOF_CHECKS).toBe(45);']],
'test/newest-first.test.ts': [[head, 'const versions = [', ...rows(46, 45, 44), '];'], [head, 'const versions = [', ...rows(45, 44), '];']],
'test/unextended.test.ts': [[head, 'const versions = [', ...rows(44, 45), '];'], [head, 'const versions = [', ...rows(44, 45), '];']],
'test/stale-later.test.ts': [[head, 'const versions = [', ...rows(44, 45, 46), '];', 'expect(PROOF_CHECKS).toBe(45);'], [head, 'const versions = [', ...rows(44, 45), '];', 'expect(PROOF_CHECKS).toBe(45);']],
'test/two-tables.test.ts': [[head, 'const a = [', ...rows(44, 45), '];', 'const b = [', ...rows(45, 46), '];'], [head, 'const a = [', ...rows(44, 45), '];', 'const b = [', ...rows(45), '];']],
'test/boundary.test.ts': [[head, 'test.each([', ...rows(45, 46), '])("%d", () => {});'], [head, 'test.each([', ...rows(45, 46), '])("%d", () => {});']],
'test/new-file.test.ts': [[head, 'const versions = [', ...rows(45, 46), '];'], null],
'test/same-line.test.ts': [[head, 'const versions = [', ' [45, "digest-45"], expect(PROOF_CHECKS).toBe(45),', ' [46, "digest-46"],', '];'], [head, 'const versions = [', ' [45, "digest-45"], expect(PROOF_CHECKS).toBe(45),', '];']],
};
for (const [rel, [now]] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), now.join(nl) + nl); }
const baseText = (rel: string) => files[rel]?.[1]?.join(nl) ?? null;
const lineOf = (rel: string, text: string) => `${rel}:${files[rel]![0].indexOf(text) + 1}`;
expect(consumersOf(root, Object.keys(files), { name: 'PROOF_CHECKS', old: '45', new: '46', file: 'src/proof-checks.ts' }, baseText).sort()).toEqual([
lineOf('test/boundary.test.ts', ' [45, "digest-45"],'),
lineOf('test/same-line.test.ts', ' [45, "digest-45"], expect(PROOF_CHECKS).toBe(45),'),
lineOf('test/stale-later.test.ts', 'expect(PROOF_CHECKS).toBe(45);'),
lineOf('test/two-tables.test.ts', ' [45, "digest-45"],'),
lineOf('test/unextended.test.ts', ' [45, "digest-45"],'),
].sort());
fs.writeFileSync(path.join(root, 'test/versions.test.ts'), ["import { SCHEMA } from '../src/schema.ts';", 'const history = [', " ['v3', 'a'],", ' ["v4", \'b\'],', '];', ''].join(nl));
const strings = (rel: string) => rel === 'test/versions.test.ts' ? ["import { SCHEMA } from '../src/schema.ts';", 'const history = [', " ['v3', 'a'],", '];'].join(nl) : null;
expect(consumersOf(root, ['test/versions.test.ts'], { name: 'SCHEMA', old: "'v3'", new: "'v4'", file: 'src/schema.ts' }, strings)).toEqual([]);
expect(consumersOf(root, ['test/versions.test.ts'], { name: 'SCHEMA', old: "'v3'", new: "'v4'", file: 'src/schema.ts' }, () => undefined)).toEqual(['test/versions.test.ts:3']);
fs.writeFileSync(path.join(root, 'test/semver.test.ts'), ["import { RELEASE } from '../src/release.ts';", 'const history = [', " ['1.2.3', 'a'],", " ['1.2.4', 'b'],", '];', ''].join(nl));
expect(consumersOf(root, ['test/semver.test.ts'], { name: 'RELEASE', old: "'1.2.3'", new: "'1.2.4'", file: 'src/release.ts' }, () => null)).toEqual([]);
expect(consumersOf(root, ['test/semver.test.ts'], { name: 'RELEASE', old: "'1.2.3'", new: "'1.2.4'", file: 'src/release.ts' }, () => " ['1.2.4', 'b'],")).toEqual(['test/semver.test.ts:3']);
fs.writeFileSync(path.join(root, 'test/big.test.ts'), [head, 'const versions = [', ...Array.from({ length: 20_000 }, () => ' [45, "x"],'), ' [46, "y"],', '];', ...Array.from({ length: 20_000 }, () => ''), ''].join(nl));
expect(consumersOf(root, ['test/big.test.ts'], { name: 'PROOF_CHECKS', old: '45', new: '46', file: 'src/proof-checks.ts' }, () => ['', ...Array.from({ length: 20_000 }, () => '')].join(nl))).toEqual([]);
fs.rmSync(root, { recursive: true, force: true });
});
test('runner summaries: bun, node --test, vitest, jest, pytest, zero-run', () => {
expect(parseSummary(' 12 pass\n 1 fail\nRan 13 tests across 2 files.')).toEqual({ ran: 13, failed: 1 });
expect(parseSummary('# tests 4\n# pass 4\n# fail 0')).toEqual({ ran: 4, failed: 0 });
@@ -369,6 +405,25 @@ describe('gstack-pregate (bin)', () => {
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
});
test('GST-344: a history table the branch extends passes literals through the merge-base read; one it does not extend fails', () => {
const history = (...ns: number[]) => ["const { MAX_RETRIES } = require('../lib/retry.js');", 'const versions = [', ...ns.map(n => ` [${n}, 'digest-${n}'],`), '];', ''].join('\n');
const repo = fixture({ baseFiles: { 'test/history.test.js': history(2, 3) } });
const bump = { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n", 'test/retry.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\nconst { MAX_RETRIES } = require('../lib/retry.js');\ntest('retries four times', () => assert.strictEqual(MAX_RETRIES, 4));\n" };
commit(repo, bump);
const stale = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
expect(stale.status).toBe(EXIT.fail);
expect(stale.out).toContain('literals: test/history.test.js:4: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
commit(repo, { 'test/history.test.js': history(2, 3, 4) });
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
expect(r.out).toMatch(/^preflight {2}literals {2}pass /m);
expect(r.status).toBe(EXIT.ok);
const renamed = fixture({ baseFiles: { 'test/history.test.js': history(2, 3, 4) } });
commit(renamed, { ...bump, 'test/history.test.js': null, 'test/versions.test.js': history(2, 3, 4) });
const held = pregate(renamed, ['--stage', 'preflight', '--only', 'literals']);
expect(held.status).toBe(EXIT.fail);
expect(held.out).toContain('literals: test/versions.test.js:4: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
});
test('secrets: a credential in an added line fails and cannot be downgraded; a committed-then-removed line is clean', () => {
const repo = fixture({ pregate: { ...BASE_PREGATE, warn: ['secrets'] } });
const bad = pregate(repo, ['--stage', 'preflight', '--only', 'secrets']);
+2
View File
@@ -224,6 +224,8 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
expect(parseFindings('r', 'FIXABLE: race in a.ts:3\nINVESTIGATE: odd lock\nRecommendation: Fix the race because x\n').map(f => f.label)).toEqual(['FIXABLE', 'INVESTIGATE']);
const m: MergedLine[] = [{ id: 'r#1', reader: 'r', label: 'FIXABLE', raw: 'x', output_sha256: 'h' }];
expect(judge(m, [{ id: 'r#1', severity: 'severe' }]).invalid).toEqual(['r#1']);
// Replay C1-34 (GSTA-193): a judged line with "severity": null crashed judge with a TypeError; it is an invalid line.
expect(judge(m, [{ id: 'r#1', severity: null as unknown as string }, { id: 'lead#1' } as unknown as { id: string; severity: string }]).invalid).toEqual(['r#1', 'lead#1']);
expect(judge(m, [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'a real defect' }]).ok).toBe(true);
});
+4 -2
View File
@@ -55,11 +55,12 @@ const NO_LOSS_REPLAYS = {
'PR-a': "GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)",
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'ship/sections/review-army.md': { sha: 'b7ca291a1cc63750bfb9710a4c12fd971523b9598da2a32ef80fb258eb0eceab', replay: 'L' },
'ship/sections/adversarial.md': { sha: 'c48aa7717c7438941fa23418367e19a62e07edda7445d0d5a55924e172dd5d33', replay: 'G1' },
'ship/sections/adversarial.md': { sha: 'd776ee5bc6e0c13930f8f4de05bad2f1ed9e52bc9fc5df80ae3ba7680455b2bb', replay: 'GST-257' },
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' },
@@ -74,8 +75,9 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'review/specialists/simplification.md': { sha: 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f', replay: '#3638' },
'review/specialists/state-machines.md': { sha: '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2', replay: '#3638' },
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
'lib/review-depth.ts': { sha: 'd4715a5f534d1470c63da879a3d66bb090a9b4929065d1f06c30acd97ef77bb0', replay: 'L' },
'lib/review-depth.ts': { sha: '4542785a1e217d8dd054985978b42c055b9fd79082579cb7664f187f7066e48e', replay: 'GST-257' },
};
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
+4
View File
@@ -288,6 +288,10 @@ describe('gstack-review-scope (bin)', () => {
const badRender = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(badRender.status).toBe(EXIT.fail);
expect(badRender.err).toContain('expected the JSON array defer --out writes');
fs.writeFileSync(d, '{"deferred":[]}');
const wrapped = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(wrapped.status, wrapped.err).toBe(EXIT.ok);
expect(wrapped.out).toBe('');
});
test('classify sees a non-ASCII changed path under its own name, so a CRITICAL finding there is fixed, not deferred to an issue', () => {
+350
View File
@@ -0,0 +1,350 @@
/**
* GST-257 PR 1: the targeted review pass on money, gate, auth and secret code
* (lib/review-targeted.ts, docs/review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code).
* The classifier, the plan's TARGETED and DISPATCH lines, the dispatch-superset no-loss check
* against main's reviewers, the production path from render to the receipt and the review-loop
* summary, and the queue's report-only REVIEW_TARGETED line.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { classifyDiff, planReview, scanNumbers, type PlanInput } from '../lib/review-depth';
import { raisedBy, summarizeReviewLoop } from '../lib/review-scope';
import { TARGETED_RULES, targetedBrief, targetedBriefSha, targetedClasses, type TargetedClass, type TargetedInput } from '../lib/review-targeted';
import { carryReceipt, extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
import { bindShippedReceipt, parseReviewTargeted, receiptTargetedLine } from '../lib/ship-receipt-depth';
import type { ShipReceipt } from '../lib/headless-artifacts';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const PLAN = path.join(ROOT, 'bin', 'gstack-review-plan');
const dirs: string[] = [];
const repos: FixtureRepo[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); while (repos.length) cleanup(repos.pop()!); });
const input = (paths: string[], o: Partial<TargetedInput> = {}): TargetedInput => ({ paths, added: {}, removed: {}, scope: { SCOPE_AUTH: 'false' }, gateTagged: 0, ...o });
const classesOf = (i: TargetedInput) => targetedClasses(i).classes;
describe('targetedClasses: the four classes from added and deleted lines', () => {
// Value: protects=a diff that adds or removes money, auth or secret handling gets the targeted reader; fails_when=a class reads only
// added lines, so a removed cap or redaction slips through, or a class fires on an unrelated diff; seam=lib/review-targeted.ts
test('each class has a positive and a negative case, and deleted lines count', () => {
expect(classesOf(input(['src/run.ts'], { added: { 'src/run.ts': ['const spend_cap_usd = 5;'] } }))).toEqual(['money']);
expect(classesOf(input(['src/run.ts'], { removed: { 'src/run.ts': [' if (spent > capUsd) throw new Error("over cap");'] } }))).toEqual(['money']);
expect(classesOf(input(['src/run.ts'], { added: { 'src/run.ts': ['const MAX_SPEND_USD = 10;'] } }))).toEqual(['money']);
expect(classesOf(input(['src/evals.ts'], { added: { 'src/evals.ts': ['const client = new Anthropic();'] } }))).toEqual(['money']);
expect(classesOf(input(['src/billing/invoice.ts']))).toEqual(['money']);
expect(classesOf(input(['src/run.ts'], { added: { 'src/run.ts': ['const total = a + b;'] } }))).toEqual([]);
expect(classesOf(input(['src/a.ts'], { gateTagged: 2 }))).toEqual(['gate']);
expect(classesOf(input(['lib/pregate-check.ts']))).toEqual([]);
expect(classesOf(input(['src/auth/session.ts'], { scope: { SCOPE_AUTH: 'true' } }))).toEqual(['auth']);
expect(classesOf(input(['src/auth/session.ts']))).toEqual([]);
expect(classesOf(input(['lib/authentication.ts', 'src/authorizer/index.ts'], { scope: { SCOPE_AUTH: 'true' } }))).toEqual(['auth']);
expect(classesOf(input(['src/client.ts'], { added: { 'src/client.ts': ['const key = process.env.OPENAI_API_KEY;'] } }))).toEqual(['secret']);
expect(classesOf(input(['src/log.ts'], { removed: { 'src/log.ts': [' console.log(s.replaceAll(process.env.ANTHROPIC_API_KEY ?? "", "***"));'] } }))).toEqual(['secret']);
expect(classesOf(input(['bin/run.sh'], { added: { 'bin/run.sh': ['curl -H "Authorization: $GH_TOKEN" "$URL"'] } }))).toEqual(['secret']);
expect(classesOf(input(['lib/credentials.ts']))).toEqual(['secret']);
expect(classesOf(input(['src/limits.ts'], { added: { 'src/limits.ts': ['const n = process.env.MAX_TOKENS;'] } }))).toEqual([]);
});
test('matching is token-bounded: costume, feeder, pricey and author name no class', () => {
expect(classesOf(input(['src/costume.ts'], { added: { 'src/costume.ts': ['export const costume = 1;', 'export const feeder = 2;', 'export const pricey = 3;', 'const usdRate = 4;'] } }))).toEqual([]);
expect(classesOf(input(['src/author.ts', 'src/feeder/list.ts'], { scope: { SCOPE_AUTH: 'true' } }))).toEqual([]);
expect(classesOf(input(['src/spend_usd_total.ts'], { added: { 'src/spend_usd_total.ts': ['const spend_usd_total = 1;'] } }))).toEqual(['money']);
});
test('docs and tests paths never trigger, whatever their lines say', () => {
const lines = ['const spend_cap_usd = 5;', 'const k = process.env.OPENAI_API_KEY;', 'new Anthropic()'];
const paths = ['docs/billing.md', 'test/billing.test.ts', 'test/fixtures/secrets/key.ts', 'CHANGELOG.md'];
expect(classesOf(input(paths, { scope: { SCOPE_AUTH: 'true' }, added: Object.fromEntries(paths.map(p => [p, lines])), removed: Object.fromEntries(paths.map(p => [p, lines])) }))).toEqual([]);
});
// Value: protects=the classifier fails closed like the depth does; fails_when=an unreadable diff, a scope error or a missing gate
// scan returns no class, so the targeted reader silently skips; seam=targetedClasses errors
test('an input the classifier cannot read fails closed to all four classes with the reason', () => {
const all: TargetedClass[] = ['money', 'gate', 'auth', 'secret'];
expect(targetedClasses(input(['src/a.ts'], { scope: undefined }))).toMatchObject({ classes: all, errors: ['scope unavailable (gstack-diff-scope did not run)'] });
expect(targetedClasses(input(['src/a.ts'], { scope: { SCOPE_ERROR: 'unmatched' } })).classes).toEqual(all);
expect(targetedClasses(input(['src/a.ts'], { gateTagged: null })).errors).toEqual(['gate scan unavailable']);
expect(targetedClasses(input(['src/a.ts'], { errors: [{ code: 'numstat_failed', input: 'git diff --numstat' }] })).classes).toEqual(all);
expect(classesOf(input(['docs/a.md'], { gateTagged: null }))).toEqual([]);
expect(TARGETED_RULES).toMatch(/^[0-9a-f]{16}$/);
});
test('the classifier and the brief are gate class, so an edit that weakens them gets full review', () => {
for (const p of ['lib/review-targeted.ts', 'review/specialists/targeted.md']) expect(classifyDiff({ paths: [p], scope: {}, gateTagged: 0 }).class).toBe('gate');
});
});
describe('the targeted brief', () => {
const text = fs.readFileSync(path.join(ROOT, 'review/specialists/targeted.md'), 'utf8');
// Value: protects=the brief carries Garry's floor and the checklist's uncapped-spend rule word for word; fails_when=the floor is
// paraphrased or the money block cites a line the checklist no longer has; seam=review/specialists/targeted.md
test('states the floor verbatim, cites the Forced-CRITICAL spend line the checklist still has, and keeps the specialist schema', () => {
expect(text).toContain('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.');
const checklist = fs.readFileSync(path.join(ROOT, 'review/checklist.md'), 'utf8');
for (const quote of ['a paid call, retry or loop with no cap', 'CRITICAL on sight']) { expect(text).toContain(quote); expect(checklist).toContain(quote); }
expect(text).toContain('Read-only');
expect(text).toContain('If no findings: output `NO FINDINGS` and nothing else.');
expect(text).toContain('"specialist":"targeted"');
});
test('targetedBrief keeps the preamble and only the blocks of the classes that hit', () => {
const brief = targetedBrief(text, ['money', 'secret']);
expect(brief).toContain('CRITICAL is the blocking floor');
expect(brief).toContain('## money');
expect(brief).toContain('## secret');
expect(brief).not.toContain('## gate');
expect(brief).not.toContain('## auth');
expect(() => targetedBrief(text, [])).toThrow(/no class/);
expect(targetedBriefSha()).toBe(createHash('sha256').update(fs.readFileSync(path.join(ROOT, 'review/specialists/targeted.md'))).digest('hex'));
});
});
describe('planReview dispatches the targeted reader', () => {
const plan = (o: Partial<PlanInput>) => planReview({ paths: ['src/a.ts'], scope: {}, gateTagged: 0, diffLines: 5, numbers: scanNumbers({}), history: { fullPasses: 0, last: 'none' }, ...o } as PlanInput);
const roles = (p: ReturnType<typeof plan>) => p.dispatches.map(d => `${d.role} ${d.effort}`);
test('at light, standard and full depth when a class hit, never when none did', () => {
expect(plan({ targeted: { classes: ['money'] } })).toMatchObject({ depth: 'light' });
expect(roles(plan({ targeted: { classes: ['money'] } }))).toContain('targeted full');
expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }).depth).toBe('standard');
expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }))).toContain('targeted full');
expect(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }).depth).toBe('full');
expect(roles(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }))).toContain('targeted full');
expect(roles(plan({ targeted: { classes: [] } }))).not.toContain('targeted full');
expect(roles(plan({ targeted: { classes: ['money'] }, history: { fullPasses: 1, last: 'full', fixedBelow: true } }))).toEqual(['fix-check full', 'targeted full']);
expect(plan({ targeted: { classes: ['money'] }, history: { fullPasses: 1, last: 'full' } }).dispatches).toEqual([]);
const ran = plan({ targeted: { classes: ['money'], ran: true } });
expect(roles(ran)).not.toContain('targeted full');
expect(ran.skipped).toContain('targeted (already read this product patch)');
});
});
// --- gstack-review-plan on fixture repositories outside the checkout ---------------------------------------------------
const lines = (n: number, f: (i: number) => string) => Array.from({ length: n }, (_, i) => f(i)).join('\n') + '\n';
type Fixture = { base?: Record<string, string>; head: Record<string, string>; args?: string[] };
const FIXTURES: Record<string, Fixture> = {
'none-light': { head: { 'src/util.ts': lines(5, i => `export const v${i} = ${i};`) } },
'none-standard': { head: { 'src/util.ts': lines(80, i => `export const v${i} = ${i};`) } },
'none-docs': { head: { 'docs/billing.md': 'Set spend_cap_usd to 5 and read process.env.OPENAI_API_KEY.\n' } },
'none-tests': { head: { 'test/billing.test.ts': 'const spend_cap_usd = 5;\nconst k = process.env.OPENAI_API_KEY;\n' } },
'none-costume': { head: { 'src/costume.ts': 'export const costume = 1;\nexport const feeder = 2;\nexport const pricey = 3;\n' } },
'none-gate-path': { head: { 'lib/pregate-check.ts': 'export const strict = true;\n' } },
'secret-light': { head: { 'src/client.ts': 'export const key = process.env.OPENAI_API_KEY;\n' } },
'secret-path': { head: { 'lib/credentials.ts': 'export const store = new Map<string, string>();\n' } },
'secret-removed': { base: { 'src/log.ts': 'export function log(s: string) {\n console.log(s.replaceAll(process.env.ANTHROPIC_API_KEY ?? "", "***"));\n}\n' }, head: { 'src/log.ts': 'export function log(s: string) {\n console.log(s);\n}\n' } },
'gate-tagged': { head: { 'src/a.ts': 'export const a = 1;\n' }, args: ['--gate-tagged', '2'] },
'money-light': { head: { 'src/charge.ts': 'export const spend_cap_usd = 5;\n' } },
'money-path': { head: { 'src/billing/invoice.ts': 'export const n = 1;\n' } },
'money-removed': { base: { 'src/run.ts': 'export function run(spent: number, capUsd: number) {\n if (spent > capUsd) throw new Error("over cap");\n return spent;\n}\n' }, head: { 'src/run.ts': 'export function run(spent: number, capUsd: number) {\n return spent;\n}\n' } },
'money-standard': { head: { 'src/evals.ts': 'import Anthropic from "@anthropic-ai/sdk";\nconst client = new Anthropic();\n' + lines(78, i => `export const v${i} = ${i};`) } },
'auth-path': { head: { 'src/auth/session.ts': 'export const ttl = 60;\n' } },
'money-fix-check': { head: { 'src/charge.ts': 'export const spend_cap_usd = 5;\n' }, args: ['--pass', '1', '--last', 'full', '--fixed-below'] },
'money-pass2': { head: { 'src/charge.ts': 'export const spend_cap_usd = 5;\n' }, args: ['--pass', '1', '--last', 'full', '--fixed-blocking'] },
'gate-repeat': { head: { 'lib/pregate-check.ts': 'export const strict = true;\n' }, args: ['--gate-tagged', '1', '--pass', '1', '--last', 'full', '--fixed-blocking', '--adversarial-ran'] },
};
/**
* main's reviewers on each fixture: gstack-review-plan at 8be7f21 (v1.91.351.0, before this change), DISPATCH lines verbatim.
* The branch must dispatch every one of them unchanged, and add only the targeted reader (the no-loss check: a superset).
*/
const MAIN: Record<string, { depth: string; classes: string; main: string[] }> = {
'none-light': { depth: 'light', classes: 'none', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'none-standard': { depth: 'standard', classes: 'none', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'none-docs': { depth: 'light', classes: 'none', main: [] },
'none-tests': { depth: 'light', classes: 'none', main: [] },
'none-costume': { depth: 'light', classes: 'none', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'none-gate-path': { depth: 'full', classes: 'none', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'secret-light': { depth: 'light', classes: 'secret', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'secret-path': { depth: 'full', classes: 'secret', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'secret-removed': { depth: 'light', classes: 'secret', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'gate-tagged': { depth: 'full', classes: 'gate', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'money-light': { depth: 'light', classes: 'money', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'money-path': { depth: 'light', classes: 'money', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'money-removed': { depth: 'light', classes: 'money', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] },
'money-standard': { depth: 'standard', classes: 'money', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'auth-path': { depth: 'full', classes: 'auth', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'money-fix-check': { depth: 'light', classes: 'money', main: ['fix-check fix-check effort=full'] },
'money-pass2': { depth: 'light', classes: 'money', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] },
'gate-repeat': { depth: 'full', classes: 'gate', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full'] },
};
function planRepo(f: Fixture) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'targeted-plan-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'targeted-plan-state-'));
dirs.push(dir, state);
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 });
const write = (files: Record<string, string>) => { for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); } };
git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false');
write({ 'README.md': '# x\n', ...(f.base ?? {}) }); git('add', '.'); git('commit', '-qm', 'base');
git('checkout', '-qb', 'feat'); write(f.head); git('add', '.'); git('commit', '-qm', 'feat');
const run = (args: string[]) => spawnSync(PLAN, ['--base', 'main', ...(args.includes('--gate-tagged') ? [] : ['--gate-tagged', '0']), ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: state } });
return { dir, run, write };
}
const field = (out: string, prefix: string, key: string) => new RegExp(`^${prefix}.* ${key}=(\\S+)`, 'm').exec(out)?.[1];
describe('gstack-review-plan prints TARGETED and dispatches the targeted reader', () => {
// Value: protects=the no-loss rule for review changes (CREW, 2026-10-30): every reviewer main dispatches on these diffs, at light,
// standard and full depth and on fix-check, scoped and repeat steps, is still dispatched unchanged, and the only addition is the
// targeted reader on a class diff; fails_when=the change drops, renames or re-efforts any reviewer, or adds the reader on a diff
// with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question)
test('the dispatch-superset no-loss check: on every fixture the branch dispatches main\'s reviewers plus only the targeted reader', () => {
expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort());
expect(new Set(Object.values(MAIN).map(m => m.depth))).toEqual(new Set(['light', 'standard', 'full']));
for (const [name, f] of Object.entries(FIXTURES)) {
const r = planRepo(f).run(f.args ?? []);
expect(r.status, `${name}: ${r.stderr}`).toBe(0);
const got = r.stdout.split('\n').filter(l => l.startsWith('DISPATCH: ')).map(l => l.slice('DISPATCH: '.length));
const want = MAIN[name]!;
expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${want.depth} `);
expect(got.filter(d => !want.main.includes(d)), name).toEqual(want.classes === 'none' ? [] : ['targeted targeted effort=full']);
expect(want.main.filter(d => !got.includes(d)), name).toEqual([]);
expect(field(r.stdout, 'TARGETED:', 'classes'), name).toBe(want.classes);
}
}, 180_000);
test('the TARGETED line carries the rules hash and the brief\'s sha256; --targeted-ran skips the reader only for the same product patch', () => {
const repo = planRepo(FIXTURES['money-light']!);
const first = repo.run([]);
expect(field(first.stdout, 'TARGETED:', 'rules')).toBe(TARGETED_RULES);
expect(field(first.stdout, 'TARGETED:', 'brief')).toBe(targetedBriefSha());
const patch = field(first.stdout, 'TARGETED:', 'targeted_patch_id')!;
expect(patch).toMatch(/^[0-9a-f]{40}$/);
const same = repo.run(['--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', patch]);
expect(same.stdout).not.toContain('DISPATCH: targeted');
expect(same.stdout).toContain('SKIP: targeted (already read this product patch)');
const other = repo.run(['--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', 'f'.repeat(40)]);
expect(other.stdout).toContain('DISPATCH: targeted targeted effort=full');
expect(other.stdout).toContain('DIFF_EXCLUDE: targeted none');
repo.write({ 'src/charge.ts': 'export const spend_cap_usd = 6;\n' });
const dirty = repo.run(['--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', patch]);
expect(field(dirty.stdout, 'TARGETED:', 'targeted_patch_id')).toBe('uncommitted');
expect(dirty.stdout).toContain('DISPATCH: targeted targeted effort=full');
expect(JSON.parse(repo.run(['--json']).stdout).targeted).toMatchObject({ classes: ['money'], rules: TARGETED_RULES, targeted_patch_id: 'uncommitted' });
const read = planRepo(FIXTURES['money-light']!);
const readId = field(read.run([]).stdout, 'TARGETED:', 'targeted_patch_id')!;
read.write({ 'test/charge.test.ts': 'test("cap", () => {});\n' });
for (const args of [['add', '.'], ['commit', '-qm', 'test-only fix']]) expect(spawnSync('git', args, { cwd: read.dir, encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
const testFix = read.run(['--gate-tagged', '1', '--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', readId]);
expect(field(testFix.stdout, 'TARGETED:', 'classes')).toBe('money,gate');
expect(field(testFix.stdout, 'TARGETED:', 'targeted_patch_id')).not.toBe(readId);
expect(testFix.stdout).toContain('DISPATCH: targeted targeted effort=full');
}, 90_000);
});
// --- production path: render, sent, collect, judge, verify, receipt write, summary and the queue ------------------------
describe('the targeted role end to end on a fixture repository', () => {
// Value: protects=the targeted reader's record reaches the receipt only through the verbatim-dispatch ledger, bound to the
// receipt's product patch, and the review loop and the queue see it; fails_when=render, sent, collect, judge or verify drop the
// role, the record claims done without a collected output, write binds another product patch, summary loses the role, or the
// queue refuses on it; seam=bin/gstack-review-dispatch, bin/gstack-ship-receipt write, bin/gstack-review-scope summary and
// bindShippedReceipt; no_claim=PR 2's enforcement
test('render → sent → collect → judge → verify --targeted-out → write --review-targeted → summary → REVIEW_TARGETED', () => {
const repo = makeFixtureRepo({ prefix: 'targeted-e2e-' });
repos.push(repo);
repo.git('checkout', '-q', '-b', 'feat');
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 5;\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'charge');
const head = repo.git('rev-parse', 'HEAD');
const base = repo.git('merge-base', 'origin/main', 'HEAD');
const plan = runBin(repo, 'gstack-review-plan', ['--base', 'main']);
expect(plan.out).toContain('TARGETED: classes=money ');
expect(plan.out).toContain('DISPATCH: targeted targeted effort=full');
const out = path.join(repo.root, 'briefs');
const ledger = path.join(out, 'review-ledger.jsonl');
const render = runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'money', '--diff-base', base, '--out', out]);
expect(render.status, render.err).toBe(0);
const file = /file=(\S+)/.exec(render.out)![1]!;
const brief = fs.readFileSync(file, 'utf8');
expect(brief).toContain('## money');
expect(brief).not.toContain('## gate');
expect(brief).toContain(`DIFF_BASE=${base} && git diff "$DIFF_BASE"`);
expect(brief).toContain('Read the targeted brief above (');
expect(runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'cost', '--diff-base', base, '--out', path.join(repo.root, 'bad')]).err).toContain('BRIEF_RENDER_FAILED: slot classes must list');
expect(runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'money', '--base', 'main', '--diff-base', base.slice(0, 12), '--out', path.join(repo.root, 'short')]).status).toBe(0);
expect(runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'money', '--diff-base', head, '--out', path.join(repo.root, 'range')]).err).toContain(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with main (${base})`);
expect(runBin(repo, 'gstack-review-dispatch', ['sent', '--ledger', ledger, '--role', 'targeted', '--sent', file]).out).toContain('BRIEF_SENT: reader=targeted');
const output = path.join(repo.root, 'targeted.out');
const finding = { severity: 'CRITICAL', confidence: 8, path: 'src/charge.ts', line: 1, category: 'money', summary: 'cap is never enforced', fingerprint: 'src/charge.ts:1:money', specialist: 'targeted' };
fs.writeFileSync(output, JSON.stringify(finding) + '\n');
const merged = path.join(repo.root, 'merged.jsonl');
expect(runBin(repo, 'gstack-review-dispatch', ['collect', '--ledger', ledger, '--role', 'targeted', '--output', output, '--merged', merged]).out).toContain('COLLECT: reader=targeted findings=1');
const judged = path.join(repo.root, 'judged.jsonl');
fs.writeFileSync(judged, JSON.stringify({ id: 'targeted#1', severity: 'CRITICAL' }) + '\n');
expect(runBin(repo, 'gstack-review-dispatch', ['judge', '--ledger', ledger, '--merged', merged, '--judged', judged]).out).toContain('JUDGMENT: ok');
const record = path.join(repo.root, 'targeted.json');
const verify = runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger, '--targeted-out', record]);
expect(verify.status, verify.out).toBe(0);
expect(verify.out).toContain(`TARGETED_RECORD: classes=money status=done findings=1 brief=${targetedBriefSha().slice(0, 12)} rules=${TARGETED_RULES}`);
expect(verify.out).toContain('VERIFY: clean');
fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 50;\n');
repo.git('commit', '-qam', 'later product change the reader never read');
expect(runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger]).out).toContain('TARGETED_RECORD: classes=money status=failed findings=0');
repo.git('reset', '-q', '--hard', head);
fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 7;\n');
expect(runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger]).out).toContain('TARGETED_RECORD: classes=money status=failed findings=0');
repo.git('checkout', '--', 'src/charge.ts');
expect(runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger]).out).toContain('TARGETED_RECORD: classes=money status=done findings=1');
const body = path.join(repo.root, 'body.md');
const write = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--review-targeted', `@${record}`, '--body', body, '--json']);
expect(write.status, write.err).toBe(0);
const receipt = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
expect(receipt.review_targeted).toEqual({ classes: ['money'], rules: TARGETED_RULES, brief_sha: targetedBriefSha(), product_patch_id: receipt.product_patch_id!, status: 'done', findings: 1, raised_by: 'targeted' });
const rebound = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--review-targeted', JSON.stringify({ ...JSON.parse(fs.readFileSync(record, 'utf8')), product_patch_id: 'x' }), '--json']);
expect(rebound.status).not.toBe(0);
expect(rebound.err).toContain("write binds the pass to the receipt's product_patch_id");
const plain = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--json']);
expect(plain.status, plain.err).toBe(0);
expect(JSON.parse(plain.out)).not.toHaveProperty('review_targeted');
const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim();
const logDir = path.join(repo.home, 'projects', slug);
fs.mkdirSync(logDir, { recursive: true });
const row = { skill: 'review', via: 'ship', timestamp: '2026-10-30T10:00:00Z', status: 'clean', review_scope: 'frozen', findings: [], dispatches: [{ role: 'specialist' }, { role: 'adversarial-native' }, { role: 'targeted' }] };
fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), JSON.stringify(row) + '\n');
const summary = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-30T09:00:00Z', '--json']);
expect(summary.status, summary.err).toBe(0);
expect(JSON.parse(summary.out).dispatches).toEqual({ specialist: 1, 'adversarial-native': 1, targeted: 1 });
const bodyText = fs.readFileSync(body, 'utf8');
const ok = bindShippedReceipt(repo.work, 'origin/main', bodyText, undefined, [head]);
expect(ok).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=money verdict=ok' });
const { review_targeted: _drop, ...legacy } = receipt;
const missing = bindShippedReceipt(repo.work, 'origin/main', renderReceiptBlock(legacy as ShipReceipt), undefined, [head]);
expect(missing).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=legacy verdict=missing' });
const failed = bindShippedReceipt(repo.work, 'origin/main', renderReceiptBlock({ ...receipt, review_targeted: { ...receipt.review_targeted!, status: 'failed' } }), undefined, [head]);
expect(failed).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=none verdict=missing' });
}, 180_000);
});
describe('the receipt record and the queue line', () => {
const record = { classes: ['gate', 'money'], rules: TARGETED_RULES, brief_sha: 'a'.repeat(64), status: 'done', findings: 0, raised_by: 'targeted' };
test('parseReviewTargeted binds the receipt\'s product patch, orders the classes and refuses a malformed record', () => {
expect(parseReviewTargeted(record, 'p1')).toEqual({ ok: true, targeted: { ...record, classes: ['money', 'gate'], product_patch_id: 'p1' } as any });
for (const bad of [{ ...record, classes: ['cost'] }, { ...record, status: 'skipped' }, { ...record, findings: -1 }, { ...record, brief_sha: 'abc' }, { ...record, extra: 1 }, null]) expect(parseReviewTargeted(bad, 'p1').ok).toBe(false);
});
// Value: protects=PR 1's queue only reports; fails_when=the line misreads a legacy, failed or partial receipt as covered, or a
// receipt without classes as required; seam=receiptTargetedLine (the line ready and train build print)
test('REVIEW_TARGETED reports ok, missing and legacy', () => {
const r = (rt?: object) => ({ schema_version: 1, head: 'h', base: 'main', ...(rt ? { review_targeted: { ...record, product_patch_id: 'p', ...rt } } : {}) }) as ShipReceipt;
expect(receiptTargetedLine([], r())).toBe('REVIEW_TARGETED: required=none receipt=legacy verdict=ok');
expect(receiptTargetedLine(['money'], r())).toBe('REVIEW_TARGETED: required=money receipt=legacy verdict=missing');
expect(receiptTargetedLine(['money'], r({}))).toBe('REVIEW_TARGETED: required=money receipt=gate,money verdict=ok');
expect(receiptTargetedLine(['money', 'secret'], r({}))).toBe('REVIEW_TARGETED: required=money,secret receipt=gate,money verdict=missing');
expect(receiptTargetedLine(['gate'], r({ status: 'failed' }))).toBe('REVIEW_TARGETED: required=gate receipt=none verdict=missing');
expect(receiptTargetedLine([], r({ classes: [], status: 'not-run' }))).toBe('REVIEW_TARGETED: required=none receipt=none verdict=ok');
});
test('a carry keeps review_targeted with the unchanged product patch', () => {
const receipt = { schema_version: 1, head: 'a'.repeat(40), base: 'main', product_patch_id: 'p', review_targeted: { ...record, product_patch_id: 'p' } } as ShipReceipt;
const carried = carryReceipt(receipt, 'b'.repeat(40), { from: 'a'.repeat(40), reason: 'base merge', product_patch_id: 'p', changed_paths: [], merge_commits: 1, range_diff: 'same', covers: [], reruns: [] }, { tree: 't', productPatchId: 'p' });
expect(carried.review_targeted).toEqual(receipt.review_targeted);
expect(carried.product_patch_id).toBe(carried.review_targeted!.product_patch_id);
});
test('the targeted reader\'s findings are attributed to it and read as a Step 11 voice', () => {
expect(raisedBy({ specialist: 'targeted' })).toBe('targeted');
expect(summarizeReviewLoop([{ skill: 'review', via: 'ship', timestamp: '2026-10-30T10:00:00Z', dispatches: [{ role: 'targeted', prompt_bytes: 40 }] }], '2026-10-30T09:00:00Z')).toMatchObject({ dispatches: { targeted: 1 }, est_input_tokens: 10 });
});
});
+1
View File
@@ -26,6 +26,7 @@ const SESSION_KIND_INVENTORY: Record<string, string> = {
'bin/gstack-plan-reality': 'records the kind (:58)',
'bin/gstack-ship-receipt': 'records the kind (:143, :322)',
'lib/autoplan-run.ts': 'records the kind (:110)',
'lib/ship-release.ts': 'an unattended push defers the pre-push guard offer (:203)',
'bin/gstack-brain-enqueue': 'an unattended session never spools for sync (:38)',
'hosts/claude/hooks/spawned-directive.ts': 'Claude Code only (:57)',
'hosts/claude/hooks/question-preference-hook.ts': 'Claude Code only (:506)',
+16 -1
View File
@@ -383,7 +383,7 @@ describe('variant E: the review segment and the docs reader (GSTA-167 replay 5)'
expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${id} kind=dispatch next=dispatch:docs projected=${K.per_dispatch} actual=2000000 `, 'm'));
const step = check(h, s.run, '14.5', '15', ['--reading', '3500000']);
expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=14\.5 projected=\d+ actual=400000 /m);
expect(h.sb(['check', '--run', s.run, '--step', '15', '--next', 'dispatch:doc', '--reading', '3600000']).err).toContain('valid: specialist, red-team, adversarial-native, outside-voice, fix-check, docs');
expect(h.sb(['check', '--run', s.run, '--step', '15', '--next', 'dispatch:doc', '--reading', '3600000']).err).toContain('valid: specialist, red-team, adversarial-native, outside-voice, fix-check, targeted, docs');
});
});
@@ -414,6 +414,21 @@ describe('variant G: observe mode, note and credits (GSTA-167)', () => {
expect(close.out).toContain('BUDGET_TIMELINE: 6 used=200000 credits=50.5; 9:dispatch:specialist,red-team used=2200000 credits=300 readers=2 readers_tokens=2000000 readers_credits=249.5');
});
// Value: protects=a note at a sub-step id records under its ship step with the sub-step as its label, so a driven run never loses a program's reading to an id error; fails_when=note refuses 9.1 or 9.2.1 (replay 8G #34 broke 1, 9G broke 3: two notes lost), records under another step, or starts accepting ids with no ship step;
// why_new=sub-step ids were refused; seam=none; no_claim=which sub-steps the driver names
test('note --step 9.1 records under step 9 labelled 9.1; an explicit label wins; an id with no ship step is refused', () => {
const h = fresh();
const s = start(h, ['--mode', 'observe', '--token-cap', '400M', '--reading', '1000000', '--credits', '100']);
const sub = h.sb(['note', '--run', s.run, '--step', '9.1', '--reading', '1100000', '--credits', '110']);
expect(sub.code, sub.out + sub.err).toBe(0);
expect(sub.out).toMatch(/^NOTE: 9:9\.1 used=100000 credits=10$/m);
const qa = h.sb(['note', '--run', s.run, '--step', '9.2.1', '--label', 'qa', '--reading', '1200000', '--credits', '120']);
expect(qa.out).toMatch(/^NOTE: 9:qa used=200000 credits=20$/m);
const bad = h.sb(['note', '--run', s.run, '--step', '99.1', '--reading', '1300000', '--credits', '130']);
expect(bad.code).toBe(2);
expect(bad.err).toContain('is not a ship step id');
});
// Value: protects=the cap stays one hard stop in observe mode: a note past it stops with the resumable block, and a credits reading below the last is refused like a token reading; fails_when=observe never stops, stops below the cap, or accepts a credits regression;
// why_new=observe mode is new; seam=none; no_claim=what a stop costs
test('a note past the cap stops; at the cap it continues; a lower credits reading is refused', () => {
+58
View File
@@ -0,0 +1,58 @@
/**
* gstack-ship-budget calls (GSTA-167 calls plan, C9): count a driven /ship's parent calls from the
* host's own transcript events, not from the agent's say-so, and split keep-alive polls from
* decision calls; flag every gap over the 300 s prompt-cache lifetime.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { countCalls } from '../lib/ship-calls';
const BIN = path.resolve(import.meta.dir, '..', 'bin', 'gstack-ship-budget');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-calls-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const at = (s: string) => `2026-10-30T${s}.000Z`;
const exec = (t: string, calls: string[]) => ({ id: `x${t}`, source: 'tool', tool: 'execute', calls: calls.map(tool => ({ tool, durationMs: 1 })), text: '', createdAt: at(t) });
// Shapes copied from GSTA-189's transcript (tools.thread.get transcript items), times shortened.
const ITEMS = [
{ id: 'u', source: 'user', text: 'run /ship', createdAt: at('18:19:00') },
exec('18:19:35', ['bash', 'thread_get', 'bash']),
exec('18:23:17', ['bash']),
{ id: 'w1', source: 'tool', tool: 'wait', text: 'Waiting for preflight lanes to finish', createdAt: at('18:23:23') },
exec('18:31:23', ['bash', 'read', 'subagent_create', 'subagent_create']),
{ id: 'w2', source: 'tool', tool: 'wait', text: 'Waiting for specialist readers to finish', createdAt: at('18:31:27') },
exec('18:31:38', ['subagent_get', 'subagent_get']),
exec('18:33:36', ['subagent_list', 'subagent_get', 'bash']),
{ id: 'a', source: 'assistant', text: 'Phase 3 running', createdAt: at('18:33:40') },
{ id: 'm', source: 'tool', tool: 'message_agent', text: 'GSTA-167', createdAt: at('18:40:00') },
];
describe('countCalls', () => {
// Value: protects=acceptance counts calls from host events: each tool item is one call, a wait or a poll-only program (status reads plus at most one bash for the note) is a keep-alive, a lone bash is a decision, everything else is a decision call, and every gap over 300 s between calls is an idle gap; fails_when=user or assistant text is counted as a parent call, a poll with a note is counted as a decision, or a 6-minute gap is missed;
// why_new=calls were counted by hand from transcripts; seam=none; no_claim=the host's billing of a gap
test('splits decision calls from keep-alives and flags gaps over 300 s', () => {
const c = countCalls(ITEMS);
expect(c).toMatchObject({ total: 8, decision: 4, keepalive: 4, idleGaps: 2, maxGapS: 480 });
expect(c.first).toBe(at('18:19:35'));
expect(c.last).toBe(at('18:40:00'));
});
test('--since and --until bound the window', () => {
const c = countCalls(ITEMS, { since: at('18:31:00'), until: at('18:34:00') });
expect(c).toMatchObject({ total: 4, decision: 1, keepalive: 3, idleGaps: 0 });
});
});
describe('gstack-ship-budget calls', () => {
test('prints one CALLS line from an events file; a missing file is a usage error', () => {
const f = path.join(tmp, 'events.json');
fs.writeFileSync(f, JSON.stringify({ items: ITEMS, cursor: '01M6719TXDC28B5ZS6712P5AB7' }));
const r = spawnSync(BIN, ['calls', '--events', f], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim()).toBe(`CALLS: total=8 decision=4 keepalive=4 idle_gaps_over_300s=2 max_gap_s=480 first=${at('18:19:35')} last=${at('18:40:00')} source=01M6719TXDC28B5ZS6712P5AB7`);
const missing = spawnSync(BIN, ['calls', '--events', path.join(tmp, 'nope.json')], { encoding: 'utf8', timeout: 30_000 });
expect(missing.status).toBe(2);
});
});
+19 -7
View File
@@ -177,14 +177,26 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST
expect(doc).toContain('gstack-ship-budget close --reading R --credits C');
});
// Value: protects=no call waits past the 5-minute prompt cache: programs return within 240 s and the next call resumes the wait, with the choice of a ping over a waiting child stated where the parent and Capy read it; fails_when=a phase waits in one program until slow work ends (cache probe: a call after a 7-minute gap cost ~2,820 credits against ~70), the cadence goes back to 270 s (16 s from the cache lifetime), or the reason is dropped;
// why_new=wait hygiene is new; seam=none; no_claim=the host's cache lifetime
test('waits ping every 240 s in the driver and the Capy skill, and both say why a waiting child is not used', () => {
// Value: protects=no call waits past the 5-minute prompt cache and no turn ends while readers run: a program returns 200 s after its first line, the next call resumes the poll, long commands run in background, and Capy waits on operations only when no reader runs, with the choice of a ping over a waiting child stated where the parent and Capy read it; fails_when=a phase waits in one program until slow work ends (cache probe: a call after a 7-minute gap cost ~2,820 credits against ~70), the parent ends its turn on `wait` while readers run (GSTA-189: 23 of 25 waits were one wake per finished reader), the cadence drifts back toward the cache lifetime (240 s counted from the poll loop, or 270 s), or the reason is dropped;
// why_new=GSTA-167 calls plan C1; seam=none; no_claim=the host's cache lifetime or wake policy
test('waits return 200 s after a program starts and never end the turn while readers run, in the driver and the Capy skill', () => {
const waits = flat(doc.slice(doc.indexOf('**Waits.**'), doc.indexOf('\n\n', doc.indexOf('**Waits.**'))));
for (const s of ['No call waits past 240 s', 'the next call resumes', 'A small waiting child saves nothing', 'Past 100 minutes']) expect(waits).toContain(s);
const capy = read('capy/skill/gstack/SKILL.md');
for (const s of ['never let 5 minutes pass between calls', '`duration_seconds: 240`', 'A small waiting child saves nothing']) expect(capy).toContain(s);
for (const text of [doc, capy]) expect(text).not.toMatch(/\b270 ?s\b/);
for (const s of ['No call waits past 200 s', '200 s after its first line', 'the next call resumes the poll', 'run in background', 'Never end the turn while readers run',
'A small waiting child saves nothing', 'Past 100 minutes']) expect(waits).toContain(s);
const capy = flat(read('capy/skill/gstack/SKILL.md'));
for (const s of ['never let 5 minutes pass between calls', 'While readers run, never end the turn on `wait`', '`duration_seconds: 200`', 'one `tools.subagent.list` per ~20 s tick',
'returning 200 s after the program\'s first line', 'under `rejected` with `tools.subagent.start`', 'A small waiting child saves nothing']) expect(capy).toContain(s);
for (const text of [doc, capy]) expect(text).not.toMatch(/\b270 ?s\b|duration_seconds: 240|at most 240 s/);
});
// Value: protects=a program's return fits the host's output cap, a fix runs as one gated program, Step 7's test-writing audit settles before the pass reads the tree, and an unattended push never stops on the one-time pre-push offer; fails_when=Phase 2 returns review-army.md + adversarial.md + documentation.md (97,750 B) at once (replays 8G: truncated reads cost extra calls), a fix walks test, fix, tests and dispatch call by call (GSTA-189: 16 calls), Step 7 writes tests while specialists read (GSTA-164: a pregate refused mid-run), or push stops unattended (all three variant G replays);
// why_new=GSTA-167 calls plan C5, C6, C7 and round-2 High 11; seam=none; no_claim=the parent's adherence, which the replays' CALLS: lines measure
test('returns stay at or under 32 KB, a fix is one gated program, Step 7 settles first, and unattended push defers the pre-push offer', () => {
const f = flat(doc);
for (const s of ['Returns: ≤32 KB per program; split longer reads at headings, each byte once, before first use',
'one program runs the failing test, fix, `--tests-only` and Phase 3, each gated', 'Step 7 may write tests, so it settles first',
'Unattended, push defers the pre-push offer; else its STOP runs Step 17\'s guard block', 'plus a keep-alive per 200 s waited']) expect(f).toContain(s);
expect(flat(doc.slice(doc.indexOf('**Budget: observe.**')))).toContain('--reading R --credits C --record <rec>');
});
// Value: protects=a reader counts as settled only once it has run (status done or failed, or a final summary), in the driver, the Capy skill and the budget section, so a poll never closes a batch on a subagent that was just created; fails_when=the settled rule is dropped or lets a bare idle status through (replay 7e #36: the Red Team reservation settled actual=0 because a just-created subagent read idle);
+12
View File
@@ -265,6 +265,18 @@ describe('push (Step 17)', () => {
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'PUSH')).toMatch(/^PUSH: ALREADY_PUSHED /);
});
// Value: protects=an unattended push defers the one-time guard offer instead of stopping, and leaves the offer open for the next interactive session (no marker, no hook); fails_when=unattended push stops on the offer (replays 8G #34, 8G #36 and 9G each spent calls on it), writes .redact-prepush-prompted, or installs a hook;
// why_new=the offer stopped every unattended run; seam=gstack-config, git push; no_claim=the interactive guard block
test('unattended, an unanswered guard offer is deferred: it pushes, writes no marker and installs no hook', () => {
const f = fx();
const r = f.run(RELEASE, ['push', '--branch', 'feat'], { GSTACK_SESSION_KIND: 'unattended' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(lineOf(r.stdout, 'REDACT_PREPUSH')).toBe('REDACT_PREPUSH: false prompted=no deferred=unattended');
expect(lineOf(r.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/feat$/);
expect(fs.existsSync(path.join(f.state, '.redact-prepush-prompted'))).toBe(false);
expect(fs.existsSync(path.join(f.work, '.git', 'hooks', 'pre-push'))).toBe(false);
});
// Value: protects=a rejected push stops with the merge-and-reverify route and never force-pushes; fails_when=the bin retries with force or reports success;
// why_new=moved from Step 17's failure rules; seam=git push; no_claim=the merge
test('a non-fast-forward push stops with the merge route', () => {
@@ -0,0 +1,4 @@
{
"bytes": 302,
"why": "GST-257: Step 11 dispatches the targeted reader on money, gate, auth and secret diffs, on the merge base every pass"
}