mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
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:
1 parent
9ccb5233f3
commit
fc5ee582e3
37 files changed
+1526
-107
No files matched your search
+9
-7
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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' ;;
|
||||
|
||||
Executable
+101
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' } } } } } },
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Executable
+167
@@ -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)));
|
||||
@@ -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`] });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+67
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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([]));
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user