wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 22:05:55 +00:00
1 parent 9ccb5233f3
commit fc5ee582e3
37 files changed
+1526 -107

No files matched your search

+9 -7
View File
@@ -14,9 +14,10 @@ ends with `(<CODE>)`.
## Phases and scope
Order: ceo → design → dx → eng. Design runs only with UI scope, DX only with DX scope, Eng always and last.
Scope flags on the first `next`: `--ui`, `--no-ui`, `--no-dx`, `--developer-tool`, `--agent-primary`. `--developer-tool` and `--agent-primary` only enable DX.
The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) source=ui:<s>,dx:<s>`
(each axis is `flag`, `marker` or `detected`). UI needs a rendered file path or a UI term next
Scope flags on the first `next`: `--ui`, `--no-ui`, `--no-dx`, `--developer-tool`, `--agent-primary`, `--docs-only`. `--developer-tool` and `--agent-primary` only enable DX.
The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) docs_only=yes|no (<s>) source=ui:<s>,dx:<s>`
(each is `flag`, `marker` or `detected`). Docs-only (`--docs-only`, or every Implementation-plan path is README*, CHANGELOG*,
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan.
@@ -34,7 +35,9 @@ line yourself; gstack never writes the plan.
accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed
`NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
### On Capy
@@ -55,7 +58,7 @@ line yourself; gstack never writes the plan.
### Commands
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold]`
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 3 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds.
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds.
- `gstack-autoplan next --out <dir> [--redispatch <attempt>]`
later calls: open the next phase or reprint open attempts; never redispatches on its own.
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`
@@ -91,8 +94,7 @@ line yourself; gstack never writes the plan.
## Reviewer result format
Every prompt ends with this contract; `submit --result` enforces it, `submit --record` accepts
the looser record shapes above.
Every prompt ends with this contract; `submit --result` enforces it, `submit --record` accepts the looser shapes above.
RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this contract):
1. The first line of your result is exactly: INPUT: <phase> <snapshot sha256>
+7 -5
View File
@@ -12,8 +12,9 @@ ends with `(<CODE>)`.
## Phases and scope
{{AUTOPLAN_DRIVER_PHASES}}
The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) source=ui:<s>,dx:<s>`
(each axis is `flag`, `marker` or `detected`). UI needs a rendered file path or a UI term next
The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected) docs_only=yes|no (<s>) source=ui:<s>,dx:<s>`
(each is `flag`, `marker` or `detected`). Docs-only (`--docs-only`, or every Implementation-plan path is README*, CHANGELOG*,
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan.
@@ -31,7 +32,9 @@ line yourself; gstack never writes the plan.
accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed
`NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
### On Capy
@@ -55,8 +58,7 @@ line yourself; gstack never writes the plan.
## Reviewer result format
Every prompt ends with this contract; `submit --result` enforces it, `submit --record` accepts
the looser record shapes above.
Every prompt ends with this contract; `submit --result` enforces it, `submit --record` accepts the looser shapes above.
{{AUTOPLAN_DRIVER_RESULT_FORMAT}}
+12 -9
View File
@@ -2,7 +2,7 @@
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary]
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only]
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
@@ -32,11 +32,11 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
import { attemptEstimate, changeBudget, liftStop } from '../lib/autoplan-budget';
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
import { ANSWERED_BY_RE } from '../lib/gate-list';
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
import { admit, touchInstallLease } from '../lib/autoplan-admission';
import { exportRun } from '../lib/autoplan-export';
import { dispatchLine, exportRun } from '../lib/autoplan-export';
import { capyRelayLine } from '../lib/captain-brief';
import { exportPublic } from '../lib/autoplan-public';
import { migrateRun } from '../lib/autoplan-migrate';
@@ -53,9 +53,10 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx]
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
--max-rounds (first next): review rounds per phase (default 3 unattended); at the cap a phase closes accepted-with-findings
--max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings
--docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
@@ -70,7 +71,8 @@ DRIVER: ${driverFile()}
the outside voice could not run: closes the phase native_only once native is bound
revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] | --settle <id,...>
review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (Eng last);
--incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one;
--incorporates discharges the named accepted changes (the plan must change); when every one resolves below High and no phase
comes into scope, the edit applies without re-review (APPLIED:); --withdraw (alone or with --plan) drops one;
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ...
next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
@@ -179,7 +181,7 @@ function doNext(out: string, rest: string[], json: boolean): number {
const maxRounds = num(rest, '--max-rounds');
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined } }, initLines);
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
@@ -197,7 +199,8 @@ function doNext(out: string, rest: string[], json: boolean): number {
function gateLines(state: RunState, out: string): string[] {
const relay = capyRelayLine(out);
const cost = gateCostOf(state, out);
return [`GATE: gate_rev=${state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`, ...(cost ? renderGateCost(cost) : []), ...(relay ? [relay] : [])];
const escalate = escalateLine(state);
return [`GATE: gate_rev=${state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`, ...(escalate ? [escalate] : []), ...(cost ? renderGateCost(cost) : []), ...(relay ? [relay] : [])];
}
function gateCostOf(state: RunState, out: string): GateCost | undefined {
const gateFile = path.join(out, 'gate.json');
@@ -397,7 +400,7 @@ async function main(argv: string[]): Promise<number> {
const r = exportRun(state);
writeState(state);
for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`);
emit(json, r.files.map(f => `EXPORTED: ${f}`), { run: state.run, files: r.files, warnings: r.warnings });
emit(json, [...r.files.map(f => `EXPORTED: ${f}`), dispatchLine(state)], { run: state.run, files: r.files, warnings: r.warnings, dispatches: state.dispatches, est_input_tokens: state.est_input_tokens });
return EXIT.ok;
});
}
+10 -2
View File
@@ -200,6 +200,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # Absent = every gate keeps its effort. GSTACK_CODEX_EFFORT
# # overrides it per run, /codex --effort per request;
# # /spec keeps medium (120 s budget).
# review_effort_tiers: on # on | off. on: read-and-report reviewers (specialists,
# # audits, fix-checks, watchers) run at light effort on
# # the same model; core and adversarial review keep full.
# Docs: docs/model-policy.md
#
# ─── Advanced ────────────────────────────────────────────────────────
@@ -360,6 +363,7 @@ lookup_default() {
implementation_tier) echo "smart" ;; # frontier | smart
model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) echo "" ;; # empty = shipped catalog ID (gstack-models list)
codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort)
review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort)
ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR
pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for
pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for
@@ -821,6 +825,10 @@ $_gstack_legacy_root"
fi
# codex_effort feeds model_reasoning_effort on paid Codex calls: reject a
# typo instead of coercing it (the probe refuses an invalid stored value too).
if [ "$KEY" = "review_effort_tiers" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
if [ "$KEY" = "codex_effort" ]; then
case "$VALUE" in
minimal|low|medium|high|xhigh|max|ultra) ;;
@@ -1078,7 +1086,7 @@ $_gstack_legacy_root"
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
model_smart_claude model_smart_openai codex_effort; do
model_smart_claude model_smart_openai codex_effort review_effort_tiers; do
gstack_config_select "$KEY"
VALUE="$_gstack_cfg_value"
SOURCE="default"
@@ -1103,7 +1111,7 @@ $_gstack_legacy_root"
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
model_smart_claude model_smart_openai codex_effort; do
model_smart_claude model_smart_openai codex_effort review_effort_tiers; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
;;
+1
View File
@@ -308,6 +308,7 @@ gstack_result_anchor() {
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;;
RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;;
RECEIPT_CARRY_PREGATE_STALE) printf '%s' 'receipt-carry-pregate-stale' ;;
RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;;
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-review-plan — how much review the next /ship step dispatches (docs/review-policy.md#review-depth).
*
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
* [--blocking-open] [--touched-gate] [--critical-found] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
* [--reviewed-numbers <fingerprint>] [--no-tiers] [--json]
*
* Reads the diff against the merge base with <ref> (committed, working tree and
* untracked), runs gstack-diff-scope and, unless --gate-tagged is given,
* gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per
* reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth';
const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth)
--base <ref> the base branch (merge base with HEAD is the diff base); required
--pass <n> full review passes already run in this invocation (default 0)
--last none|full|fix-check the step that just finished (default none: plan pass 1)
--fixed-blocking the finished step fixed a blocking finding
--fixed-below the finished step fixed findings below the floor
--blocking-open a blocking finding is still open in fix scope
--touched-gate a fix touched a gate-class path
--critical-found a specialist reported a CRITICAL finding (Red Team on code class)
--gate-tagged <n> gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable)
--gated a,b specialists the hit-rate stats tag GATE_CANDIDATE
--force a,b|all specialists forced by prompt flags (--all-specialists is all)
--reviewed-numbers <fp> the numeric-claim fingerprint the outside voice already reviewed
--no-tiers every role at the host's default effort (as gstack-config review_effort_tiers off does)
--json the plan as JSON
${exitTableHelp()}`;
const args = process.argv.slice(2);
const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
const has = (n: string) => args.includes(n);
const list = (n: string) => (flag(n) ?? '').split(',').map(s => s.trim()).filter(Boolean);
const git = (a: string[]) => { const r = spawnSync('git', ['-c', 'core.quotePath=false', ...a], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
function main(): number {
if (has('--help') || has('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
const base = flag('--base');
if (!base) { process.stderr.write(`gstack-review-plan: --base <ref> is required\n${HELP}\n`); return EXIT.usage; }
const last = (flag('--last') ?? 'none') as History['last'];
if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; }
const pass = Number(flag('--pass') ?? '0');
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base;
const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim();
if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; }
const nameStatus = (git(['diff', '--name-status', '--no-renames', mergeBase]) ?? '').split('\n').filter(Boolean).map(l => l.split('\t'));
const untracked = (git(['ls-files', '--others', '--exclude-standard']) ?? '').split('\n').filter(Boolean);
const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort();
const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!);
const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => {
const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l));
return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l));
});
const diffLines = (git(['diff', '--numstat', mergeBase]) ?? '').split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0)
+ untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0);
const added: Record<string, string[]> = {};
let current = '';
for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) {
if (line.startsWith('+++ b/')) { current = line.slice(6); continue; }
if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1));
}
for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } }
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeRun = spawnSync('bash', [path.join(binDir, 'gstack-diff-scope'), base], { encoding: 'utf8', timeout: 60_000 });
const scope: Record<string, string> = {};
for (const l of (scopeRun.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; }
if (scopeRun.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${scopeRun.status}`;
let gateTagged: number | null = null;
if (flag('--gate-tagged') !== undefined) gateTagged = Number(flag('--gate-tagged'));
else {
const g = spawnSync(path.join(binDir, 'gstack-gate-diff'), [base], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
gateTagged = g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
}
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
const force = flag('--force') === 'all' ? 'all' as const : list('--force');
const tiersSetting = spawnSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 });
const tiers = !has('--no-tiers') && (tiersSetting.stdout ?? '').trim() !== 'off';
const plan = planReview({
paths, deleted, stampOnly, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'),
criticalFound: has('--critical-found'), reviewedNumbers: flag('--reviewed-numbers'), tiers,
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') },
});
if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; }
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`];
for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`);
for (const s of plan.skipped) out.push(`SKIP: ${s}`);
process.stdout.write(out.join('\n') + '\n');
return EXIT.ok;
}
process.exit(main());
+31 -14
View File
@@ -12,7 +12,7 @@
* gstack-ship-receipt read --body <file> [--json]
* gstack-ship-receipt result (--pr <n> [--repo <owner/repo>] | --body <file>) [--head <sha>] [--out <dir>] [--json]
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
* gstack-ship-receipt carry (--pr <n> [--repo <owner/repo>] | --body <file>) [--base <ref>] [--head <sha>] [--dry-run] [--out <dir>] [--json]
* gstack-ship-receipt carry (--pr <n> [--repo <owner/repo>] | --body <file>) [--base <ref>] [--head <sha>] [--pregate <pregate.json>] [--dry-run] [--out <dir>] [--json]
* gstack-ship-receipt docs-caps [--base <ref>] [--policy <ship-policy.json>] [--json]
*
* `write` validates against `gstack-artifact schema ship-receipt`, prints the
@@ -60,11 +60,12 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
GSTACK_RESULT: skill=ship status=complete run=<dir> (refused: status=refused run=-)
census --repo <r> --since <tag|date> [--cohort <file>]
the P7 adoption number over merged PRs (deduplicated by run and thread)
carry --pr <n> [--repo <r>] | --body <file> [--base <ref>] [--head <sha>] [--dry-run] [--out <dir>]
carry --pr <n> [--repo <r>] | --body <file> [--base <ref>] [--head <sha>] [--pregate <pregate.json>] [--dry-run] [--out <dir>]
carry the receipt to HEAD when only a base merge, tests or release metadata changed
since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record,
posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete;
RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3);
a receipt that records a pregate needs --pregate current for HEAD (RECEIPT_CARRY_PREGATE_STALE);
needs git >= 2.39 (RECEIPT_GIT_TOO_OLD)
docs-caps [--base <ref>] /ship Step 14.5: one DOCS_CAP: <path> <bytes>/<max> ok|over line per docs.caps entry of the
reviewed policy (origin/<base>'s .gstack/ship-policy.json; --policy <file> reads that file
@@ -144,18 +145,12 @@ function cmdWrite(rest: string[], json: boolean): number {
const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
}
let pregate: { pregate: string; requires_remote?: string[] } | undefined;
let pregate: PregateField | undefined;
const pregateFile = flag(rest, '--pregate');
if (pregateFile) {
const read = readPregateFile(pregateFile);
if (!read.file) {
for (const e of read.errors) process.stderr.write(resultLine('gstack-ship-receipt write', e.code, `${e.message} at ${e.path}`) + '\n');
return EXIT.fail;
}
const v = verifyPregate(repoRoot, read.file);
if (!v.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', v.code!, `${v.message}; nothing written`) + '\n'); return EXIT.fail; }
const remote = read.file.requires_remote ?? [];
pregate = { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined };
const read = pregateFieldFor(repoRoot, pregateFile, head);
if (!read.ok) { for (const e of read.errors) process.stderr.write(resultLine('gstack-ship-receipt write', e.code, `${e.message}; nothing written`) + '\n'); return EXIT.fail; }
pregate = read.field;
}
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const bodyFile = flag(rest, '--body');
@@ -369,6 +364,17 @@ function gitVersionBelow(repoRoot: string, minimum: string): string | null {
* receipt's head and at HEAD by patch-id, classifies every changed path, and
* records the range-diff as evidence in the receipt it rewrites.
*/
type PregateField = { pregate: string; requires_remote?: string[] };
/** A pregate result file → the receipt's pregate field, verified current for this checkout's tree (shared by write and carry). */
function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: true; field: PregateField } | { ok: false; errors: Array<{ code: string; message: string }> } {
const read = readPregateFile(file);
if (!read.file) return { ok: false, errors: read.errors.map(e => ({ code: e.code, message: `${e.message} at ${e.path}` })) };
const v = verifyPregate(repoRoot, read.file);
if (!v.ok) return { ok: false, errors: [{ code: v.code!, message: v.message ?? 'pregate is not current' }] };
const remote = read.file.requires_remote ?? [];
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } };
}
function cmdCarry(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
const pr = flag(rest, '--pr');
@@ -378,7 +384,7 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; }
const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase();
if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; }
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD', message: string) => {
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`);
return EXIT.refused;
@@ -480,7 +486,18 @@ function cmdCarry(rest: string[], json: boolean): number {
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
if (!plan.ok) return refused(plan.code, plan.message);
const carried = carryReceipt(receipt, head, plan.carry);
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
// so a carried receipt never claims the old tree's checks (docs/ship-policy.md#receipt-carry).
const pregateFile = flag(rest, '--pregate');
let pregateField: PregateField | undefined;
if (pregateFile) {
const read = pregateFieldFor(repoRoot, pregateFile, head);
if (!read.ok) return refused('RECEIPT_CARRY_PREGATE_STALE', `--pregate ${pregateFile}: ${read.errors[0]?.code ?? 'invalid'} ${read.errors[0]?.message ?? ''}`.trim());
pregateField = read.field;
} else if (receipt.pregate && !rest.includes('--dry-run')) {
return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate <pregate.json>`);
}
const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry);
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
const newBody = placeReceiptFirst(body, carried);
+13
View File
@@ -154,6 +154,19 @@ set, because a higher effort would spend the time the gate is sized for.
`gstack-models list` prints the effective effort and where it came from
(`--json`: `codexEffort`). The Claude Code runner takes no effort setting.
### Review effort tiers
Subagents that only read and report run at a lighter reasoning effort on the
same model; gstack never swaps in a cheaper model family for them.
`gstack-review-plan` names each dispatch's tier: `light` for specialists,
audits, fix-checks, decision-trail reviewers and watchers, `full` for the core
checklist, the Red Team and the native adversarial pass, `host` for the outside
voice (its provider and `codex_effort` above). A host that can set a
subagent's effort maps `light` to a medium or low setting; one that cannot runs
it at its default. `gstack-config set review_effort_tiers off` (default `on`)
renders every role at the host default
([review-policy.md](review-policy.md#review-depth)).
## Which workflows use the tiers?
The plan-review role applies to autoplan's CEO, design, DX and engineering
+84 -19
View File
@@ -87,21 +87,76 @@ incomplete coverage, which blocks until the reviewer is rerun.
(the changed line behind a defect reported elsewhere, a deleted file included)
places it in the diff.
Below-floor findings outside the diff are `note`, listed under `## Review notes`.
2. **Pass 2 and later review only the range since the previous pass.** Each pass
saves its head; the next pass diffs `RANGE_BASE..HEAD` for every reviewer (the
gate scan and QA probes keep the whole candidate) and classifies with
`--range-base`. Only a blocking finding in that range or in the diff is `fix`;
every below-floor finding is a `note`. A finding the diff causes in code it did
not change names the changed line in `cause`, and the locus follows it.
3. **Three passes is the cap.** A pass that applies no edits is the exit: the
PR ships with its deferred and noted items named in the body. A blocking
finding still in `fix` scope on pass 3 stops the run and names the finding
(path, severity, problem) for the owner; a fourth pass is the owner's call,
never the loop's.
2. **What comes next is the transition table below**, not "any fix earns
another pass". A full pass after pass 1 reviews only the range since the
previous pass: each pass saves its head, the next diffs `RANGE_BASE..HEAD`
for every reviewer (the gate scan and QA probes keep the whole candidate) and
classifies with `--range-base`. Only a blocking finding in that range or in
the diff is `fix`; every below-floor finding is a `note`.
3. **Two full passes is the cap**, plus at most one fix-check. A blocking
finding still open after the last allowed step stops the run and names the
finding (path, severity, problem) for the owner; a third full pass is the
owner's call, never the loop's. The cap approves nothing.
The same floor and the same `NO FINDINGS` rule apply to `/review` on its own;
its re-review loop keeps its three-cycle fix cap (`CYCLES`), and a pass that
applies no edits is its exit too.
<a id="transitions"></a>
### Transitions
`gstack-review-plan` (`lib/review-depth.ts` `nextStep`) owns this table, and
/ship asks it after every step:
| The step that just finished | Next |
|---|---|
| none | full pass 1 on the whole diff |
| a full pass with no edits | done |
| a full pass that fixed a blocking finding, below the cap | full pass on the range |
| a full pass whose fix touched a gate-class path, below the cap | full pass on the range |
| a full pass that fixed only below-floor findings | one fix-check |
| a full pass at the cap that fixed a blocking finding | one fix-check (full effort when the fix touched gate code) |
| a full pass at the cap, or with no fix, leaving a blocking finding open | stop: name it for the owner |
| a fix-check that found nothing blocking | done |
| a fix-check that found a blocking finding | stop: name it for the owner |
A **fix-check** is one reviewer reading the touched hunks, the fix list, the
test results and the callers of every changed symbol, at light effort. It is
logged as a `review-fix-check` row, so it never counts as a pass. No army, no
Red Team and no outside voice run on it.
<a id="review-depth"></a>
## Review depth by diff class
`gstack-review-plan --base <base>` classifies the diff and prints the reviewers
the next step dispatches (`REVIEW_CLASS:`, `REVIEW_PLAN:`, one `DISPATCH:` per
reviewer with its effort, one `SKIP:` per reviewer left out, `--json`). A mixed
diff takes its most restrictive class; a `gstack-diff-scope` error fails closed
to code, and code with the gate scan unavailable fails closed to gate.
| Class | What it is | Pass 1 dispatches |
|---|---|---|
| **docs** | markdown no agent or gate reads: README, CHANGELOG, TODOS, how-to and explanation docs | the parent's core checklist only |
| **tests** | test files and fixtures only | core plus the testing specialist |
| **code** | everything else, including every `SKILL.md` and template, `*/sections/**`, `scripts/resolvers/**`, `docs/*-policy.md`, checklists, specialists and bins | specialists by scope (as before), the native adversarial pass, the outside voice; the Red Team past 200 lines or after a CRITICAL |
| **gate** | code plus auth or migration scope, a hunk the gate scan tags (an RH rule: a relaxed threshold, a skipped test), a deleted code file, CI workflows, credential or state-root paths | code's set, plus the Red Team always, at full effort, and no hit-rate gating |
**Numbers keep the outside voice.** On a docs or tests diff the outside voice is
skipped unless the diff publishes numeric claims: `gstack-review-plan` scans the
added lines of every path for percentages, money, multipliers, durations, token
or credit counts and score, rate or speed claims (`NUMBERS: yes`), and a
claim always dispatches it. The claims' fingerprint (`fingerprint=`) is passed
back as `--reviewed-numbers`; when a later fix changes a claim, only the
outside voice reruns, on the numbers.
**The adversarial and outside voices run once**, on pass 1's diff (Step 11).
Later full passes rerun the specialists on the range only.
**Effort tiers.** Reviewers that only read and report (specialists, the Step 7
coverage audit, the Step 8 plan-completion audit, the Step 14.5 docs audit,
fix-checks, decision-trail reviewers, watchers) run at `effort: light`: the
same model at a lower reasoning effort, never a cheaper model family. The core
checklist, the Red Team and the native adversarial pass stay at `full`. The
outside voice keeps its own provider and effort (`codex_effort`). A host that
cannot set a subagent's effort runs it at its default.
`gstack-config set review_effort_tiers off` (or `--no-tiers`) renders every
role at the host default ([model-policy.md](model-policy.md#reasoning-effort)).
## What the receipt records
@@ -111,15 +166,24 @@ puts in the receipt:
| Field | Meaning |
|---|---|
| `review_loop.passes` | review passes this ship ran (1 to 3) |
| `review_loop.passes` | full review passes this ship ran (1 or 2; up to 3 under `review_scope: all`) |
| `review_loop.blocking_found` | distinct blocking findings across the passes |
| `review_loop.deferred` | blocking findings in unchanged code filed as issues (a Step 11 late defer, logged as a `review-late-defer` row so it never displaces the newest `review` pass, counts here and is not a pass) |
| `review_loop.noted` | below-floor findings listed in the PR body |
| `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.est_input_tokens` | prompt plus diff bytes the dispatches read, divided by 4: a relative estimate for comparing runs, not a bill |
Each review row may carry `dispatches: [{role, effort, prompt_bytes,
diff_bytes, status}]`; `summary` adds them up (older rows fall back to the
specialist stats they record). The summary line appends `fix_checks=`,
`dispatches=` and `est_input_tokens=` only when they were recorded, so an old
loop prints the old line.
The receipt's summary line prints `passes=<n> blocking_found=<k> deferred=<m>`,
which is what a scorecard tracks (target: a median of 2 passes per PR). A
which is what a scorecard tracks (target: a median of 1 pass per PR). A
receipt carried across a base merge or a test-only fix
([ship-policy.md#receipt-carry](ship-policy.md#receipt-carry)) keeps these
counters, because the reviewed product diff is unchanged.
@@ -130,8 +194,8 @@ counters, because the reviewed product diff is unchanged.
| Value | Pass 1 | Blocking finding in unchanged code | Pass 2+ | Cap |
|---|---|---|---|---|
| `frozen` (default) | the diff | filed as an issue, listed as deferred | the range since the previous pass, blocking only | 3 passes |
| `diff-plus-blocking` | the diff | fixed in this PR | the range since the previous pass, blocking only | 3 passes |
| `frozen` (default) | the diff | filed as an issue, listed as deferred | the range since the previous pass, blocking only | 2 full passes + 1 fix-check |
| `diff-plus-blocking` | the diff | fixed in this PR | the range since the previous pass, blocking only | 2 full passes + 1 fix-check |
| `all` | everything | fixed in this PR | the whole diff, every finding | 3 fix cycles |
`diff-plus-blocking` is the fallback for a repository where deferred issues pile
@@ -198,7 +262,8 @@ trailers, appended }`.
- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=<diff-base>`: the merge base on pass 1, `RANGE_BASE` from pass 2 on), because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above.
- `ship/sections/review-army.md` Step 9: passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way.
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary`; refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line.
- `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port.
- `lib/ship-receipt-prose.ts`, `gstack-ship-receipt read --check-prose`: the body's quoted counters against the receipt (`RECEIPT_PROSE_MISMATCH`, [ship-policy.md](ship-policy.md#ship-receipt)).
+20 -3
View File
@@ -903,11 +903,28 @@ A changed product path, or a product patch-id that differs, is
(exit 3; the refusal names the paths), and a receipt whose head is not in HEAD's
history (a rebase or force-push) or a working tree with uncommitted changes is
[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship`
on the new head. `/ship` Step 1 runs the carry check itself when the branch has
an open PR, and a `CARRY: carried` result ends that run with the receipt bound
to HEAD; the queue's `ready`, `train build` and `verify --train` read the
on the new head. The queue's `ready`, `train build` and `verify --train` read the
carried receipt like any other.
**Delta /ship: eligibility, then checks, then publication.** `/ship` Step 1 runs
the carry when the branch has an open PR, in this order, and never reruns the
review loop for a carried receipt:
1. `gstack-ship-receipt carry --pr <n> --base <base> --dry-run`. `CARRY: would
carry` is eligibility only; a refusal continues the full ship.
2. At that pinned HEAD: the CHANGELOG check, the size-cap check the policy
declares, and `gstack-pregate` (the remote tests stage when
`requires_remote` asks for it). A failing check, or HEAD moving, continues
the full ship from Step 2.
3. `gstack-ship-receipt carry --pr <n> --base <base> --pregate <pregate.json>`
rebinds the receipt with a pregate current for HEAD and ends the run.
A receipt that records a pregate never rebinds without a current one:
`carry` without `--pregate` (or with a stale one) is
[`RECEIPT_CARRY_PREGATE_STALE`](troubleshooting.md#receipt-carry-pregate-stale)
and leaves the body unchanged. A crew that reran the whole `/ship` after every
base merge should run this sequence instead.
## What `/land-and-deploy` reads from it
Step 3.4 runs `show-effective --base <base>` before the merge and prints one
+13
View File
@@ -4704,6 +4704,19 @@ run did not produce. The `PROSE:` line names each field with both values.
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
([ship-policy.md](ship-policy.md#ship-receipt)).
<a id="receipt-carry-pregate-stale"></a>
### `RECEIPT_CARRY_PREGATE_STALE`
**Meaning.** The receipt in the body records a pregate result for its old head,
and `gstack-ship-receipt carry` was asked to rebind it to HEAD without a pregate
result current for HEAD (or the one passed with `--pregate` is not current). A
carry certifies the new head, so it never keeps the old tree's checks. Nothing
was rewritten.
**Fix.** Run `gstack-pregate` at HEAD (the remote tests stage when the policy
requires it), then rerun `carry --pregate <pregate.json>`. `--dry-run` still
reports eligibility without one ([ship-policy.md](ship-policy.md#receipt-carry)).
<a id="receipt-git-too-old"></a>
### `RECEIPT_GIT_TOO_OLD`
+26 -4
View File
@@ -1,21 +1,26 @@
/**
* autoplan-budget — the run budget of the unattended runner (field list 2,
* W5). Review rounds are counted per phase from `state.history` reopen events
* and capped by `run.json.max_rounds` (default 3 for unattended runs) plus the
* and capped by `run.json.max_rounds` (default 2 for new unattended runs; a
* resumed run keeps the value it persisted) plus the
* owner's per-phase grants at the gate. Unattended runs on Capy reserve a
* default per-voice estimate for unestimated host attempts, so `--spend-cap`
* binds. A dispatch the cap refuses stops the run at the terminal `incomplete`
* status with a structured `stop` object (`reason: budget_exhausted`) and
* printed recovery commands; `gstack-autoplan budget` journals the owner's cap
* change and `resume` lifts the stop only after one. Phase close at the cap
* lives in lib/autoplan-gate.ts (`closeDeferred`).
* lives in lib/autoplan-gate.ts (`closeDeferred`); `escalateLine` names the
* capped phases still holding High or Critical findings.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ANSWERED_BY_RE, type GateItem } from './gate-list';
import { FILES, RunError, journal, now, readJournal, type RunState, type RunStop } from './autoplan-state';
import { settledIds } from './autoplan-confirm';
import type { ConsensusRecord } from './autoplan-reconcile';
import { FILES, RunError, journal, now, readJournal, voiceRows, type RunState, type RunStop } from './autoplan-state';
import { readLedger, release, setCap, summarize } from './spend-ledger';
export const DEFAULT_MAX_ROUNDS = 3;
export const DEFAULT_MAX_ROUNDS = 2;
/** Reserved per voice for a host-subagent attempt with no `--estimate-usd` in an unattended Capy run. */
export const DEFAULT_HOST_ESTIMATE_USD = 5;
@@ -30,6 +35,23 @@ export const atCap = (state: RunState, phase: string): boolean => { const cap =
/** The phase is about to open past its cap (a rerun-all reply reopened it). */
export const overCap = (state: RunState, phase: string): boolean => { const cap = roundCap(state, phase); return cap !== null && roundOf(state, phase) > cap; };
/** Open High or Critical findings of a closed phase: the deferred counts, else bound rows not settled or answered, an outside row paired with a native one counted once. */
function openHigh(state: RunState, phase: string): number {
const ps = state.phases[phase];
if (ps?.deferred) return (ps.deferred.counts.High ?? 0) + (ps.deferred.counts.Critical ?? 0);
const file = path.join(state.out, `${phase}-consensus.json`);
const paired = new Set(fs.existsSync(file) ? (JSON.parse(fs.readFileSync(file, 'utf8')) as ConsensusRecord).rows.filter(r => r.native_id).map(r => r.outside_id) : []);
const answered = settledIds(state);
return [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside')]
.filter(r => !paired.has(r.id) && !(r as { settled_by?: string }).settled_by && !answered.has(r.id) && (r.severity === 'High' || r.severity === 'Critical')).length;
}
/** At the round cap with High or Critical findings still open, more rounds buy nothing: the owner decides. */
export function escalateLine(state: RunState): string | undefined {
const held = state.phase_order.filter(p => state.phases[p]?.closed_at && atCap(state, p)).map(p => [p, openHigh(state, p)] as const).filter(([, n]) => n > 0);
return held.length ? `ESCALATE: evidence sufficient, decision needed (${held.map(([p, n]) => `${p}: ${n} High open`).join('; ')})` : undefined;
}
export function roundsLine(state: RunState): string {
const cell = (p: string) => `${p}=${roundOf(state, p)}/${roundCap(state, p) ?? 'none'}`;
return `ROUNDS: ${state.phase_order.map(cell).join(' ')} max_rounds=${state.max_rounds ?? 'none'} host_spend=unknown`;
+20 -2
View File
@@ -6,7 +6,8 @@
* tables, the gate and the GUARD_NOT_INSTALLED line the validator requires),
* `findings.jsonl`, `tasks.jsonl`, `decisions.jsonl` (already written by the
* gate), `timing.json`, and `run.json` with every artifact's path and hash
* and the counts `gstack-artifact validate` checks against the files. The
* and the counts `gstack-artifact validate` checks against the files, plus
* the reviewer dispatch counts and their relative input-token estimate. The
* snapshot bin stays unchanged: this is `gstack-autoplan export --out <dir>`.
*/
import * as fs from 'node:fs';
@@ -14,7 +15,7 @@ import * as path from 'node:path';
import { PHASES } from './autoplan-prompts';
import type { ConsensusRecord } from './autoplan-reconcile';
import { decisionId } from './gate-list';
import { readDecisions, voiceRows, type RunState } from './autoplan-state';
import { readDecisions, readJournal, voiceRows, type DispatchCounts, type RunState, type Voice } from './autoplan-state';
import type { FindingRow, TaskRow } from './headless-artifacts';
import { readJsonl, renderUrgentBlock, sha256File } from './headless-artifacts';
@@ -105,6 +106,22 @@ function runLog(state: RunState, findings: number, tasks: number, decisions: { t
export interface ExportResult { warnings: string[]; files: string[] }
/** Every dispatched attempt in attempts.jsonl, whatever became of it; prompt bytes come from the row (older rows: the prompt file if still on disk). */
export function dispatchStats(out: string): { dispatches: DispatchCounts; est_input_tokens: number } {
const dispatches: DispatchCounts = { total: 0, by_phase: {} };
let bytes = 0;
for (const r of readJournal(out)) {
if (r.event !== 'dispatched') continue;
const by = (dispatches.by_phase[r.phase] ??= {});
by[r.voice as Voice] = (by[r.voice as Voice] ?? 0) + 1;
dispatches.total += 1;
const file = typeof r.prompt === 'string' ? path.resolve(out, r.prompt) : '';
bytes += typeof r.prompt_bytes === 'number' ? r.prompt_bytes : file && fs.existsSync(file) ? fs.statSync(file).size : 0;
}
return { dispatches, est_input_tokens: Math.round(bytes / 4) };
}
export const dispatchLine = (state: RunState) => `DISPATCHES: ${state.dispatches?.total ?? 0} est_input_tokens=${state.est_input_tokens ?? 0} (relative estimate)`;
/** Write the file set and bind every artifact into run.json (counts and hashes); the caller writes run.json. */
export function exportRun(state: RunState): ExportResult {
const warnings: string[] = [];
@@ -134,6 +151,7 @@ export function exportRun(state: RunState): ExportResult {
for (const phase of state.phase_order) for (const name of [`${phase}-consensus.md`, `${phase}-consensus.json`]) {
if (fs.existsSync(path.join(state.out, name))) state.artifacts[name] = { path: name, sha256: sha256File(path.join(state.out, name)) };
}
Object.assign(state, dispatchStats(state.out));
state.counts = { decisions: decisions.length, decisions_pending: decisions.filter(d => d.status === 'pending').length, findings: findings.length, tasks: tasks.length };
return { warnings, files };
}
+12 -4
View File
@@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { bunIsolationArgs } from './bun-isolation';
import { readScopeMarker, scopeMarker, scopeTokens, type ScopeFlags, type UiEvidence } from './autoplan-scope';
import { detectDocsOnly, readScopeMarker, scopeMarker, scopeTokens, type ScopeFlags, type UiEvidence } from './autoplan-scope';
import { COORDINATOR_CONTRACT } from './coordinator-contract';
import { capyBootstrapCommand } from './doctor-components';
import { DX_ERROR_COMPACT } from './dx-error-contract';
@@ -39,6 +39,8 @@ export interface Scope {
pin?: { marker: string; file: string } | null;
/** Runs created before per-axis provenance. */
source?: 'detected' | 'flags';
/** A docs-only plan runs CEO (native voice only) and Eng; `--docs-only`, the marker, or every Implementation-plan path docs-class. */
docs_only?: boolean; docs_only_source?: ScopeSource;
}
export function snapshotTool(args: string[], cwd: string): any {
@@ -61,13 +63,19 @@ export function detectScope(activePlan: string, cwd: string, flags: ScopeFlags,
const dxSource: ScopeSource = flags.dx !== undefined ? 'flag' : m.dx !== undefined ? 'marker'
: flags.developerTool || flags.agentPrimary ? 'flag' : m.developerTool || m.agentPrimary ? 'marker' : 'detected';
const cli = scopeTokens(flags);
const merged = scopeTokens({ ui: flags.ui ?? m.ui, dx: flags.dx ?? m.dx, developerTool, agentPrimary });
const merged = scopeTokens({ ui: flags.ui ?? m.ui, dx: flags.dx ?? m.dx, developerTool, agentPrimary, docsOnly: !!(flags.docsOnly || m.docsOnly) });
const pinned = !cli.length || (marker !== null && [...merged].sort().join(' ') === [...marker.tokens].sort().join(' '));
// A flag or marker that turns a phase on outranks docs-only detection; --docs-only itself outranks everything.
const enables = flags.ui === true || m.ui === true || developerTool || agentPrimary;
const planText = fs.readFileSync(activePlan, 'utf8');
const docsOnlySource: ScopeSource = flags.docsOnly ? 'flag' : m.docsOnly ? 'marker' : 'detected';
const docsOnly = !!(flags.docsOnly || m.docsOnly) || (!enables && detectDocsOnly(extractHeading(planText, 'Implementation plan') ?? planText, cwd).docsOnly);
return {
ui: flags.ui ?? m.ui ?? detected.uiRequired, dx: flags.dx ?? m.dx ?? detected.dxRequired,
ui_matches: detected.uiMatches, dx_matches: detected.matchCount, developer_tool: developerTool, agent_primary: agentPrimary,
sources: { ui: flags.ui !== undefined ? 'flag' : m.ui !== undefined ? 'marker' : 'detected', dx: dxSource }, ui_evidence: detected.uiEvidence, dx_terms: detected.matches,
cli_flags: cli, marker: marker?.tokens ?? null, pin: pinned ? null : { marker: scopeMarker(merged), file: sourcePlan },
docs_only: docsOnly, docs_only_source: docsOnlySource,
};
}
@@ -85,12 +93,12 @@ export function scopeLines(scope: Scope): string[] {
const ev = scope.ui_evidence;
const ui = `ui=${scope.ui ? 'yes' : 'no'} (terms: ${list(ev?.terms)}; visual: ${ev?.visual.length ? ev.visual.join(', ') : 'none'}; files: ${ev?.files.length ? ev.files.join(', ') : 'none'})`;
const dx = `dx=${scope.dx ? 'yes' : 'no'} (detected: ${list(scope.dx_terms)}${scope.developer_tool ? '; developer tool' : ''}${scope.agent_primary ? '; agent primary' : ''})`;
const lines = [`SCOPE: ${ui} ${dx} source=${scopeSourceLabel(scope)}`];
const lines = [`SCOPE: ${ui} ${dx} docs_only=${scope.docs_only ? 'yes' : 'no'} (${scope.docs_only_source ?? 'detected'}) source=${scopeSourceLabel(scope)}`];
if (scope.pin) lines.push(`SCOPE_PIN: add ${scope.pin.marker} as the first line of ${scope.pin.file} to keep these flags`);
return lines;
}
export function phaseOrder(scope: Scope): Phase[] {
return PHASES.filter(p => (p === 'design' ? scope.ui : p === 'dx' ? scope.dx : true));
return PHASES.filter(p => (p === 'design' ? scope.ui && !scope.docs_only : p === 'dx' ? scope.dx && !scope.docs_only : true));
}
/** Extract `## <heading>` … next same-level heading, for repos whose plans use a custom heading. */
+43 -8
View File
@@ -9,7 +9,11 @@
* when named: `--incorporates <ids>` binds each id to the old and new hashes
* and refuses an unchanged plan; `--withdraw <id>` is the owner's journaled
* decision to drop one. Archives keep `.r<rev>` names with `.v<k>` only on
* collision. One recoverable transaction: the staged plan and state are
* collision. An edit that only incorporates decisions whose source findings
* are all below High (no user challenge, no newly in-scope phase) applies
* without re-review: the touched phases' reviewed hash is rebound to the
* revision and `applied_without_rereview` is journaled; anything unsure
* reopens. One recoverable transaction: the staged plan and state are
* written and `revise_intent` journaled before anything moves; `resume`
* (recoverRevise) completes an interrupted revise from the stage.
*/
@@ -17,13 +21,13 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
import { parseScopeTokens } from './autoplan-scope';
import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts';
import { FILES, RunError, journal, now, pendingAmendments, readJournal, readState, writeState, type RunState } from './autoplan-state';
import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceRows, writeState, type RunState } from './autoplan-state';
export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; settle?: string[]; cwd: string }
export interface ReviseResult { state: RunState; reopened: Phase[]; lines: string[] }
@@ -82,11 +86,31 @@ function settleEvidence(state: RunState, ids: string[], revised?: string): { pla
return { plan_sha256: active, changes };
}
const SEVERITY_ORDER = ['Informational', 'Low', 'Medium', 'High', 'Critical'];
export const BELOW_HIGH = ['Informational', 'Low', 'Medium'];
/**
* The severity an incorporated decision resolves to: the highest of the findings its gate item named (the
* decisions.jsonl origin's ids, looked up in that phase's bound rows). `unknown` when the row, its origin or a
* finding is missing; `challenge` for a user challenge, whose direction change is always re-reviewed.
*/
export function decisionSeverity(state: RunState, decision: string): string {
const row = readDecisions(state.out).find(d => d.id === decision);
const [phase, ids] = row?.origin?.split('|') ?? [];
if (!phase || !ids || !state.phases[phase]?.closed_at) return 'unknown';
const rows = [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside')];
const found = ids.split('+').filter(id => id && id !== 'null').map(id => rows.find(r => r.id === id));
if (!found.length || found.some(f => !f)) return 'unknown';
if (found.some(f => (f as { user_challenge?: boolean }).user_challenge)) return 'challenge';
return found.map(f => f!.severity).reduce((a, b) => (SEVERITY_ORDER.indexOf(b) > SEVERITY_ORDER.indexOf(a) ? b : a));
}
/** Every accepted change is in: approve, unless a plan delta after a round cap still needs the owner (a new gate revision). */
function approveOrRegate(state: RunState, at: string): string[] {
if (pendingDeltas(state).length) {
finishGate(state);
return [`GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`];
const escalate = escalateLine(state);
return [`GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
@@ -142,9 +166,13 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
const opened = state.phase_order.filter(p => state.phases[p]?.snapshot);
const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash);
const touched = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0));
// Below-High decisions only, every touched phase closed, nothing newly in scope (no added phase, docs-only still holds): the edit is theirs, so no re-review.
const severities = incorporates.map(id => [id, decisionSeverity(state, id)] as const);
const quiet = severities.length > 0 && severities.every(([, sev]) => BELOW_HIGH.includes(sev)) && touched.length > 0 && !added.length
&& touched.every(p => state.phases[p]?.closed_at) && !(state.scope.docs_only && !detected.docs_only);
// A phase that used its last round is not reopened: the delta becomes an owner gate item, so the cap neither loops nor hides it.
const capped = touched.filter(p => atCap(state, p));
const reopen = touched.filter(p => !capped.includes(p));
const capped = quiet ? [] : touched.filter(p => atCap(state, p));
const reopen = quiet ? [] : touched.filter(p => !capped.includes(p));
const delta = capped.length ? diffStat(lineDiff(fs.readFileSync(state.active_plan, 'utf8'), fs.readFileSync(path.join(staging, FILES.active), 'utf8'))) : { added: 0, removed: 0 };
const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle });
@@ -154,6 +182,11 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
(state.capped_deltas ??= []).push({ k, phase, round: roundOf(state, phase), cap: roundCap(state, phase)!, old_plan_sha256: oldHash, new_plan_sha256: newHash, ...delta, status: 'pending', at });
journal(out, { event: 'cap_delta', k, phase, round: roundOf(state, phase), cap: roundCap(state, phase), old_plan_sha256: oldHash, new_plan_sha256: newHash, ...delta });
}
for (const p of quiet ? touched : []) {
const ps = state.phases[p]!;
(ps.rebound ??= []).push({ k, from: ps.snapshot!, to: newHash, decisions: incorporates, at });
ps.snapshot = newHash;
}
for (const p of added) state.phases[p] = {};
state.phase_order = order; state.required_phases = order;
state.scope = { ...detected, ui: detected.ui || state.scope.ui, dx: detected.dx || state.scope.dx };
@@ -161,13 +194,15 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
state.plan_input = file; state.plan_source = path.join(out, `plan-source.v${k}.md`); state.plan_revision = k;
delete state.plan_reality;
const settled = [...settleAmendments(state, incorporates, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' }),
...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] }))];
...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] })),
...(quiet ? [{ event: 'applied_without_rereview', k, decisions: incorporates, severities: Object.fromEntries(severities), phases: touched, old_plan_sha256: oldHash, new_plan_sha256: newHash }] : [])];
if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : pendingDeltas(state).length ? 'gate_pending' : 'complete';
const regate = capped.length > 0 && state.status === 'gate_pending';
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
publish(state, staging, renames, committed, settled);
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`, amendmentLine(state)];
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`,
...(quiet ? [`APPLIED: ${incorporates.join(',')} without re-review (below High)`] : []), amendmentLine(state)];
for (const phase of capped) lines.push(`CAPPED: ${phase} used round ${roundOf(state, phase)} of ${roundCap(state, phase)}: not reopened; the gate asks the owner about the unreviewed delta (+${delta.added} -${delta.removed} lines)`);
if (regate) {
lines.push(...approveOrRegate(state, at));
+5 -3
View File
@@ -109,7 +109,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
const consentMissing = consent && !consent.allowed ? consent.lines[0] : undefined;
const sessionKind = o.sessionKind ?? process.env.GSTACK_SESSION_KIND ?? 'unattended';
const phases: Record<string, PhaseState> = {};
for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } };
for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } };
const state: RunState = {
schema_version: 1, run, status: 'running', required_phases: order, phases, artifacts: {}, revision: 0, out,
plan_source: init.sourcePlan, active_plan: init.activePlan, restore: init.restorePath, scope, phase_order: order,
@@ -169,7 +169,8 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
ps.sections = { loaded: sections.loaded, skipped: sections.skipped, checklist: !!sections.checklist };
ps.native = { status: 'pending', attempts: [] };
const notRun = state.outside_not_run as { reason: string; detail: string } | undefined;
ps.outside = state.outside_enabled ? { status: 'pending', attempts: [] }
ps.outside = phase === 'ceo' && state.scope.docs_only ? { status: 'disabled', outcome: 'not_run', reason: 'docs_only', detail: 'docs-only plan: CEO runs the native voice only; Eng keeps both voices' }
: state.outside_enabled ? { status: 'pending', attempts: [] }
: notRun ? { status: 'disabled', outcome: 'not_run', reason: notRun.reason, detail: notRun.detail } : { status: 'disabled' };
ps.prompts = prompts;
journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) });
@@ -202,6 +203,7 @@ export function nextStep(o: NextOptions): NextResult {
if (drift) lines.push(drift);
if (first) lines.push(...scopeLines(state.scope), `DRIVER: ${driverFile()}`);
if (ps.outside?.reason === 'consent_missing') lines.push(`OUTSIDE: ${phase} not_run reason=consent_missing`, String(ps.outside.detail));
if (ps.outside?.reason === 'docs_only') lines.push(`OUTSIDE: ${phase} not_run reason=docs_only (native voice only)`);
if (ps.sections) lines.push(`Skipped sections: ${ps.sections.skipped.length ? ps.sections.skipped.join(',') : 'none'} (scope: ui=${state.scope.ui ? 'yes' : 'no'},dx=${state.scope.dx ? 'yes' : 'no'},source=${scopeSourceLabel(state.scope)}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`);
const prompts = ps.prompts!;
const estimate = attemptEstimate(state, o.estimateUsd, process.env);
@@ -231,7 +233,7 @@ export function nextStep(o: NextOptions): NextResult {
catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; }
const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate.usd, dispatched_at: now() };
bindPromptResult(row.prompt, row.result);
journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), result: path.relative(state.out, row.result), estimate_usd: estimate.usd });
journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), prompt_bytes: fs.statSync(row.prompt).size, result: path.relative(state.out, row.result), estimate_usd: estimate.usd });
(v.attempts ??= []).push(row);
v.attempt = attempt; v.status = 'running';
result.attempts.push({ attempt, voice, prompt: row.prompt, result: row.result, model_family: familyHint(state, voice), new: true });
+31 -4
View File
@@ -7,8 +7,12 @@
* file, or a UI term next to a visual noun. Bare UI words are weak evidence:
* they keep the runner's conservative section fallback but do not add the
* Design phase. The scope marker is the author's way to pin flags in the plan
* itself; it is read, never written.
* itself; it is read, never written. `detectDocsOnly` classifies the paths
* the Implementation plan names: a plan that touches only markdown no agent
* or gate consumes is docs-only (CEO + Eng, outside voice on Eng only).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export const UI_TERMS = ['component', 'screen', 'form', 'button', 'modal', 'layout', 'dashboard', 'sidebar', 'nav', 'dialog'] as const;
export const VISUAL_NOUNS = ['page', 'layout', 'style', 'pixel', 'view', 'browser', 'css'] as const;
@@ -44,13 +48,35 @@ export function detectUiScope(implementationPlan: string): UiScope {
return { uiRequired: files.length > 0 || visual.length > 0, uiMatches: terms.reduce((s, t) => s + t.count, 0), uiEvidence: { terms, visual: [...new Set(visual)], files } };
}
export interface ScopeFlags { ui?: boolean; developerTool?: boolean; agentPrimary?: boolean; dx?: boolean }
export const SCOPE_FLAG_TOKENS = ['--ui', '--no-ui', '--no-dx', '--developer-tool', '--agent-primary'] as const;
/** Markdown an agent or a gate reads is code: skill prose and its sections, resolvers, DRIVER and template prose, policy docs, agent instructions. */
const CODE_MARKDOWN = [/(?:^|\/)SKILL\.md/i, /(?:^|\/)sections\//, /(?:^|\/)scripts\/resolvers\//, /(?:^|\/)DRIVER[^/]*$/, /\.tmpl$/, /(?:^|\/)templates?\//, /^docs\/[^/]*-policy\.md$/, /(?:^|\/)(?:AGENTS|CLAUDE)\.md$/i];
/** Docs-class: README*, CHANGELOG*, TODOS*, docs/** and *.md outside a skill directory (one holding SKILL.md or SKILL.md.tmpl under `root`). */
export function docsClassPath(file: string, root: string): boolean {
const p = file.replace(/^\.\//, '');
if (CODE_MARKDOWN.some(re => re.test(p))) return false;
if (/^(?:README|CHANGELOG|TODOS)/i.test(path.basename(p)) || p.startsWith('docs/')) return true;
if (!/\.md$/i.test(p)) return false;
const top = p.includes('/') ? p.split('/')[0]! : '';
return !top || !['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f)));
}
export interface DocsOnlyScope { docsOnly: boolean; paths: string[]; code: string[] }
/** Docs-only when the Implementation plan names at least one path and every one is docs-class; unsure means not docs-only. */
export function detectDocsOnly(implementationPlan: string, root: string): DocsOnlyScope {
const paths = [...new Set([...implementationPlan.matchAll(/[\w@~$.\/*-]+/g)].map(m => m[0].replace(/[.\-]+$/, ''))
.filter(t => !/^[a-z]+:\/\//i.test(t) && t.length > 1 && (t.includes('/') || /\.[a-z][a-z0-9]*$/i.test(t))))];
const code = paths.filter(p => !docsClassPath(p, root));
return { docsOnly: paths.length > 0 && code.length === 0, paths, code };
}
export interface ScopeFlags { ui?: boolean; developerTool?: boolean; agentPrimary?: boolean; dx?: boolean; docsOnly?: boolean }
export const SCOPE_FLAG_TOKENS = ['--ui', '--no-ui', '--no-dx', '--developer-tool', '--agent-primary', '--docs-only'] as const;
const MARKER_RE = /^<!--\s*gstack-autoplan-scope:(.*?)-->\s*$/;
export function scopeTokens(f: ScopeFlags): string[] {
return [...(f.ui === true ? ['--ui'] : f.ui === false ? ['--no-ui'] : []), ...(f.dx === false ? ['--no-dx'] : []),
...(f.developerTool ? ['--developer-tool'] : []), ...(f.agentPrimary ? ['--agent-primary'] : [])];
...(f.developerTool ? ['--developer-tool'] : []), ...(f.agentPrimary ? ['--agent-primary'] : []), ...(f.docsOnly ? ['--docs-only'] : [])];
}
export function parseScopeTokens(tokens: readonly string[], where: string): ScopeFlags {
@@ -62,6 +88,7 @@ export function parseScopeTokens(tokens: readonly string[], where: string): Scop
...(tokens.includes('--no-dx') ? { dx: false } : {}),
...(tokens.includes('--developer-tool') ? { developerTool: true } : {}),
...(tokens.includes('--agent-primary') ? { agentPrimary: true } : {}),
...(tokens.includes('--docs-only') ? { docsOnly: true } : {}),
};
}
+6
View File
@@ -50,7 +50,11 @@ export interface PhaseState {
deferred?: DeferredClose;
/** A confirm round (r > 1): the revision this phase last reviewed. */
confirm?: { round: number; previous_sha256?: string; added: number; removed: number };
/** Revisions bound to this phase without re-review: every incorporated decision was below High (lib/autoplan-revise.ts). */
rebound?: Array<{ k: number; from: string; to: string; decisions: string[]; at: string }>;
}
/** Reviewer attempts journaled as dispatched (failed, unknown and superseded ones included), per phase and voice. */
export interface DispatchCounts { total: number; by_phase: Record<string, Partial<Record<Voice, number>>> }
export interface RunState extends RunManifest {
revision: number; out: string; plan_source: string; active_plan: string; restore: string; scope: Scope; phase_order: Phase[];
phases: Record<string, PhaseState>; native_model?: string; outside_model?: string; outside_enabled: boolean; attempt_counter: number;
@@ -64,6 +68,8 @@ export interface RunState extends RunManifest {
/** Review rounds per phase (null: uncapped); round_grants are the owner's per-phase raises at the gate. */
max_rounds?: number | null; round_grants?: Record<string, number>;
stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[];
/** Written by export from attempts.jsonl; est_input_tokens is dispatched prompt bytes / 4, a relative estimate only. Absent on older runs. */
dispatches?: DispatchCounts; est_input_tokens?: number;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+4 -2
View File
@@ -98,7 +98,7 @@ export interface ShipReceipt {
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string };
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; dispatches?: Record<string, number>; est_input_tokens?: number };
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] };
}
@@ -195,6 +195,8 @@ export const ARTIFACT_SCHEMAS = {
gate_rev: { type: 'integer' }, gate: { type: 'object' },
consumed_by: { type: 'array', items: { type: 'object', required: ['consumer', 'at'], properties: { consumer: str, at: str } } },
deviations: strList,
dispatches: { type: 'object', description: 'total and by_phase → { native, outside }: reviewer attempts from attempts.jsonl, failed and unknown included' },
est_input_tokens: { type: 'integer', minimum: 0, description: 'dispatched prompt bytes / 4: a relative estimate, never a bill' },
},
},
pregate: {
@@ -216,7 +218,7 @@ export const ARTIFACT_SCHEMAS = {
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str } },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
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 } },
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' } } } } } },
},
+5
View File
@@ -264,6 +264,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR body quotes a counter the ship receipt also records (review passes, deferred findings, gate case counts) with a different number, so the prose and the receipt disagree about the same run',
fix: 'correct the body to the receipt\'s number (the PROSE: line names each field with both values); when the receipt is the stale one, rerun /ship so the receipt is rewritten from the run',
},
RECEIPT_CARRY_PREGATE_STALE: {
anchor: 'receipt-carry-pregate-stale',
summary: 'gstack-ship-receipt carry would rebind a receipt that records a pregate without a pregate result current for HEAD, so the carried receipt would claim the old tree\'s checks',
fix: 'run gstack-pregate at HEAD (the remote tests stage when the policy requires it), then rerun carry with --pregate <pregate.json>',
},
RECEIPT_GIT_TOO_OLD: {
anchor: 'receipt-git-too-old',
summary: 'gstack-ship-receipt carry needs git 2.39 or later for `git patch-id --verbatim`; the installed git is older, so the product diff could not be compared',
+212
View File
@@ -0,0 +1,212 @@
/**
* review-depth — the one policy owner for how much review a /ship run dispatches
* (docs/review-policy.md#review-depth). A pure function from the changed paths,
* gstack-diff-scope flags, the diff size, the gate scan and the pass history to a
* diff class, the next step of the loop and the reviewers that step dispatches,
* each with its reasoning-effort tier. bin/gstack-review-plan prints it; the
* review-army resolver and the skills read the same definitions.
*/
import { createHash } from 'node:crypto';
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
export type PathClass = 'docs' | 'tests' | 'code';
export type Effort = 'light' | 'full' | 'host';
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check';
export type Step = 'full' | 'fix-check' | 'done' | 'stop';
export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const;
export type Specialist = (typeof SPECIALISTS)[number];
export const FULL_PASS_CAP = 2;
export const SMALL_DIFF_LINES = 50;
export const RED_TEAM_LINES = 200;
const CLASS_RANK: Record<DiffClass, number> = { docs: 0, tests: 1, code: 2, gate: 3 };
const AGENT_PROSE = [/(^|\/)SKILL\.md(\.tmpl)?$/, /(^|\/)sections\//, /^scripts\/resolvers\//, /^docs\/[^/]*-policy\.md$/, /(^|\/)DRIVER\.md(\.tmpl)?$/, /(^|\/)templates\//, /(^|\/)(AGENTS|CLAUDE|ETHOS)\.md$/, /(^|\/)specialists\//, /(^|\/)checklist\.md$/, /^\.gstack\//];
/** gstack-diff-scope's TEST_PATH_RE (the test-bootstrap rule), plus fixture directories. */
const TEST_PATH = [/(^|\/)(tests?|spec|__tests__)\/|(^|\/)tests?\.py$|(^|\/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$/, /(^|\/)fixtures?\//];
const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
/** Release metadata a version stamp writes: never a reason for review depth on its own. */
const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//];
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)$/];
/** One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy docs, templates) is code: it is gstack's product. */
export function classifyPath(p: string): PathClass {
if (RELEASE_METADATA.some(r => r.test(p))) return 'docs';
if (AGENT_PROSE.some(r => r.test(p))) return 'code';
if (TEST_PATH.some(r => r.test(p))) return 'tests';
if (DOC_PATH.test(p)) return 'docs';
return 'code';
}
export interface ClassInput {
paths: readonly string[];
/** gstack-diff-scope output (SCOPE_* keys). */
scope?: Record<string, string>;
/** Gate hunks the gate scan tagged (an RH rule: a relaxed threshold, a skipped test, a loosened check); null when the scan did not run or failed. Plain test edits are listed, never tagged, and do not count. */
gateTagged: number | null;
/** Paths the diff deletes or renames away. */
deleted?: readonly string[];
/** Paths whose whole change is a version stamp (package.json's version key): classified as docs. */
stampOnly?: readonly string[];
}
export interface ClassResult { class: DiffClass; reasons: string[] }
/** The diff's class: the most restrictive of its paths, raised to gate by any gate signal; errors fail closed. */
export function classifyDiff(i: ClassInput): ClassResult {
const reasons: string[] = [];
let cls: DiffClass = 'docs';
const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); };
if (!i.paths.length) raise('docs', 'no changed paths');
for (const p of i.paths) { const c = (i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
const s = i.scope ?? {};
if (s.SCOPE_ERROR) raise('code', `scope error: ${s.SCOPE_ERROR}`);
const codeLike = CLASS_RANK[cls] >= CLASS_RANK.code;
if (codeLike) {
if (s.SCOPE_AUTH === 'true') raise('gate', 'auth paths');
if (s.SCOPE_MIGRATIONS === 'true') raise('gate', 'migrations');
if (i.gateTagged === null) raise('gate', 'gate scan unavailable');
else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
const deletedCode = (i.deleted ?? []).filter(p => classifyPath(p) === 'code');
if (deletedCode.length) raise('gate', `deletes ${deletedCode.length} code path(s)`);
}
return { class: cls, reasons };
}
// ---------------------------------------------------------------------------
// numeric claims: the outside voice's trigger on docs and tests diffs
// ---------------------------------------------------------------------------
const CLAIM = new RegExp([
String.raw`\d+(?:\.\d+)?\s?%`, String.raw`[$€£]\s?\d`, String.raw`\b\d+(?:\.\d+)?\s?x\b`,
String.raw`\b\d+(?:\.\d+)?\s?(?:ms|s|seconds?|minutes?|hours?|tokens?|credits?|[kKmM]tok)\b`,
String.raw`(?<![\d.])\d+\.\d+(?![\d.])`,
String.raw`[=≈<>≤≥]\s*-?\d`, String.raw`\b\d+\s*/\s*\d+\b`, String.raw`\b\d+(?:\.\d+)?e-?\d+\b`,
String.raw`\b(?:p|n|median|mean|average|rate|score|accuracy|recall|precision|speedup|faster|slower|cheaper|cost|hits?|misses)\b[^\n]{0,40}\d`,
].join('|'), 'i');
/** Not claims: dates, versions, issue numbers, step numbers. Stripped before the scan. */
const NOT_CLAIM = /\b\d{4}-\d{2}-\d{2}\b|\bv?\d+(?:\.\d+){2,}\b|#\d+\b|\b(?:step|phase|pass|rule|item|round)\s+\d+(?:\.\d+)?\b/gi;
export interface NumbersResult { publishes: boolean; claims: number; fingerprint: string }
/** Added lines (path → lines) → whether the diff publishes numeric claims, and a fingerprint of those claims and their files. */
export function scanNumbers(added: Record<string, readonly string[]>): NumbersResult {
const hits: string[] = [];
for (const p of Object.keys(added).sort()) for (const line of added[p] ?? []) if (CLAIM.test(line.replace(NOT_CLAIM, ' '))) hits.push(`${p}\t${line.trim()}`);
const fingerprint = hits.length ? createHash('sha256').update(hits.join('\n')).digest('hex').slice(0, 16) : 'none';
return { publishes: hits.length > 0, claims: hits.length, fingerprint };
}
// ---------------------------------------------------------------------------
// the loop: which step comes next
// ---------------------------------------------------------------------------
export interface History {
/** Full review passes already run in this invocation (0 before pass 1). */
fullPasses: number;
/** The step that just finished; none before pass 1. */
last: 'none' | 'full' | 'fix-check';
/** The finished step fixed at least one blocking finding. */
fixedBlocking?: boolean;
/** The finished step fixed findings below the floor (pass 1 only fixes those). */
fixedBelow?: boolean;
/** A blocking finding is still open in fix scope (unfixed). */
blockingOpen?: boolean;
/** A fix in the finished step touched a gate-class path. */
touchedGate?: boolean;
}
export interface NextStep { step: Step; reason: string; effort: Effort }
/**
* The transition table (docs/review-policy.md#transitions). Pass 1 is full. A fixed
* blocking finding earns a second full pass on the range; any other fix earns one
* fix-check; two full passes is the cap; a blocker still open after the last
* allowed step stops for the owner.
*/
export function nextStep(h: History): NextStep {
if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff', effort: 'full' };
const fixed = !!(h.fixedBlocking || h.fixedBelow);
if (h.last === 'fix-check') return h.blockingOpen || fixed ? { step: 'stop', reason: 'the fix-check found a blocking finding: name it for the owner, never a third full pass', effort: 'full' } : { step: 'done', reason: 'the fix-check found nothing blocking', effort: 'full' };
if (h.blockingOpen && h.fullPasses >= FULL_PASS_CAP) return { step: 'stop', reason: `a blocking finding is open after ${FULL_PASS_CAP} full passes: name it for the owner`, effort: 'full' };
if (!fixed) return h.blockingOpen ? { step: 'stop', reason: 'a blocking finding is open with no fix applied', effort: 'full' } : { step: 'done', reason: 'the step applied no edits', effort: 'full' };
if ((h.fixedBlocking || h.touchedGate) && h.fullPasses < FULL_PASS_CAP) return { step: 'full', reason: h.fixedBlocking ? 'a blocking finding was fixed: full pass on the range' : 'a fix touched a gate-class path: full pass on the range', effort: 'full' };
return { step: 'fix-check', reason: h.fixedBlocking ? `full-pass cap ${FULL_PASS_CAP} reached: one fix-check reads the fix` : 'only below-floor fixes: one fix-check reads the touched hunks', effort: h.touchedGate ? 'full' : 'light' };
}
// ---------------------------------------------------------------------------
// the dispatch plan of one step
// ---------------------------------------------------------------------------
export interface PlanInput extends ClassInput {
diffLines: number;
history: History;
numbers: NumbersResult;
/** Specialists force-included by prompt flags; 'all' for --all-specialists. */
force?: readonly string[] | 'all';
/** Specialists the hit-rate stats tag GATE_CANDIDATE. */
gated?: readonly string[];
/** A specialist produced a CRITICAL finding (Red Team activation on code class). */
criticalFound?: boolean;
/** The numeric-claim fingerprint the outside voice last reviewed; a change reruns it. */
reviewedNumbers?: string;
/** review_effort_tiers: off renders every role at the host's default effort. */
tiers?: boolean;
}
export interface Dispatch { role: Role; name: string; effort: Effort }
export interface ReviewPlan { class: DiffClass; reasons: string[]; next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[] }
/** The specialists a full pass selects for a class: class sets the eligible set, hit-rate gating applies inside it, gate class ignores gating. */
export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] {
const s = i.scope ?? {};
const forced = (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n));
if (cls === 'docs') { skipped.push('specialists (docs class)'); return SPECIALISTS.filter(forced); }
if (cls === 'tests') { skipped.push('specialists except testing (tests class)'); return SPECIALISTS.filter(n => n === 'testing' || forced(n)); }
const want = (n: Specialist): boolean => {
if (forced(n)) return true;
if (n === 'state-machines') return s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined;
if (i.diffLines < SMALL_DIFF_LINES) return false;
switch (n) {
case 'testing': case 'maintainability': return true;
case 'security': return s.SCOPE_AUTH === 'true' || (s.SCOPE_BACKEND === 'true' && i.diffLines > 100);
case 'performance': return s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true';
case 'data-migration': return s.SCOPE_MIGRATIONS === 'true';
case 'api-contract': return s.SCOPE_API === 'true';
case 'design': return s.SCOPE_FRONTEND === 'true';
case 'simplification': return i.diffLines > 100;
}
};
return SPECIALISTS.filter(n => {
if (!want(n)) return false;
if (cls !== 'gate' && !forced(n) && (i.gated ?? []).includes(n)) { skipped.push(`${n} (gated by hit rate)`); return false; }
return true;
});
}
/** The reviewers the next step dispatches, with each role's effort tier. */
export function planReview(i: PlanInput): ReviewPlan {
const { class: cls, reasons } = classifyDiff(i);
const next = nextStep(i.history);
const tier = (e: Effort): Effort => (i.tiers === false ? 'host' : e);
const skipped: string[] = [];
const dispatches: Dispatch[] = [];
const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint;
if (next.step === 'full') {
for (const n of specialistsFor(cls, i, skipped)) dispatches.push({ role: 'specialist', name: n, effort: tier('light') });
const redTeam = cls === 'gate' || (cls === 'code' && (i.diffLines > RED_TEAM_LINES || !!i.criticalFound));
if (redTeam) dispatches.push({ role: 'red-team', name: 'red-team', effort: tier('full') });
else skipped.push(`red-team (${cls} class${cls === 'code' ? `, ${i.diffLines} lines, no critical` : ''})`);
if (i.history.fullPasses === 0) {
if (cls === 'docs' || cls === 'tests') skipped.push(`adversarial-native (${cls} class)`);
else dispatches.push({ role: 'adversarial-native', name: 'adversarial', effort: tier('full') });
if (cls === 'code' || cls === 'gate' || i.numbers.publishes) dispatches.push({ role: 'outside-voice', name: 'outside-voice', effort: 'host' });
else skipped.push(`outside-voice (${cls} class, no numeric claims)`);
} else {
skipped.push('adversarial-native and outside-voice (run once, on pass 1)');
if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' });
}
} else if (next.step === 'fix-check') {
dispatches.push({ role: 'fix-check', name: 'fix-check', effort: tier(next.effort) });
if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' });
}
return { class: cls, reasons, next, numbers: i.numbers, dispatches, skipped };
}
export function reviewPlanLine(p: ReviewPlan, pass: number): string {
return `REVIEW_PLAN: class=${p.class} pass=${pass} next=${p.next.step} dispatches=${p.dispatches.length} numbers=${p.numbers.publishes ? `yes(${p.numbers.claims})` : 'no'} fingerprint=${p.numbers.fingerprint}`;
}
+45 -5
View File
@@ -234,7 +234,15 @@ export function renderDeferredSection(deferred: readonly DeferredRecord[], noted
// ---------------------------------------------------------------------------
// loop summary: what the ship receipt carries for the factory scorecard
// ---------------------------------------------------------------------------
export interface ReviewLoopSummary { passes: number; blocking_found: number; deferred: number; noted: number; fixed: number; scope: ReviewScopePolicy | 'unknown' }
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). */
dispatches?: Record<string, number>;
/** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */
est_input_tokens?: number;
}
/** Review-log rows of one /ship invocation (skill review, via ship, at or after `since`) → the loop summary. */
/**
@@ -259,11 +267,27 @@ function rowTime(row: Record<string, any>): number {
* row without a start token would read as UNVERIFIED and displace the pass).
*/
export const LATE_DEFER_SKILL = 'review-late-defer';
/** A fix-check (one reviewer reading the touched hunks and the fix list) logs under its own skill, so it is never a pass. */
export const FIX_CHECK_SKILL = 'review-fix-check';
const DISPATCH_SKILLS = ['review', LATE_DEFER_SKILL, FIX_CHECK_SKILL, 'adversarial-review'];
/** Dispatch records of one row: its `dispatches` array, else the specialists and Red Team its stats say ran (older rows). */
function rowDispatches(row: Record<string, any>): Array<{ role: string; bytes: number | null }> {
if (Array.isArray(row.dispatches)) {
return row.dispatches.filter((d: any) => d && typeof d.role === 'string').map((d: any) => {
const b = [d.prompt_bytes, d.diff_bytes].filter((x: unknown) => typeof x === 'number' && Number.isFinite(x) && x >= 0) as number[];
return { role: String(d.role).slice(0, 40), bytes: b.length ? b.reduce((a, c) => a + c, 0) : null };
});
}
if (row.skill !== 'review' || !row.specialists || typeof row.specialists !== 'object') return [];
return Object.entries(row.specialists as Record<string, any>).filter(([, s]) => s && s.dispatched === true)
.map(([name]) => ({ role: name === 'red-team' ? 'red-team' : 'specialist', bytes: null }));
}
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string): ReviewLoopSummary {
const sinceMs = Date.parse(since);
const loopRows = rows.filter(r => r && (r.skill === 'review' || r.skill === LATE_DEFER_SKILL) && r.via === 'ship' && Number.isFinite(rowTime(r))
&& (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs));
const inRun = (r: Record<string, any>) => r && r.via === 'ship' && Number.isFinite(rowTime(r)) && (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs);
const loopRows = rows.filter(r => inRun(r) && (r.skill === 'review' || r.skill === LATE_DEFER_SKILL || r.skill === FIX_CHECK_SKILL));
const passes = loopRows.filter(r => r.skill === 'review');
const blocking = new Set<string>();
const deferred = new Set<string>();
@@ -281,9 +305,25 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
if (f.action === 'fixed' || f.action === 'auto-fixed') fixed.add(id);
}
}
return { passes: passes.length, blocking_found: blocking.size, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blocking.size, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
const fixChecks = loopRows.filter(r => r.skill === FIX_CHECK_SKILL).length;
if (fixChecks) out.fix_checks = fixChecks;
const byRole: Record<string, number> = {};
let bytes = 0; let measured = false;
for (const row of rows.filter(r => inRun(r) && DISPATCH_SKILLS.includes(r.skill))) {
for (const d of rowDispatches(row)) { byRole[d.role] = (byRole[d.role] ?? 0) + 1; if (d.bytes !== null) { bytes += d.bytes; measured = true; } }
}
if (Object.keys(byRole).length) out.dispatches = byRole;
if (measured) out.est_input_tokens = Math.round(bytes / 4);
return out;
}
export function dispatchTotal(d: Record<string, number> | undefined): number | undefined {
return d ? Object.values(d).reduce((a, c) => a + c, 0) : undefined;
}
export function reviewLoopLine(s: ReviewLoopSummary): string {
return `REVIEW_LOOP: passes=${s.passes} blocking_found=${s.blocking_found} deferred=${s.deferred} noted=${s.noted} fixed=${s.fixed} scope=${s.scope}`;
const total = dispatchTotal(s.dispatches);
return `REVIEW_LOOP: passes=${s.passes} blocking_found=${s.blocking_found} deferred=${s.deferred} noted=${s.noted} fixed=${s.fixed} scope=${s.scope}`
+ (s.fix_checks ? ` fix_checks=${s.fix_checks}` : '') + (total !== undefined ? ` dispatches=${total}` : '') + (s.est_input_tokens !== undefined ? ` est_input_tokens=${s.est_input_tokens}` : '');
}
+9 -1
View File
@@ -120,7 +120,8 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
const loop = parseReviewLoop(r.review_loop);
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`);
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
+ (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : ''));
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
}
@@ -268,6 +269,13 @@ export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
if (n('noted') !== undefined) out.noted = n('noted');
if (n('fixed') !== undefined) out.fixed = n('fixed');
if (typeof v.scope === 'string') out.scope = v.scope.slice(0, 20);
if (n('fix_checks') !== undefined) out.fix_checks = n('fix_checks');
if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens');
if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) {
const d = Object.fromEntries(Object.entries(v.dispatches as Record<string, unknown>)
.filter(([k, c]) => /^[a-z][a-z0-9-]{0,39}$/.test(k) && typeof c === 'number' && Number.isInteger(c) && c >= 0).slice(0, 12)) as Record<string, number>;
if (Object.keys(d).length) out.dispatches = d;
}
return out;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Fix-check prompt (docs/review-policy.md#transitions): the one reviewer /ship dispatches after a
* step whose fixes do not earn a full pass. It reads the fixes, never the whole diff. The parent
* writes every input in literally: a reviewer never sees the parent's shell.
*/
export interface FixCheckInput { fixList: string; touchedHunks: string; callers: string; testResults: string }
export const FIX_CHECK_INSTRUCTIONS = `You are the fix-check reviewer for a pre-landing review. An earlier review pass found
findings and the author fixed them. You check only those fixes: the fix list, the hunks the fixes
touched, the callers of every symbol the fixes changed, and the test results after the fixes.
Find any defect a fix introduced or left in place: a caller the change breaks, a guard that now
fails open (accepts what it refused) or fails closed (refuses what it accepted), a regression of
behaviour the code had before the fix, an error or unreadable state now read as success, a test
made weaker. Name the input or call site that reaches each defect.
Rate each finding against the blocking floor: CRITICAL only when it would fail a test, break
production, or lose data or secrets; everything else is INFORMATIONAL. Output one JSON object per
line, {"severity":"CRITICAL|INFORMATIONAL","path":"<file>","line":<n>,"summary":"<what breaks, for which input>"},
or the two words NO FINDINGS when the fixes are sound.`;
export function renderFixCheckPrompt(i: FixCheckInput): string {
return [FIX_CHECK_INSTRUCTIONS, '', '## Fix list', i.fixList.trim(), '', '## Touched hunks', '```diff', i.touchedHunks.trim(), '```', '',
'## Callers of changed symbols', i.callers.trim() || '(none found)', '', '## Test results after the fixes', i.testResults.trim()].join('\n') + '\n';
}
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* review-dispatch-compare — how many reviewers a /ship run and an /autoplan run dispatch under the
* legacy policy and under docs/review-policy.md#review-depth, on fixed fixture diffs, with an input
* plus output token estimate per run. The proof-of-saving table in the GSTA-131 PR comes from here.
*
* bun scripts/review-dispatch-compare.ts [--fixture <file>]... [--tokens <measured.json>] [--json]
* bun scripts/review-dispatch-compare.ts --capture <sha> --name <name> --out <file>
*
* --capture writes a fixture from one commit (its diff against its first parent) through
* gstack-review-plan --json. --tokens takes the live per-dispatch output-token medians by role and
* effort ({ "specialist": { "full": n, "light": n }, ... }); without it the script uses the shipped
* defaults below and says so. Counts are exact for the modelled scenarios; tokens are estimates.
*/
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 { planReview, specialistsFor, type DiffClass, type Dispatch, type History, type NumbersResult } from '../lib/review-depth';
const ROOT = path.resolve(import.meta.dir, '..');
const BYTES_PER_TOKEN = 4;
const FIXED_AUDITS = ['coverage-audit', 'plan-completion', 'docs-audit'];
const RANGE_FRACTION = 0.15;
const FIX_CHECK_FRACTION = 0.1;
/** Output plus reasoning tokens per dispatch when no live measurement is given (labelled DEFAULT in the output). */
const DEFAULT_OUT: Record<string, { full: number; light: number }> = {
specialist: { full: 6000, light: 3000 }, 'red-team': { full: 8000, light: 4000 }, 'adversarial-native': { full: 8000, light: 4000 },
'outside-voice': { full: 6000, light: 6000 }, 'fix-check': { full: 4000, light: 2000 }, audit: { full: 5000, light: 2500 }, 'autoplan-voice': { full: 9000, light: 4500 },
};
export interface Fixture { name: string; commit: string; paths: string[]; deleted: string[]; stamp_only?: string[]; scope: Record<string, string>; gate_tagged: number | null; diff_lines: number; diff_bytes: number; numbers: NumbersResult }
/** clean: pass 1 finds nothing; nits: pass 1 fixes below-floor findings; blocker: pass 1 fixes a blocker; adversarial-fix: Step 11 finds a should that is fixed; fix-regression: a pass-1 fix introduces a blocker the next step finds. */
type Scenario = 'clean' | 'nits' | 'blocker' | 'adversarial-fix' | 'fix-regression';
const SCENARIOS: Scenario[] = ['clean', 'nits', 'blocker', 'adversarial-fix', 'fix-regression'];
interface Call { role: string; effort: 'full' | 'light' | 'host'; diffBytes: number }
const fileBytes = (rel: string) => { try { return fs.statSync(path.join(ROOT, rel)).size; } catch { return 0; } };
function promptBytes(role: string, name: string): number {
if (role === 'specialist') return fileBytes(`review/specialists/${name}.md`) + fileBytes('review/checklist.md') / 4;
if (role === 'red-team') return fileBytes('review/specialists/red-team.md');
if (role === 'adversarial-native' || role === 'outside-voice') return fileBytes('ship/sections/adversarial.md') / 2;
if (role === 'fix-check') return fileBytes('review/checklist.md') / 2;
return 20_000;
}
function legacyShip(f: Fixture, sc: Scenario): Call[] {
const calls: Call[] = [];
const passes = sc === 'clean' ? 1 : sc === 'fix-regression' ? 3 : 2;
const step11Runs = sc === 'adversarial-fix' ? 2 : 1;
const legacySpecialists = specialistsFor('code', { ...base(f), history: { fullPasses: 0, last: 'none' } });
for (let p = 1; p <= passes; p++) {
const diffBytes = p === 1 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION;
for (const d of legacySpecialists) calls.push({ role: 'specialist', effort: 'full', diffBytes });
const critical = (sc === 'blocker' && p === 1) || (sc === 'fix-regression' && p === 2);
if (f.diff_lines > 200 || critical) calls.push({ role: 'red-team', effort: 'full', diffBytes });
}
for (let s = 0; s < step11Runs; s++) calls.push({ role: 'adversarial-native', effort: 'full', diffBytes: f.diff_bytes }, { role: 'outside-voice', effort: 'host', diffBytes: f.diff_bytes });
for (const a of FIXED_AUDITS) calls.push({ role: 'audit', effort: 'full', diffBytes: f.diff_bytes / 2 });
return calls;
}
function base(f: Fixture) {
return { paths: f.paths, deleted: f.deleted, stampOnly: f.stamp_only ?? [], scope: f.scope, gateTagged: f.gate_tagged, diffLines: f.diff_lines, numbers: f.numbers };
}
/** The new policy, step by step through lib/review-depth's own transition table. */
function newShip(f: Fixture, sc: Scenario): { calls: Call[]; ending: string } {
const calls: Call[] = [];
const outcomes: Record<Scenario, Array<Partial<History>>> = {
clean: [{}],
nits: [{ fixedBelow: true }, {}],
blocker: [{ fixedBlocking: true }, {}],
'adversarial-fix': [{ fixedBelow: true }, {}],
'fix-regression': [{ fixedBelow: true }, { blockingOpen: true }],
};
let h: History = { fullPasses: 0, last: 'none' };
let ending = 'done';
for (let i = 0; i < 6; i++) {
const plan = planReview({ ...base(f), history: h, criticalFound: sc === 'blocker' && i === 0 });
if (plan.next.step === 'done' || plan.next.step === 'stop') { ending = plan.next.step; break; }
const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION;
for (const d of plan.dispatches) calls.push({ role: d.role, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes });
const o = outcomes[sc][Math.min(i, outcomes[sc].length - 1)] ?? {};
h = { fullPasses: h.fullPasses + (plan.next.step === 'full' ? 1 : 0), last: plan.next.step, ...o };
}
for (const a of FIXED_AUDITS) calls.push({ role: 'audit', effort: 'light', diffBytes: f.diff_bytes / 2 });
return { calls, ending };
}
function tokens(calls: Call[], out: typeof DEFAULT_OUT): number {
return Math.round(calls.reduce((n, c) => {
const o = out[c.role] ?? out.audit!;
return n + (promptBytes(c.role, '') + c.diffBytes) / BYTES_PER_TOKEN + (c.effort === 'light' ? o.light : o.full);
}, 0));
}
/** /autoplan: phases x voices x rounds. Legacy reopens every touched phase on any accepted change, up to 3 rounds. */
function autoplanRuns(docsOnly: boolean): Array<{ scenario: string; legacy: number; next: number }> {
const phases = ['ceo', 'eng'];
const legacyRounds = { 'no change': 1, 'medium-only change': 2, 'high change': 2, 'high twice': 3 } as Record<string, number>;
const newRounds = { 'no change': 1, 'medium-only change': 1, 'high change': 2, 'high twice': 2 } as Record<string, number>;
return Object.keys(legacyRounds).map(s => ({
scenario: s,
legacy: phases.length * 2 * legacyRounds[s]!,
next: (docsOnly ? 3 : phases.length * 2) * newRounds[s]!,
}));
}
function capture(sha: string, name: string, out: string): number {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-compare-'));
const git = (args: string[], cwd = ROOT) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
try {
if (git(['worktree', 'add', '-q', '--detach', dir, sha]).status !== 0) throw new Error(`cannot check out ${sha}`);
git(['branch', '-f', 'gsta131-capture-base', `${sha}^`], dir);
const r = spawnSync(path.join(ROOT, 'bin', 'gstack-review-plan'), ['--base', 'gsta131-capture-base', '--json'], { cwd: dir, encoding: 'utf8', timeout: 300_000, maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`gstack-review-plan failed: ${r.stderr}`);
const plan = JSON.parse(r.stdout);
const diffBytes = Buffer.byteLength(git(['diff', `${sha}^`, sha], dir).stdout ?? '');
const fixture: Fixture = { name, commit: sha, paths: plan.paths, deleted: plan.deleted, stamp_only: plan.stamp_only, scope: plan.scope, gate_tagged: plan.gate_tagged, diff_lines: plan.diff_lines, diff_bytes: diffBytes, numbers: plan.numbers };
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, JSON.stringify(fixture, null, 2) + '\n');
process.stdout.write(`CAPTURED: ${name} ${sha} class=${plan.class} paths=${plan.paths.length} lines=${plan.diff_lines} -> ${out}\n`);
return 0;
} finally {
git(['worktree', 'remove', '--force', dir]);
git(['branch', '-D', 'gsta131-capture-base']);
}
}
export function compare(fixtures: Fixture[], out: typeof DEFAULT_OUT) {
const rows: Array<Record<string, string | number>> = [];
for (const f of fixtures) {
const cls: DiffClass = planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }).class;
for (const sc of SCENARIOS) {
const legacy = legacyShip(f, sc);
const next = newShip(f, sc);
const lt = tokens(legacy, out); const nt = tokens(next.calls, out);
rows.push({ fixture: f.name, class: cls, scenario: sc, legacy_dispatches: legacy.length, new_dispatches: next.calls.length, legacy_tokens: lt, new_tokens: nt, ratio: Math.round((nt / lt) * 100) / 100, ending: next.ending });
}
}
return rows;
}
function main(argv: string[]): number {
const flag = (n: string) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
const cap = flag('--capture');
if (cap) return capture(cap, flag('--name') ?? cap, flag('--out') ?? path.join(ROOT, 'test/fixtures/review-depth', `${flag('--name') ?? cap}.json`));
const files = argv.flatMap((a, i) => (a === '--fixture' ? [argv[i + 1]!] : []));
const fixtureFiles = files.length ? files : fs.readdirSync(path.join(ROOT, 'test/fixtures/review-depth')).filter(f => f.endsWith('.json')).map(f => path.join(ROOT, 'test/fixtures/review-depth', f));
const fixtures = fixtureFiles.map(f => JSON.parse(fs.readFileSync(f, 'utf8')) as Fixture);
const tokFile = flag('--tokens');
const out = tokFile ? { ...DEFAULT_OUT, ...JSON.parse(fs.readFileSync(tokFile, 'utf8')) } : DEFAULT_OUT;
const rows = compare(fixtures, out);
const ap = fixtures.map(f => ({ fixture: f.name, runs: autoplanRuns(planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }).class === 'docs') }));
if (argv.includes('--json')) { process.stdout.write(JSON.stringify({ tokens_source: tokFile ?? 'DEFAULT', ship: rows, autoplan: ap }) + '\n'); return 0; }
const lines = [`TOKENS: ${tokFile ? `measured (${tokFile})` : 'DEFAULT estimates (pass --tokens for measured output)'}`, '', '| fixture | class | scenario | /ship dispatches legacy → new | est. tokens legacy → new | ratio | ending |', '|---|---|---|---|---|---|---|'];
for (const r of rows) lines.push(`| ${r.fixture} | ${r.class} | ${r.scenario} | ${r.legacy_dispatches} → ${r.new_dispatches} | ${r.legacy_tokens} → ${r.new_tokens} | ${r.ratio} | ${r.ending} |`);
lines.push('', '| fixture | /autoplan scenario | voice dispatches legacy → new |', '|---|---|---|');
for (const a of ap) for (const r of a.runs) lines.push(`| ${a.fixture} | ${r.scenario} | ${r.legacy} → ${r.next} |`);
const ratios = rows.map(r => Number(r.ratio)).sort((a, b) => a - b);
lines.push('', `SHIP_TOKEN_RATIO: median=${ratios[Math.floor(ratios.length / 2)]} min=${ratios[0]} max=${ratios[ratios.length - 1]} (n=${ratios.length})`);
process.stdout.write(lines.join('\n') + '\n');
return 0;
}
if (import.meta.main) process.exit(main(process.argv.slice(2)));
+3 -3
View File
@@ -19,8 +19,8 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
/** A gated run with a disagreement in ceo (taste item d1) and the fixture's user challenges (uc1 ceo, uc2 eng). */
function gatedRun(name: string): string {
const out = startSmallRun(path.join(TMP, name));
function gatedRun(name: string, extra: string[] = []): string {
const out = startSmallRun(path.join(TMP, name), extra);
const disagree = JSON.stringify([
{ id: 'O1', severity: 'Low', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'minor' },
{ id: 'O2', severity: 'High', title: 'Gate reuse on tree equality alone is unsound', file: 'lib/tree-receipt.ts', line: 3 },
@@ -166,7 +166,7 @@ describe('answer', () => {
}, 30_000);
test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
const out = gatedRun('eng-only');
const out = gatedRun('eng-only', ['--max-rounds', '3']);
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
const runId = readRun(out).run;
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
+6 -6
View File
@@ -43,15 +43,15 @@ describe('spend: the default host estimate binds and budget_exhausted is a resum
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--spend-cap', '12']);
expect(first.status, first.stderr).toBe(0);
expect(first.stdout).toMatch(/^ESTIMATE: 5\.00 per voice reserved for each host-subagent attempt/m);
expect(first.stdout).toMatch(/^ROUNDS: ceo=1\/3 eng=1\/3 max_rounds=3 host_spend=unknown$/m);
expect(first.stdout).toMatch(/^ROUNDS: ceo=1\/2 eng=1\/2 max_rounds=2 host_spend=unknown$/m);
expect(spend(out).reserved.map((x: any) => x.estimate_usd)).toEqual([5, 5]);
expect(readRunJson(out).max_rounds).toBe(3);
expect(readRunJson(out).max_rounds).toBe(2);
phaseVia(r, out, 'ceo', NONE);
expect(unknownUsd(out)).toBe(10);
const stopped = r.ap(['next', '--out', out]);
expect(stopped.status).toBe(3);
expect(stopped.stderr).toMatch(/\(SPEND_CAP_EXCEEDED\)$/m);
expect(stopped.stdout).toMatch(/^STOP: reason=budget_exhausted phase=eng cap=12\.00 spent=0\.00 reserved=0\.00 unknown=2\(10\.00\) next_estimate=10\.00 rounds=ceo:1\/3,eng:1\/3$/m);
expect(stopped.stdout).toMatch(/^STOP: reason=budget_exhausted phase=eng cap=12\.00 spent=0\.00 reserved=0\.00 unknown=2\(10\.00\) next_estimate=10\.00 rounds=ceo:1\/2,eng:1\/2$/m);
expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan close --out ${out} --phase eng --deferred`);
expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan budget --out ${out} --spend-cap <usd>`);
expect(stopped.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=incomplete run=${out}`);
@@ -97,7 +97,7 @@ describe('close --deferred', () => {
expect(noBy.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)$/m);
const close = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner stop', '--by', 'human']);
expect(close.status, close.stderr).toBe(0);
expect(close.stdout).toMatch(/^PHASE_DEFERRED: ceo round=1\/3 by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 reason="owner stop"$/m);
expect(close.stdout).toMatch(/^PHASE_DEFERRED: ceo round=1\/2 by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 reason="owner stop"$/m);
expect(close.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running');
const run = readRunJson(s.out);
expect(run.phases.ceo.outside.status).toBe('skipped');
@@ -126,9 +126,9 @@ describe('close --deferred', () => {
expect(v.status, v.stdout + v.stderr).toBe(0);
const reply = answer(s.out, 1, 'rec');
expect(reply.status, reply.stderr).toBe(0);
expect(reply.stdout).toMatch(/^ROUND_GRANTED: ceo cap=4 \(uc\d+b by human\)$/m);
expect(reply.stdout).toMatch(/^ROUND_GRANTED: ceo cap=3 \(uc\d+b by human\)$/m);
expect(reply.stdout).toMatch(/^REOPENED: ceo,eng/m);
expect(reply.stdout).toMatch(/^ROUND_GRANTED: eng cap=4 \(reruns after ceo; uc\d+b by human\)$/m);
expect(reply.stdout).toMatch(/^ROUND_GRANTED: eng cap=3 \(reruns after ceo; uc\d+b by human\)$/m);
expect(readRunJson(s.out).round_grants).toEqual({ ceo: 1, eng: 1 });
});
});
+2 -2
View File
@@ -49,7 +49,7 @@ const ok = (res: { status: number | null; stderr: string; stdout: string }) => {
describe('unattended runs on the Capy host', () => {
test('a three-revision run driven only through the CLI validates with unique ids and records gstack_version', () => {
const s = start();
const s = start(['--max-rounds', '3']);
expect(readRunJson(s.out).gstack_version).toBe(VERSION);
for (const rev of [1, 2]) {
phaseVia(r, s.out, 'ceo');
@@ -134,7 +134,7 @@ describe('unattended runs on the Capy host', () => {
describe('conformance: one run through every mechanism', () => {
test('canonical and attested binds, an unavailable voice, partial then full replies, PLAN_AMEND_PENDING, revise --incorporates, an interruption, three gate revisions', () => {
const c: CapyRunner = r;
const s = start();
const s = start(['--max-rounds', '3']);
const run = readRunJson(s.out).run;
// ceo: native canonical (fixture), outside attested record
ok(c.review(path.join(s.out, 'ceo-native-prompt.md')));
+3 -3
View File
@@ -31,8 +31,8 @@ function start(args: string[] = [], text = SMALL_PLAN): { out: string; dir: stri
return { out, dir, plan };
}
const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; };
function gated(): { out: string; dir: string; plan: string } {
const s = start();
function gated(args: string[] = []): { out: string; dir: string; plan: string } {
const s = start(args);
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
expect(phaseVia(r, s.out, 'eng', { native: [], outside: [] }).outside.stdout).toContain('status=gate_pending');
return s;
@@ -97,7 +97,7 @@ describe('revise', () => {
});
test('a second revise never overwrites the first archive', () => {
const s = gated();
const s = gated(['--max-rounds', '3']);
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
const first = fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md'));
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
+1 -1
View File
@@ -94,7 +94,7 @@ describe('per-axis provenance, --no-dx and the plan marker', () => {
const out = path.join(host.base, 'run-pin');
const r = next(['--out', out, '--plan', file, '--no-ui', '--developer-tool']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) source=ui:flag,dx:flag$/m);
expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) docs_only=no \(detected\) source=ui:flag,dx:flag$/m);
expect(r.stdout).toContain(`SCOPE_PIN: add <!-- gstack-autoplan-scope: --no-ui --developer-tool --> as the first line of ${file} to keep these flags`);
expect(fs.readFileSync(file).equals(before)).toBe(true);
expect(readRun(out).scope).toMatchObject({ cli_flags: ['--no-ui', '--developer-tool'], marker: null, sources: { ui: 'flag', dx: 'flag' } });
+219
View File
@@ -0,0 +1,219 @@
/**
* GSTA-131 token efficiency in the unattended /autoplan runner, on the
* simulated Capy host: new unattended runs default to two review rounds (a
* resumed run keeps what it persisted); `revise --incorporates` applies an
* edit without re-review when every incorporated decision resolves below High
* and nothing new comes into scope, and reopens on anything else; the gate at
* the round cap prints ESCALATE while High findings stay open; a docs-only
* plan runs CEO (native voice only) and Eng; export counts every dispatched
* attempt and a relative input-token estimate. Every spawn has a timeout.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { DEFAULT_MAX_ROUNDS } from '../lib/autoplan-budget';
import { detectDocsOnly, docsClassPath } from '../lib/autoplan-scope';
import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
const r = capyRunner();
afterAll(() => r.cleanup());
let n = 0;
const NONE = { native: [], outside: [] };
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 30 s, jitter drawn from a seeded source');
const DX_V2 = SMALL_PLAN.replace('Tests cover', 'Add a CLI flag --max-retries and an error message for a bad value. Tests cover');
const DOCS_PLAN = '# Retry docs\n\n## Implementation plan\n\nRewrite README.md and docs/howto-retry.md so they explain the retry backoff, and add a CHANGELOG.md entry.\n\n## Review record\n';
const journal = (out: string) => readJsonl(path.join(out, 'attempts.jsonl'));
const voices = (stdout: string) => stdout.split('\n').filter(l => l.startsWith('ATTEMPT: ')).map(l => /voice=(\w+)/.exec(l)![1]);
function start(args: string[] = [], text = SMALL_PLAN) {
const dir = path.join(r.host.base, `tok${++n}`);
const plan = writePlan(dir, text);
const out = path.join(dir, 'run');
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]);
if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`);
return { out, dir, plan, first };
}
const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; };
const disagreement = (native: string, outside: string) => ({
native: [{ id: 'F1', severity: native, title: 'Jitter source is unstated', file: 'lib/worker.ts', line: 19, fix: 'name the source' }],
outside: [{ id: 'O1', severity: outside, title: 'Jitter source is unstated', file: 'lib/worker.ts', line: 19, fix: 'name the source' }],
});
/** A run gated on one ceo disagreement d1, answered with its plan-changing option: amend_pending on d1. */
function amended(native: string, outside: string, args: string[] = []) {
const s = start(args);
phaseVia(r, s.out, 'ceo', disagreement(native, outside));
expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
const res = r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except d1b']);
expect(res.status, res.stderr).toBe(0);
expect(readRunJson(s.out).status).toBe('amend_pending');
return { ...s, run: readRunJson(s.out).run as string };
}
function bindNative(out: string) {
const run = readRunJson(out);
expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: '[]' }).status).toBe(0);
return r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases.ceo.native.attempt]);
}
describe('round budget', () => {
test('a new unattended run defaults to two rounds per phase', () => {
expect(DEFAULT_MAX_ROUNDS).toBe(2);
const s = start();
expect(s.first.stdout).toMatch(/^ROUNDS: ceo=1\/2 eng=1\/2 max_rounds=2 host_spend=unknown$/m);
expect(readRunJson(s.out).max_rounds).toBe(2);
});
test('a resumed run keeps the max_rounds it persisted', () => {
const s = start();
fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify({ ...readRunJson(s.out), max_rounds: 3 }, null, 2) + '\n');
expect(r.ap(['resume', '--out', s.out]).status).toBe(0);
expect(readRunJson(s.out).max_rounds).toBe(3);
expect(r.ap(['status', '--out', s.out]).stdout).toMatch(/^ROUNDS: ceo=1\/3 eng=1\/3 max_rounds=3 /m);
});
});
describe('revise by severity', () => {
test('below-High decisions apply without re-review: no phase reopens, snapshots rebind, the run completes', () => {
const g = amended('Medium', 'Informational');
const before = readRunJson(g.out);
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir), '--incorporates', 'd1']);
expect(rev.status, rev.stderr).toBe(0);
expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=none$/m);
expect(rev.stdout).toContain(`APPLIED: ${g.run}-d1 without re-review (below High)`);
expect(rev.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete');
const run = readRunJson(g.out);
const row = journal(g.out).find(e => e.event === 'applied_without_rereview');
expect(row).toMatchObject({ decisions: [`${g.run}-d1`], severities: { [`${g.run}-d1`]: 'Medium' }, phases: ['ceo', 'eng'], old_plan_sha256: before.phases.ceo.snapshot });
for (const p of ['ceo', 'eng']) {
expect(run.phases[p].snapshot).toBe(row.new_plan_sha256);
expect(run.phases[p].rebound).toEqual([expect.objectContaining({ from: before.phases[p].snapshot, to: row.new_plan_sha256, decisions: [`${g.run}-d1`] })]);
expect(run.phases[p].closed_at).toBe(before.phases[p].closed_at);
}
expect(run.attempt_counter).toBe(before.attempt_counter);
expect(journal(g.out).filter(e => e.event === 'reopened')).toEqual([]);
expect(run.amendments[0]).toMatchObject({ status: 'incorporated' });
const v = r.validate(g.out);
expect(v.status, v.stdout + v.stderr).toBe(0);
});
test('a High decision reopens as before', () => {
const g = amended('High', 'Low');
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir), '--incorporates', 'd1']);
expect(rev.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m);
expect(rev.stdout).not.toContain('APPLIED:');
expect(journal(g.out).some(e => e.event === 'applied_without_rereview')).toBe(false);
});
test('a decision of unknown severity reopens', () => {
const g = amended('Medium', 'Informational');
const file = path.join(g.out, 'decisions.jsonl');
fs.writeFileSync(file, readJsonl(file).map(d => JSON.stringify(d.label === 'D1' ? { ...d, origin: undefined } : d)).join('\n') + '\n');
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir), '--incorporates', 'd1']);
expect(rev.status, rev.stderr).toBe(0);
expect(rev.stdout).toMatch(/reopened=ceo,eng$/m);
expect(rev.stdout).not.toContain('APPLIED:');
});
test('a below-High edit that brings a phase into scope reopens and adds it', () => {
const g = amended('Medium', 'Informational');
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir, DX_V2), '--incorporates', 'd1']);
expect(rev.status, rev.stderr).toBe(0);
expect(rev.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng added=dx$/m);
expect(rev.stdout).not.toContain('APPLIED:');
});
test('at the round cap a below-High edit applies without a capped delta; a High one keeps the unreviewed-delta gate item', () => {
const low = amended('Medium', 'Informational', ['--max-rounds', '1']);
const quiet = r.ap(['revise', '--out', low.out, '--plan', v2File(low.dir), '--incorporates', 'd1']);
expect(quiet.stdout).toContain(`APPLIED: ${low.run}-d1 without re-review (below High)`);
expect(readRunJson(low.out).capped_deltas).toBeUndefined();
const high = amended('High', 'Low', ['--max-rounds', '1']);
const capped = r.ap(['revise', '--out', high.out, '--plan', v2File(high.dir), '--incorporates', 'd1']);
expect(capped.stdout).toMatch(/^REVISED: plan=\S+ reopened=none capped=ceo,eng$/m);
expect(capped.stdout).not.toContain('APPLIED:');
expect(readRunJson(high.out).capped_deltas.map((d: any) => d.phase)).toEqual(['ceo', 'eng']);
});
});
describe('ESCALATE at the round cap', () => {
const HIGH = { native: [{ severity: 'High', title: 'Retries can double-charge', fix: 'idempotency key' }], outside: [] };
test('the gate at the cap with a High finding open names the decision', () => {
const s = start(['--max-rounds', '1']);
phaseVia(r, s.out, 'ceo', HIGH);
const eng = phaseVia(r, s.out, 'eng', NONE);
expect(eng.outside.stdout).toMatch(/^GATE: gate_rev=1 /m);
expect(eng.outside.stdout.split('\n').filter(l => l.startsWith('ESCALATE:'))).toEqual(['ESCALATE: evidence sufficient, decision needed (ceo: 1 High open)']);
});
test('control: below the cap, or at it with nothing High open, no ESCALATE', () => {
const s = start();
phaseVia(r, s.out, 'ceo', HIGH);
expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).not.toContain('ESCALATE:');
const m = start(['--max-rounds', '1']);
phaseVia(r, m.out, 'ceo', { native: [{ severity: 'Medium', title: 'Jitter source is unstated' }], outside: [] });
const gate = phaseVia(r, m.out, 'eng', NONE).outside.stdout;
expect(gate).toMatch(/^GATE: /m);
expect(gate).not.toContain('ESCALATE:');
});
});
describe('docs-only', () => {
test('docs-class paths', () => {
for (const p of ['README.md', 'CHANGELOG.md', 'TODOS.md', 'docs/howto-retry.md', 'docs/designs/x/PLAN.md', 'notes.md', './README.md']) expect(docsClassPath(p, ROOT), p).toBe(true);
for (const p of ['docs/review-policy.md', 'autoplan/SKILL.md.tmpl', 'autoplan/SKILL.md', 'review/sections/army.md', 'scripts/resolvers/autoplan-driver.ts', 'autoplan/DRIVER.md',
'captain/templates/brief.md', 'autoplan/notes.md', 'AGENTS.md', 'CLAUDE.md', 'lib/worker.ts', 'bin/gstack-autoplan']) expect(docsClassPath(p, ROOT), p).toBe(false);
expect(detectDocsOnly('Rewrite README.md and docs/howto.md.', ROOT)).toMatchObject({ docsOnly: true, code: [] });
expect(detectDocsOnly('Rewrite README.md and docs/review-policy.md.', ROOT)).toMatchObject({ docsOnly: false, code: ['docs/review-policy.md'] });
expect(detectDocsOnly('Make the retry docs clearer.', ROOT).docsOnly).toBe(false);
});
test('detected: CEO dispatches the native voice only, Eng both; SCOPE reports it; DISPATCHES counts what ran', () => {
const s = start([], DOCS_PLAN);
expect(s.first.stdout).toMatch(/^SCOPE: ui=no .* docs_only=yes \(detected\) source=ui:flag,dx:detected$/m);
expect(s.first.stdout).toMatch(/^PHASE: ceo snapshot=[0-9a-f]{64} order=ceo,eng$/m);
expect(s.first.stdout).toContain('OUTSIDE: ceo not_run reason=docs_only (native voice only)');
expect(voices(s.first.stdout)).toEqual(['native']);
expect(readRunJson(s.out).phases.ceo.outside).toMatchObject({ status: 'disabled', outcome: 'not_run', reason: 'docs_only' });
expect(bindNative(s.out).stdout).toMatch(/^PHASE_CLOSED: ceo /m);
const eng = phaseVia(r, s.out, 'eng', NONE);
expect(voices(eng.next.stdout)).toEqual(['native', 'outside']);
expect(eng.outside.stdout).toContain('status=gate_pending');
const exp = r.ap(['export', '--out', s.out]);
expect(exp.status, exp.stderr).toBe(0);
const run = readRunJson(s.out);
const bytes = journal(s.out).filter(e => e.event === 'dispatched').reduce((t, e) => t + e.prompt_bytes, 0);
expect(run.dispatches).toEqual({ total: 3, by_phase: { ceo: { native: 1 }, eng: { native: 1, outside: 1 } } });
expect(run.est_input_tokens).toBe(Math.round(bytes / 4));
expect(exp.stdout).toContain(`DISPATCHES: 3 est_input_tokens=${run.est_input_tokens} (relative estimate)`);
const v = r.validate(s.out);
expect(v.status, v.stdout + v.stderr).toBe(0);
});
test('--docs-only on the first next forces CEO + Eng on a code plan; a phase-enabling flag outranks detection', () => {
const flagged = start(['--docs-only', '--developer-tool']);
expect(flagged.first.stdout).toMatch(/ docs_only=yes \(flag\) source=/m);
expect(readRunJson(flagged.out).phase_order).toEqual(['ceo', 'eng']);
const code = start();
expect(code.first.stdout).toMatch(/ docs_only=no \(detected\) source=/m);
expect(voices(code.first.stdout)).toEqual(['native', 'outside']);
const dx = start(['--developer-tool'], DOCS_PLAN);
expect(dx.first.stdout).toMatch(/ docs_only=no \(detected\) source=/m);
expect(readRunJson(dx.out).phase_order).toEqual(['ceo', 'dx', 'eng']);
});
});
describe('dispatch counts', () => {
test('failed attempts count; a run.json without the fields still validates', () => {
const s = start();
expect(bindNative(s.out).status).toBe(0);
expect(r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--failed', 'runner crashed']).stdout).toMatch(/^PHASE_CLOSED: ceo /m);
phaseVia(r, s.out, 'eng', NONE);
const exp = r.ap(['export', '--out', s.out, '--json']);
expect(JSON.parse(exp.stdout)).toMatchObject({ dispatches: { total: 4, by_phase: { ceo: { native: 1, outside: 1 }, eng: { native: 1, outside: 1 } } } });
const run = readRunJson(s.out);
expect(run.est_input_tokens).toBeGreaterThan(0);
delete run.dispatches; delete run.est_input_tokens;
fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
const v = r.validate(s.out);
expect(v.status, v.stdout + v.stderr).toBe(0);
expect(r.ap(['status', '--out', s.out]).status).toBe(0);
});
});
+67
View File
@@ -0,0 +1,67 @@
{
"name": "code",
"commit": "392f50d",
"paths": [
"CHANGELOG.md",
"VERSION",
"agents-digest/gstack-AGENTS.md",
"package.json",
"plan-ceo-review/sections/review-sections.md",
"plan-devex-review/sections/review-sections.md",
"plan-eng-review/sections/review-sections.md",
"review/SKILL.md",
"review/SKILL.md.tmpl",
"review/sections/manifest.json",
"review/sections/plan-completion.md",
"review/sections/review-army.md",
"scripts/resolvers/outside-voice-steps.ts",
"scripts/resolvers/plan-gates.ts",
"scripts/resolvers/review-army.ts",
"scripts/resolvers/review-scope.ts",
"scripts/resolvers/sideways.ts",
"ship/sections/review-army.md",
"test/docsync-command-grammar.test.ts",
"test/fixtures/design-floor-focus-cover-all.json",
"test/fixtures/design-floor-focus-picker.ts",
"test/fixtures/docsync-branch-listing-public.json",
"test/fixtures/docsync-two-attempts-public.json",
"test/fixtures/ship-local-hook-train-replies.json",
"test/fixtures/skill-md-size-allowlist.json",
"test/helpers/carve-guards.ts",
"test/helpers/docsync-contract.ts",
"test/helpers/docsync-interface.ts",
"test/helpers/docsync-observer.ts",
"test/helpers/free-fixtures-data.ts",
"test/helpers/ship-hook-actor.ts",
"test/outside-voice-preflight.test.ts",
"test/plan-design-floor-fixture.test.ts",
"test/plan-review-cases.test.ts",
"test/review-workflow-clarity.test.ts",
"test/ship-document-release-dispatch.test.ts",
"test/ship-hook-actor.test.ts",
"test/skill-e2e-ship-docsync.test.ts"
],
"deleted": [],
"stamp_only": [
"package.json"
],
"scope": {
"SCOPE_FRONTEND": "false",
"SCOPE_BACKEND": "true",
"SCOPE_PROMPTS": "false",
"SCOPE_TESTS": "true",
"SCOPE_DOCS": "true",
"SCOPE_CONFIG": "true",
"SCOPE_MIGRATIONS": "false",
"SCOPE_API": "false",
"SCOPE_AUTH": "false"
},
"gate_tagged": 0,
"diff_lines": 1491,
"diff_bytes": 205941,
"numbers": {
"publishes": false,
"claims": 0,
"fingerprint": "none"
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "docs-only",
"commit": "ab9a854",
"paths": [
"CHANGELOG.md",
"README.md",
"VERSION",
"agents-digest/gstack-AGENTS.md",
"docs/ADDING_A_HOST.md",
"docs/domain-skills.md",
"docs/gbrain-quick-tour.md",
"docs/hosts.md",
"docs/install.md",
"docs/privacy.md",
"docs/qa-without-a-webpage.md",
"docs/standalone-binaries.md",
"docs/troubleshooting.md",
"docs/uninstall.md",
"docs/voice-input.md",
"docs/why-gstack.md",
"package.json"
],
"deleted": [],
"stamp_only": [
"package.json"
],
"scope": {
"SCOPE_FRONTEND": "false",
"SCOPE_BACKEND": "false",
"SCOPE_PROMPTS": "false",
"SCOPE_TESTS": "false",
"SCOPE_DOCS": "true",
"SCOPE_CONFIG": "true",
"SCOPE_MIGRATIONS": "false",
"SCOPE_API": "false",
"SCOPE_AUTH": "false"
},
"gate_tagged": 0,
"diff_lines": 1329,
"diff_bytes": 153962,
"numbers": {
"publishes": true,
"claims": 34,
"fingerprint": "36e9f8f080a2cdcd"
}
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, test, expect } from 'bun:test';
import { classifyDiff, classifyPath, nextStep, planReview, scanNumbers, FULL_PASS_CAP, type PlanInput } from '../lib/review-depth';
const none = scanNumbers({});
const base = (o: Partial<PlanInput>): PlanInput => ({ paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, history: { fullPasses: 0, last: 'none' }, numbers: none, ...o });
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}:${d.effort}`);
describe('classifyPath: agent-read markdown is code', () => {
test.each([
['README.md', 'docs'], ['CHANGELOG.md', 'docs'], ['docs/howto-x.md', 'docs'], ['TODOS.md', 'docs'],
['ship/SKILL.md.tmpl', 'code'], ['review/SKILL.md', 'code'], ['ship/sections/review-army.md', 'code'], ['scripts/resolvers/review-army.ts', 'code'],
['docs/review-policy.md', 'code'], ['autoplan/DRIVER.md.tmpl', 'code'], ['captain/templates/brief.md', 'code'], ['AGENTS.md', 'code'], ['review/specialists/security.md', 'code'],
['test/review-depth.test.ts', 'tests'], ['src/a.test.ts', 'tests'], ['pkg/x_test.go', 'tests'], ['test/fixtures/a.json', 'tests'],
['lib/review-depth.ts', 'code'], ['package.json', 'code'],
])('%s -> %s', (p, c) => expect(classifyPath(p)).toBe(c as any));
});
describe('classifyDiff', () => {
test('docs only stays docs', () => expect(classifyDiff({ paths: ['README.md', 'docs/a.md'], gateTagged: null }).class).toBe('docs'));
test('a skill-template-only diff is code, not docs', () => expect(classifyDiff({ paths: ['ship/SKILL.md.tmpl'], gateTagged: 0 }).class).toBe('code'));
test('mixed diff takes the most restrictive class', () => {
expect(classifyDiff({ paths: ['README.md', 'test/a.test.ts'], gateTagged: 0 }).class).toBe('tests');
expect(classifyDiff({ paths: ['README.md', 'lib/a.ts'], gateTagged: 0 }).class).toBe('code');
});
test('a scope error fails closed to code', () => expect(classifyDiff({ paths: ['README.md'], scope: { SCOPE_ERROR: 'unmatched' }, gateTagged: 0 }).class).toBe('code'));
test('code with the gate scan unavailable fails closed to gate', () => expect(classifyDiff({ paths: ['lib/a.ts'], gateTagged: null }).class).toBe('gate'));
test.each([
[{ scope: { SCOPE_AUTH: 'true' } }], [{ scope: { SCOPE_MIGRATIONS: 'true' } }], [{ gateTagged: 2 }],
[{ paths: ['lib/a.ts', '.github/workflows/ci.yml'] }], [{ paths: ['lib/a.ts', 'lib/credentials.ts'] }], [{ deleted: ['lib/old.ts'] }],
])('gate signal %j raises code to gate', (o: any) => expect(classifyDiff({ paths: ['lib/a.ts'], gateTagged: 0, ...o }).class).toBe('gate'));
test('docs never become gate from code-only signals', () => expect(classifyDiff({ paths: ['README.md'], gateTagged: null, deleted: ['docs/old.md'] }).class).toBe('docs'));
});
describe('nextStep: the transition table', () => {
test('pass 1 is full', () => expect(nextStep({ fullPasses: 0, last: 'none' }).step).toBe('full'));
test('no edits is done', () => expect(nextStep({ fullPasses: 1, last: 'full' }).step).toBe('done'));
test('a fixed blocker earns full pass 2', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBlocking: true }).step).toBe('full'));
test('below-floor fixes earn one light fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true })).toMatchObject({ step: 'fix-check', effort: 'light' }));
test('a fix touching gate code earns a full pass instead of a fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, touchedGate: true }).step).toBe('full'));
test('at the full-pass cap a fixed blocker gets a fix-check', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true }).step).toBe('fix-check'));
test('at the cap a gate-touching fix gets a full-effort fix-check', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true })).toMatchObject({ step: 'fix-check', effort: 'full' }));
test('a blocker open at the cap stops', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true }).step).toBe('stop'));
test('a blocker open with no fix stops', () => expect(nextStep({ fullPasses: 1, last: 'full', blockingOpen: true }).step).toBe('stop'));
test('a fix-check that finds a blocker stops, never a third full pass', () => {
expect(nextStep({ fullPasses: 1, last: 'fix-check', blockingOpen: true }).step).toBe('stop');
expect(nextStep({ fullPasses: 2, last: 'fix-check', fixedBlocking: true }).step).toBe('stop');
});
test('a clean fix-check is done', () => expect(nextStep({ fullPasses: 1, last: 'fix-check' }).step).toBe('done'));
});
describe('scanNumbers', () => {
test('finds claims and fingerprints them', () => {
const a = scanNumbers({ 'docs/bench.md': ['Median dropped 48% to 1.2s', 'no numbers here'] });
expect(a).toMatchObject({ publishes: true, claims: 1 });
expect(scanNumbers({ 'docs/bench.md': ['Median dropped 47% to 1.2s'] }).fingerprint).not.toBe(a.fingerprint);
});
test('versions, dates, issue and step numbers and plain prose are not claims', () => expect(scanNumbers({ 'CHANGELOG.md': ['## [1.91.283.0] - 2026-10-27', 'Fixes the ship loop (#3516), see Step 9.4 and rule 23.'] }).publishes).toBe(false));
test.each([
['the smallest two-sided p with 16 clusters is 2 x 0.5^6 = 0.031'],
['the miss split is 10/52'], ['p falls to 3.05e-5'], ['median 1.4 passes per PR'], ['Hot-memory hits: 10 of 62'],
])('a bare numeric claim counts: %s', line => expect(scanNumbers({ 'docs/comparing-runs.md': [line] }).publishes).toBe(true));
});
describe('planReview', () => {
test('docs class: core only, no outside voice', () => {
const p = planReview(base({ paths: ['README.md'], scope: {}, gateTagged: null, diffLines: 300 }));
expect(p.class).toBe('docs');
expect(p.dispatches).toEqual([]);
});
test('docs class with numeric claims keeps the outside voice', () => {
const p = planReview(base({ paths: ['docs/bench.md'], scope: {}, numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) }));
expect(roles(p)).toEqual(['outside-voice:outside-voice:host']);
});
test('tests class: testing specialist only', () => {
const p = planReview(base({ paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' } }));
expect(roles(p)).toEqual(['specialist:testing:light']);
});
test('code class pass 1: specialists light, adversarial full, outside voice', () => {
const p = planReview(base({}));
expect(roles(p)).toEqual(['specialist:testing:light', 'specialist:maintainability:light', 'specialist:security:light', 'specialist:performance:light', 'specialist:simplification:light', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
});
test('gate class forces red team and ignores hit-rate gating', () => {
const p = planReview(base({ gateTagged: 1, gated: ['security'] }));
expect(roles(p)).toContain('red-team:red-team:full');
expect(roles(p)).toContain('specialist:security:light');
});
test('code class honours hit-rate gating and small-diff skip', () => {
expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:light');
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist')).toEqual([]);
});
test('pass 2 reruns the army on the range but not the adversarial or outside voice', () => {
const p = planReview(base({ history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
expect(p.next.step).toBe('full');
expect(roles(p).some(r => r.startsWith('outside-voice') || r.startsWith('adversarial'))).toBe(false);
});
test('a fix-check is one light dispatch', () => {
expect(roles(planReview(base({ history: { fullPasses: 1, last: 'full', fixedBelow: true } })))).toEqual(['fix-check:fix-check:light']);
});
test('changed numeric claims rerun only the outside voice', () => {
const numbers = scanNumbers({ 'docs/bench.md': ['3x faster'] });
const p = planReview(base({ numbers, reviewedNumbers: 'old', history: { fullPasses: 1, last: 'full', fixedBelow: true } }));
expect(roles(p)).toEqual(['fix-check:fix-check:light', 'outside-voice:outside-voice (numbers changed):host']);
});
test('tiers off renders every role at the host default', () => {
expect(planReview(base({ tiers: false })).dispatches.every(d => d.effort === 'host')).toBe(true);
});
test('done dispatches nothing', () => expect(planReview(base({ history: { fullPasses: 1, last: 'full' } })).dispatches).toEqual([]));
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, test, expect } from 'bun:test';
import { summarizeReviewLoop, reviewLoopLine, FIX_CHECK_SKILL } from '../lib/review-scope';
import { parseReviewLoop, receiptSummaryLine } from '../lib/ship-receipt';
const since = '2026-10-27T10:00:00Z';
const row = (o: Record<string, unknown>) => ({ via: 'ship', review_scope: 'frozen', findings: [], ...o });
describe('review loop dispatch counters', () => {
test('fix-check rows count as fix_checks, never passes, and dispatch events add up by role', () => {
const rows = [
row({ skill: 'review', timestamp: '2026-10-27T10:01:00Z', findings: [{ fingerprint: 'a:1:x', action: 'auto-fixed' }],
dispatches: [{ role: 'specialist', prompt_bytes: 4000, diff_bytes: 8000 }, { role: 'specialist', prompt_bytes: 4000, diff_bytes: 8000 }, { role: 'red-team', prompt_bytes: 2000 }] }),
row({ skill: 'adversarial-review', timestamp: '2026-10-27T10:05:00Z', dispatches: [{ role: 'adversarial-native', prompt_bytes: 1000 }, { role: 'outside-voice', status: 'unknown' }] }),
row({ skill: FIX_CHECK_SKILL, timestamp: '2026-10-27T10:10:00Z', dispatches: [{ role: 'fix-check', prompt_bytes: 400, diff_bytes: 600 }] }),
row({ skill: 'review', timestamp: '2026-10-27T09:00:00Z', dispatches: [{ role: 'specialist', prompt_bytes: 99999 }] }),
];
const s = summarizeReviewLoop(rows, since);
expect(s).toMatchObject({ passes: 1, fixed: 1, fix_checks: 1, dispatches: { specialist: 2, 'red-team': 1, 'adversarial-native': 1, 'outside-voice': 1, 'fix-check': 1 }, est_input_tokens: Math.round(28000 / 4) });
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=1 scope=frozen fix_checks=1 dispatches=6 est_input_tokens=7000');
});
test('older rows without dispatch events fall back to the specialist stats and print the old line plus a count', () => {
const rows = [row({ skill: 'review', timestamp: '2026-10-27T10:01:00Z', specialists: { testing: { dispatched: true }, security: { dispatched: false, reason: 'scope' }, 'red-team': { dispatched: true } } })];
const s = summarizeReviewLoop(rows, since);
expect(s.dispatches).toEqual({ specialist: 1, 'red-team': 1 });
expect(s.est_input_tokens).toBeUndefined();
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=0 scope=frozen dispatches=2');
});
test('rows with neither keep the historical line byte for byte', () => {
const s = summarizeReviewLoop([row({ skill: 'review', timestamp: '2026-10-27T10:01:00Z' })], since);
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=0 scope=frozen');
});
});
describe('receipt round trip', () => {
test('parseReviewLoop keeps the new counters and drops malformed ones; an old receipt parses unchanged', () => {
expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0 })).toEqual({ passes: 1, blocking_found: 0, deferred: 0 });
expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, est_input_tokens: 7000, dispatches: { specialist: 2, 'Bad Key': 3, 'red-team': -1, 'fix-check': 1 } }))
.toEqual({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, est_input_tokens: 7000, dispatches: { specialist: 2, 'fix-check': 1 } });
});
test('the SHIP_RECEIPT line carries fix_checks and the dispatch total only when recorded', () => {
const r = { schema: 1, skill: 'ship', head: 'a'.repeat(40), base: 'main', pr: 7 } as any;
expect(receiptSummaryLine({ ...r, review_loop: { passes: 1, blocking_found: 0, deferred: 0 } })).toContain('passes=1 blocking_found=0 deferred=0');
expect(receiptSummaryLine({ ...r, review_loop: { passes: 1, blocking_found: 0, deferred: 0 } })).not.toContain('dispatches=');
expect(receiptSummaryLine({ ...r, review_loop: { passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, dispatches: { specialist: 2, 'fix-check': 1 } } })).toContain('deferred=0 fix_checks=1 dispatches=3');
});
});
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, 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';
const BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-review-plan');
const dirs: string[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
function repo(files: Record<string, string>): { dir: string; state: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-plan-'));
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'review-plan-state-'));
dirs.push(dir, state);
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 });
git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false');
fs.writeFileSync(path.join(dir, 'README.md'), '# x\n'); git('add', '.'); git('commit', '-qm', 'base');
git('checkout', '-qb', 'feat');
for (const [p, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), text); }
git('add', '.'); git('commit', '-qm', 'feat');
return { dir, state };
}
const run = (r: { dir: string; state: string }, args: string[]) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', ...args], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
describe('gstack-review-plan', () => {
test('a docs-only diff plans no dispatch and says why', () => {
const r = run(repo({ 'docs/howto.md': '# How to\nStep one.\n' }), []);
expect(r.status).toBe(0);
expect(r.stdout).toContain('REVIEW_PLAN: class=docs pass=1 next=full dispatches=0 numbers=no');
expect(r.stdout).toContain('SKIP: outside-voice (docs class, no numeric claims)');
});
test('a docs diff that publishes numbers keeps the outside voice', () => {
const r = run(repo({ 'docs/bench.md': 'Median latency fell 42% to 180 ms.\n' }), []);
expect(r.stdout).toContain('DISPATCH: outside-voice outside-voice effort=host');
});
test('--json carries the class, the next step and every dispatch with its effort; --no-tiers renders host effort', () => {
const r = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
const plan = JSON.parse(run(r, ['--json']).stdout);
expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 });
expect(plan.dispatches.find((d: any) => d.role === 'specialist').effort).toBe('light');
const host = JSON.parse(run(r, ['--json', '--no-tiers']).stdout);
expect(host.dispatches.every((d: any) => d.effort === 'host')).toBe(true);
});
test('a below-floor fix after pass 1 plans one fix-check', () => {
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'full', '--fixed-below']);
expect(r.stdout).toContain('next=fix-check dispatches=1');
expect(r.stdout).toContain('DISPATCH: fix-check fix-check effort=light');
});
test('usage errors exit 2', () => {
expect(spawnSync(BIN, [], { encoding: 'utf8', timeout: 10_000 }).status).toBe(2);
expect(run(repo({ 'a.md': 'x' }), ['--last', 'later']).status).toBe(2);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* gstack-ship-receipt carry with a pregate: eligibility is not publication. A receipt that recorded a
* pregate rebinds to a new head only with a pregate current for that head (docs/ship-policy.md#receipt-carry).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt } from '../lib/ship-receipt';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const pregate = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']);
const outFile = (repo: FixtureRepo) => path.join(repo.work, '.gstack/tmp/pregate.json');
describe('carry with a recorded pregate', () => {
// Value: protects=a carried receipt never claims the old tree's pregate; fails_when=carry rebinds without a current pregate
// or accepts a stale one; seam=none; no_claim=gh pr edit (--body stands in)
test('refuses without a current --pregate, dry-run still reports eligibility, and a current pregate rebinds', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
expect(pregate(repo).status).toBe(EXIT.ok);
const body = path.join(repo.root, 'body.md');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--pregate', outFile(repo), '--body', body]);
expect(w.status).toBe(EXIT.ok);
const oldPregate = fs.readFileSync(outFile(repo), 'utf8');
repo.git('checkout', '-q', 'main');
write(repo.work, 'docs/README.md', '# docs moved on main\n');
repo.git('commit', '-qam', 'main: docs'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', 'origin/main');
const head = repo.git('rev-parse', 'HEAD');
const bare = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(bare.status).toBe(EXIT.refused);
expect(bare.out).toContain('CARRY: refused (RECEIPT_CARRY_PREGATE_STALE)');
expect(bare.err).toContain(`run gstack-pregate at HEAD ${head.slice(0, 12)}`);
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run']);
expect([dry.status, dry.out]).toEqual([EXIT.ok, expect.stringContaining('CARRY: would carry')]);
const stalePath = path.join(repo.root, 'old-pregate.json');
fs.writeFileSync(stalePath, oldPregate);
const stale = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--pregate', stalePath]);
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toContain('PREGATE_STALE');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
expect(pregate(repo).status).toBe(EXIT.ok);
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--pregate', outFile(repo)]);
expect(carried.status).toBe(EXIT.ok);
expect(carried.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)}`);
const after = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
expect(after.head).toBe(head);
expect(after.pregate).toContain(`current for ${head.slice(0, 12)}`);
});
});