From 6ffbfb8ef1d7d64113f7768b9819b1bd48148e17 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 12:45:39 -0800 Subject: [PATCH 01/11] v1.92.40.0 ship-budget: a subagent's /ship cost says it is the root counter, an upper bound (GST-467) (#3696) * gstack-ship-budget start --meter-scope self|tree: label tree-scoped cost lines, refuse METER_SCOPE_PARENT (GST-467) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-budget: a ledger rebuilt from its record keeps meter_scope=tree; document the cap on a tree run Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-budget: a re-start asking for tree upgrades an open run; tree labels reach the return file, drift and reconcile lines; reading-missing fix names the subagent route Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs/ship-budget.md: GSTACK_SHIP_SUBAGENT=1 stands in for --subagent only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-doctor-components.sh | 1 + capy/skill/gstack/SKILL.md | 2 +- docs/ship-budget.md | 11 +- docs/troubleshooting.md | 12 ++ lib/result-codes-budget.ts | 7 +- lib/ship-budget-cli.ts | 21 +++- lib/ship-budget.ts | 18 ++- lib/ship-handoff.ts | 12 +- package.json | 2 +- ship/DRIVER.md | 4 +- ship/DRIVER.md.tmpl | 4 +- test/ship-budget-meter-scope.test.ts | 161 +++++++++++++++++++++++++++ 15 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 test/ship-budget-meter-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 758e468fa..7f5163280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.40.0] - 2026-11-01 + +**A subagent's /ship cost now says it is an upper bound.** A Capy subagent cannot read its own jam counter, so its /ship read `"this"`, the root thread, whose counter also holds every sibling lane. BUDGET_FINAL and the notes printed that total as if it were the run's own cost. + +### Added +- **`gstack-ship-budget start --meter-scope self|tree`.** A run started with `tree`, or with `--subagent` (or `GSTACK_SHIP_SUBAGENT=1`) and `--reading-from this`, is tree-scoped: every BUDGET, NOTE, BUDGET_FINAL and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. +- **`METER_SCOPE_PARENT`.** A subagent reading `"this"` that asks for `--meter-scope self` is refused with exit 2 and the fix `--meter-scope tree` (docs/troubleshooting.md#meter-scope-parent). + +### Changed +- The Capy skill and docs/ship-budget.md tell a subagent to start with `--subagent --reading-from this --meter-scope tree`, and say exact per-run cost comes from a root replay thread. The driver's Step 22 carries the `METER: tree` line into the report. + +### Tests +- `test/ship-budget-meter-scope.test.ts`: tree runs label every cost line, self runs print none, the refusal and its troubleshooting anchor, and the docs pointers. + ## [1.92.39.0] - 2026-11-01 **A push to main no longer runs Free Tests and Windows Free Tests again on a tree its pull request already passed.** 22 of 22 merges in 30 hours (Oct 27–29) reran both suites on the identical tree, about 66 Linux shard-minutes and 44 Windows minutes each. Now a `reuse` job, on main pushes only, finds the merged pull request, requires its head's tree to equal the pushed tree, and verifies that head's newest green `pull_request` or `workflow_dispatch` run with the same checks `gstack-pregate clear --run` uses. When it verifies, every suite job is skipped and the required check links the reused run. Anything else runs every job as before. The same verifier backs a new `--from-run` flag for the pregate tests stage. diff --git a/VERSION b/VERSION index 0e6e6f87b..15ecb1fd0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.39.0 +1.92.40.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index ab2e7139f..08a0a099b 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.39.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.40.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index e592bae08..33f77842f 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -361,6 +361,7 @@ gstack_result_anchor() { UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; + METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;; BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;; BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;; BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;; diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 12e9d01a2..7c87a2786 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -44,7 +44,7 @@ Run directories and scratch on Capy live under `~/.capy/work/` (`--out ~/. Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render /gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent: --owner-reply ""`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out --from ` restores the newest valid checkpoint. -Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). +Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). /ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing. diff --git a/docs/ship-budget.md b/docs/ship-budget.md index c00dab63a..34380ab94 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -76,8 +76,14 @@ one program measure nothing: Capy books a call after its program ends. A jam's own counter includes its own subagents, outside voices too (measured 2026-10-30: +10.92M across a six-reader dispatch with two ~0.35M parent calls between). `"this"` is the root thread, -whose counter also holds every sibling tree, so a subagent running /ship passes its own jam id; -a root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it +whose counter also holds every sibling tree. A subagent running /ship cannot read its own jam id, so it +reads `"this"` and starts with `--subagent --reading-from this --meter-scope tree` (`GSTACK_SHIP_SUBAGENT=1` in its +brief stands in for `--subagent` only; `--reading-from this` is still required): the run is tree-scoped, and every BUDGET, NOTE, BUDGET_FINAL +and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. A +subagent that asks for `--meter-scope self` while reading `"this"` is refused +(`METER_SCOPE_PARENT`). A `--token-cap` on a tree-scoped run stops on that upper bound, so sibling +lanes count against it. Exact per-run cost comes from a root replay thread: a root thread that runs +only that /ship, whose `"this"` is its own counter. A root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it verifies with no reading and prints `METER: parent`; the parent's handoff and reconcile readings, taken on its own counter (which holds the child), measure it. @@ -286,6 +292,7 @@ stop rules) before claiming a saving from the cap or from delegation. [BUDGET_CAP_REACHED](troubleshooting.md#budget-cap-reached) · [BUDGET_READING_MISSING](troubleshooting.md#budget-reading-missing) · +[METER_SCOPE_PARENT](troubleshooting.md#meter-scope-parent) · [BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected) · [BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable) · [BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid) · diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 575871a04..80496ac85 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1942,6 +1942,18 @@ owner's call: run the printed `RECOVER:` line; its resume needs **Fix.** In the execute program that runs the command, read `(await tools.thread.get({ thread_id: "" })).cost.tokens` and pass it as `--reading `. + +### `gstack-ship-budget: ... (METER_SCOPE_PARENT)` + +**Meaning.** `start` was told the run is a subagent (`--subagent` or `GSTACK_SHIP_SUBAGENT=1`) reading +`"this"` (`--reading-from this`), the root thread's counter, which also holds every sibling lane, and +asked for `--meter-scope self`. A self scope would print that counter as the run's own cost. Exit 2; +no run started. + +**Fix.** Start with `--meter-scope tree`. Every cost line then prints +`METER: tree (root counter, upper bound)`. Exact per-run cost comes from a root replay thread +(`docs/ship-budget.md`). + ### `gstack-ship-budget: ... (BUDGET_READING_REJECTED)` diff --git a/lib/result-codes-budget.ts b/lib/result-codes-budget.ts index 7ddcf5bd2..5fcb1b63b 100644 --- a/lib/result-codes-budget.ts +++ b/lib/result-codes-budget.ts @@ -17,7 +17,12 @@ export const BUDGET_RESULT_CODES = { BUDGET_READING_MISSING: { anchor: 'budget-reading-missing', summary: 'the Capy meter needs a fresh counter reading and none was passed', - fix: 'read cost.tokens from tools.thread.get({ thread_id: "" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading (ship/sections/budget-handoff.md has the recipe)', + fix: 'read cost.tokens from tools.thread.get({ thread_id: "" }) (your own jam, its subagents included; a subagent that can read only the root thread starts with --subagent --reading-from this --meter-scope tree) inside the execute program that runs the command and pass it with --reading (ship/sections/budget-handoff.md has the recipe)', + }, + METER_SCOPE_PARENT: { + anchor: 'meter-scope-parent', + summary: 'a subagent asked for a self-scoped run while its reading comes from "this", the root thread\'s counter, which also holds every sibling lane', + fix: 'start with --meter-scope tree (every cost line then prints METER: tree (root counter, upper bound)); exact per-run cost comes from a root replay thread', }, BUDGET_READING_REJECTED: { anchor: 'budget-reading-rejected', diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 99c10d167..1f88cc6b6 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -14,7 +14,7 @@ import * as path from 'node:path'; import { resultLine } from './result-codes'; import { BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, - aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, + aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, labelScope, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths, runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, type Ledger, type MeterKind, type Next, type Note, @@ -237,9 +237,15 @@ function pickMeter(o: Opts): MeterKind | undefined { } export function cmdStart(argv: string[], ctx: Ctx): Outcome { - const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode']); + const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from'], ['subagent']); const mode = str(o, 'mode') ?? 'admit'; if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`); + const scope = str(o, 'meter-scope'); + if (scope !== undefined && scope !== 'self' && scope !== 'tree') throw usageError(`--meter-scope '${scope}' is not self or tree`); + const from = str(o, 'reading-from'); + if (from !== undefined && from !== 'this' && from !== 'own') throw usageError(`--reading-from '${from}' is not this or own`); + const parentCounter = (o.subagent === true || ctx.env.GSTACK_SHIP_SUBAGENT === '1') && from === 'this'; + if (parentCounter && scope === 'self') throw budgetError('METER_SCOPE_PARENT', 'start --subagent --reading-from this --meter-scope self'); const record = str(o, 'record'); if (!record) throw usageError('start needs --record '); const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP; @@ -249,8 +255,10 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`); const existing = parseRecordBlock(record); if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) { - const l = readLedger(existing.ledger); - return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 }; + return mutateLedger(existing.ledger, ctx.env, l => { + if (scope === 'tree' || parentCounter) l.meter_scope = 'tree'; + return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 }; + }); } const meter = pickMeter(o); if (!meter) { @@ -271,6 +279,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [], marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}), ...(callFloor !== undefined ? { call_floor: callFloor } : {}), ...(mode === 'observe' ? { mode } : {}), + ...(scope === 'tree' || parentCounter ? { meter_scope: 'tree' as const } : {}), }; if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy'); if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!; @@ -279,7 +288,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { const out = decide(l, '0.9', { kind: 'step', step: '1' }, call, true); writeLedger(l, ctx.env); pointMarker(l.marker, l); - return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }; + return labelScope(l, { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }); } /** @@ -472,7 +481,7 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null }, epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [], ...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(), - ...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), + ...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), ...(b.meter_scope === 'tree' ? { meter_scope: 'tree' as const } : {}), }; if (meter === 'estimate') l.estimate_used = 0; fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 }); diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 57083b236..ae354cf40 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -91,6 +91,8 @@ export interface Ledger { /** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */ credits?: { baseline: number; last: number }; notes?: Note[]; + /** tree: the reading is the root thread's counter (a subagent cannot read its own jam id), so every cost line carries TREE_METER. */ + meter_scope?: 'tree'; stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string; } @@ -359,10 +361,22 @@ export function mutateLedger(file: string, env: NodeJS.ProcessEnv, fn: (l: Le const ledger = readLedger(file); const out = fn(ledger); writeLedger(ledger, env); - return out; + return labelScope(ledger, out); }); } +export const TREE_METER = 'METER: tree (root counter, upper bound)'; + +/** A tree-scoped run never prints a cost line without TREE_METER on the line after it. */ +export function labelScope(l: Ledger, out: T): T { + if (l.meter_scope !== 'tree') return out; + const label = (lines: string[]) => lines.flatMap((line, i) => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/.test(line) && lines[i + 1] !== TREE_METER ? [line, TREE_METER] : [line]); + if (Array.isArray(out)) return label(out) as T; + const o = out as { stdout?: unknown }; + if (Array.isArray(o?.stdout)) o.stdout = label(o.stdout); + return out; +} + function childUsed(l: Ledger, r: Reservation): number { const h = l.handoffs.find(x => x.reservation === r.id); if (!h || h.reading_at === null || h.epoch !== l.meter.epoch || l.meter.last === null) return 0; @@ -463,7 +477,7 @@ export function recordBlock(l: Ledger): string { const t = tally(l); const open = l.handoffs.filter(h => !h.reconciled_at).map(h => h.sha).join(',') || 'none'; const credits = creditsUsed(l); - return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`; + return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}${l.meter_scope === 'tree' ? ' meter_scope=tree' : ''}`; } /** The record's fenced gstack-budget block: written by the bin, never by the agent. */ diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index e50af160b..e19cdb190 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -25,7 +25,7 @@ import { resolveClaudeCommand } from './claude-bin'; import { wtreeFingerprint } from './script-invocation'; import { resultLine } from './result-codes'; import { - MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, + MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, labelScope, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, projector, readLedger, reserveIn, runDirFor, segmentEstimate, settleIn, tally, usageError, writeLedger, type Handoff, type Ledger, } from './ship-budget'; import { loadMeterState, saveMeterState } from './claude-usage'; @@ -195,7 +195,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { ...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) }, epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [], marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration, - ...(parentMetered ? { parent_metered: true } : {}), + ...(parentMetered ? { parent_metered: true } : {}), ...(parent.meter_scope ? { meter_scope: parent.meter_scope } : {}), }; if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session '); readMeter(child, o, !parentMetered); @@ -205,9 +205,9 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { }); writeLedger(child, ctx.env); pointMarker(child.marker, child); - return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`, + return labelScope(child, { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`, ...(parentMetered ? [`${PARENT_METERED} (no counter of its own: the parent's handoff and reconcile readings measure Steps ${block.from}-${block.to})`] : []), - budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 }; + budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 }); } export function cmdReturn(argv: string[], ctx: Ctx): Outcome { @@ -229,11 +229,11 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome { const unreconciled = l.handoffs.filter(h => !h.reconciled_at); const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id)); const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status; - const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, + const lines = labelScope(l, [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, ...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`), ...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`), `RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }), - `GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]; + `GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]); writeReturnFile(l.parent.return_file, lines); l.closed_at = new Date().toISOString(); return { stdout: lines, exit: 0 }; diff --git a/package.json b/package.json index c1604360f..888907da6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.39", + "version": "1.92.40", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/DRIVER.md b/ship/DRIVER.md index f5f6e3cce..3dec39134 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -120,8 +120,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. - Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. -- Step 20: the ship metrics row via `gstack-review-log`. -- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. +- Step 20: the metrics row via `gstack-review-log`. +- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. ## Where each STOP sends you (paths under `/ship/`) diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index f78226988..d4a874ae6 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -118,8 +118,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. - Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. -- Step 20: the ship metrics row via `gstack-review-log`. -- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. +- Step 20: the metrics row via `gstack-review-log`. +- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. ## Where each STOP sends you (paths under `/ship/`) diff --git a/test/ship-budget-meter-scope.test.ts b/test/ship-budget-meter-scope.test.ts new file mode 100644 index 000000000..acd8b4608 --- /dev/null +++ b/test/ship-budget-meter-scope.test.ts @@ -0,0 +1,161 @@ +/** + * GST-467: a subagent running /ship cannot read its own jam id, so its Capy reading is the root + * thread's counter, which also holds every sibling lane. Such a run is tree-scoped and every cost + * line it prints carries METER: tree (root counter, upper bound); a subagent that asks for a self + * scope while metering from "this" is refused (METER_SCOPE_PARENT). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { RESULT_CODES } from '../lib/result-codes'; +import { labelScope, type Ledger } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const TREE = 'METER: tree (root counter, upper bound)'; +const COST = /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/; +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function costLinesLabeled(out: string): boolean[] { + const lines = out.split('\n'); + return lines.flatMap((l, i) => COST.test(l) ? [lines[i + 1] === TREE] : []); +} + +function observedRun(h: Harness, start: string[], env: NodeJS.ProcessEnv = {}) { + const s = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--credits', '100', ...start], env); + if (s.code !== 0) return { start: s, note: s, close: s }; + const run = h.field(s.out, 'BUDGET_RUN', 'id'); + const note = h.sb(['note', '--run', run, '--step', '6', '--reading', '1500000', '--credits', '150']); + const close = h.sb(['close', '--run', run, '--reading', '2000000', '--credits', '200']); + return { start: s, note, close }; +} + +describe('meter scope (GST-467)', () => { + // Value: protects=a run metered off the root counter never prints a cost without saying it is the root counter, an upper bound; fails_when=a tree-scoped run prints a BUDGET, NOTE, BUDGET_FINAL or BUDGET_TIMELINE line with no METER: tree line under it, or a self run is labeled tree; + // why_new=#3686's ledger baseline 5,769,355,282 was the root thread's counter, sibling lanes included (GST-467); seam=none; no_claim=the exact per-run cost, which only a root replay thread measures + test('every cost line of a tree-scoped run is labeled, whether asked for or implied by a subagent reading "this"; a self run is not', () => { + for (const [start, env] of [[['--meter-scope', 'tree'], {}], [['--subagent', '--reading-from', 'this'], {}], [['--reading-from', 'this'], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) { + const r = observedRun(fresh(), [...start], env); + for (const out of [r.start, r.note, r.close]) { + expect(out.code, out.err).toBe(0); + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + expect(r.close.out).toMatch(/^BUDGET_FINAL: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m); + expect(r.close.out).toMatch(/^BUDGET_TIMELINE: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m); + } + for (const start of [['--meter-scope', 'self'], ['--reading-from', 'this', '--meter-scope', 'self'], []]) { + const r = observedRun(fresh(), start); + for (const out of [r.start, r.note, r.close]) { + expect(out.code, out.err).toBe(0); + expect(out.out).not.toContain('METER: tree'); + } + } + }); + + // Value: protects=resume and a handoff child of a tree-scoped run keep the label; fails_when=resume or verify-handoff prints a BUDGET line with no METER: tree line under it; + // why_new=the test above drives start, note and close only; seam=none; no_claim=a child's exact cost + test('resume and a handoff child of a tree-scoped run label their cost lines too', () => { + const h = fresh(); + const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']); + const run = h.field(s.out, 'BUDGET_RUN', 'id'); + const resumed = h.sb(['resume', '--run', run, '--reading', '1200000']); + const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']); + expect(ho.code, ho.out + ho.err).toBe(0); + const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]); + for (const out of [resumed, ho, child]) { + expect(out.code, out.out + out.err).toBe(0); + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + }); + + // Value: protects=a tree run rebuilt from its record after a lost ledger keeps the label; fails_when=the record block drops meter_scope or rebuildLedger does not restore it (red-team, ship gst467-p1); + // why_new=the cases above never lose the ledger; seam=none; no_claim=checkpoint restore, which copies the whole ledger + test('a tree run rebuilt from its record still labels its cost lines', () => { + const h = fresh(); + const record = h.record(); + const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000', '--meter-scope', 'tree']); + const ledger = h.field(s.out, 'BUDGET_RUN', 'ledger'); + fs.writeFileSync(ledger, '{"kind": "gstack-ship-budget", oops'); + const rebuilt = h.sb(['resume', '--record', record, '--rebuild-from-record', '--reading', '1500000']); + expect(rebuilt.out, rebuilt.out + rebuilt.err).toContain('REBUILT:'); + const close = h.sb(['close', '--record', record, '--reading', '2000000']); + for (const out of [rebuilt, close]) { + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + }); + + // Value: protects=re-running start with tree flags on an open self run upgrades it, and a tree child's saved return file keeps the label the parent reads; fails_when=the re-start returns before applying the scope, or the return file is written before labelScope (outside voice, ship gst467-p1); + // why_new=start's resume branch and the return file were never driven with tree scope; seam=none; no_claim=a downgrade, which tree never allows + test('a re-start asking for tree upgrades an open run, and a tree child return file carries the label once', () => { + const h = fresh(); + const record = h.record(); + const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000']); + expect(s.out).not.toContain(TREE); + const again = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1100000', '--subagent', '--reading-from', 'this', '--meter-scope', 'tree']); + const close = h.sb(['close', '--record', record, '--reading', '2000000']); + for (const out of [again, close]) { + expect(out.code, out.out + out.err).toBe(0); + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + const p = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']); + const run = h.field(p.out, 'BUDGET_RUN', 'id'); + const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']); + const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; + const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]); + const ret = h.sb(['return', '--run', h.field(child.out, 'BUDGET_RUN', 'id'), '--to', '11.5']); + expect(ret.code, ret.out + ret.err).toBe(0); + for (const text of [ret.out, fs.readFileSync(returnFile, 'utf8')]) { + const labels = costLinesLabeled(text); + expect(labels.length, text).toBeGreaterThan(0); + expect(labels.every(Boolean), text).toBe(true); + expect(text.split(TREE).length - 1, text).toBe(labels.length); + } + }); + + // Value: protects=every root-counter line a tree run prints is labeled once, drift and reconcile lines included; fails_when=labelScope misses BUDGET_DRIFT or RECONCILED, or labels an already-labeled line twice (adversarial, ship gst467-p1); + // why_new=no CLI case above prints drift or reconcile lines; seam=labelScope; no_claim=which commands print them + test('labelScope labels drift and reconcile lines too, and never twice', () => { + const tree = { meter_scope: 'tree' } as Ledger; + const lines = ['RECONCILED: handoff=x child=complete child_used=5', 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', 'GSTACK_RESULT: skill=ship']; + expect(labelScope(tree, [...lines])).toEqual(['RECONCILED: handoff=x child=complete child_used=5', TREE, 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', TREE, 'GSTACK_RESULT: skill=ship']); + expect(labelScope({} as Ledger, [...lines])).toEqual(lines); + }); + + // Value: protects=a subagent cannot claim its own cost while reading the root counter; fails_when=start accepts --meter-scope self from a subagent metering "this", or the refusal lacks its code and fix; + // why_new=GST-467; seam=none; no_claim=whether the brief sets the subagent flag + test('a subagent metering "this" that asks for a self scope is refused with METER_SCOPE_PARENT and a fix', () => { + const h = fresh(); + for (const [args, env] of [[['--subagent'], {}], [[], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) { + const r = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--reading-from', 'this', '--meter-scope', 'self', ...args], env); + expect(r.code).toBe(2); + expect(r.err).toContain('(METER_SCOPE_PARENT)'); + expect(r.err).toContain('--meter-scope tree'); + } + const bad = h.sb(['start', '--record', h.record(), '--reading', '1', '--meter-scope', 'mine']); + expect(bad.code).toBe(2); + expect(bad.err).toContain('--meter-scope'); + expect(RESULT_CODES.METER_SCOPE_PARENT.fix).toContain('--meter-scope tree'); + expect(fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8')).toContain(``); + }); + + // Value: protects=the /ship brief tells a subagent to meter tree-scoped and the docs say where exact cost comes from; fails_when=the Capy skill or docs/ship-budget.md drops --meter-scope tree or the root-replay pointer; + // why_new=GST-467; seam=none; no_claim=agent compliance + test('the Capy skill and docs/ship-budget.md tell a subagent to pass --meter-scope tree; exact per-run cost comes from root replay threads', () => { + const skill = fs.readFileSync(path.join(ROOT, 'capy/skill/gstack/SKILL.md'), 'utf8').replace(/\s+/g, ' '); + expect(skill).toContain('--subagent --reading-from this --meter-scope tree'); + const doc = fs.readFileSync(path.join(ROOT, 'docs/ship-budget.md'), 'utf8').replace(/\s+/g, ' '); + expect(doc).toContain('--meter-scope tree'); + expect(doc).toContain(TREE); + expect(doc).toContain('root replay thread'); + }); +}); From 74836e41d677e70ec56a472ad003ee33e82f6c6d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 13:11:44 -0800 Subject: [PATCH 02/11] v1.92.41.0 fix(ubicloud): pack and usage on macOS, reap and up on an empty project (#3680, #3679, #3678) (#3746) * fix(ubicloud): pack and usage on macOS, empty projects (#3680, #3679, #3678) pack builds its archive in two tar passes plus gzip, which bsdtar and GNU tar both accept, with COPYFILE_DISABLE=1 so Apple's tar adds no ._* files. vm_rows skips the blank row Ubicloud returns for a project with no VMs. usage parenthesizes its ternary for the one-true-awk and exits 1 on an unreadable list or size; the quota preflight logs that and defers to the API. Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (usage skips a VM destroyed since the list; spawn timeout) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (quota wait retries an unreadable usage) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for the ubicloud macOS and empty-project fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: TESTING_INTERNALS notes the quota preflight's unreadable-usage path Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/TESTING_INTERNALS.md | 4 +- package.json | 2 +- scripts/ubicloud/ubi-runner.sh | 56 ++++++-- test/helpers/ubi-runner-fake-vm.ts | 4 +- ...on-issue3680-ubicloud-bsd-userland.test.ts | 122 ++++++++++++++++++ test/ubicloud-runner-verify-quota.test.ts | 54 ++++++++ test/ubicloud-runner.test.ts | 38 ++++++ 10 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 test/regression-issue3680-ubicloud-bsd-userland.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5163280..abd27e4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.92.41.0] - 2026-11-01 + +**The Ubicloud runner works on macOS and on a project with no VMs (#3680, #3679, #3678).** On a Mac, `gstack-ubi pack` failed, so every `sync`, `run` and `verify` broke after the VM was already created, and `gstack-ubi usage` was a syntax error that also switched off the vCPU quota check. On any machine, an empty Ubicloud project made `reap` and every `up` die. All three were reported by Jared. + +### Fixed +- **`pack` on macOS.** macOS's tar read pack's options as file names and shipped an archive holding only `.git`. Pack now builds the archive in two steps that work with both macOS and GNU tar, and keeps macOS tar from adding hidden `._*` copies of files that carry extended attributes. (#3680) +- **`usage` and the quota check on macOS.** One line of the usage report was a syntax error for macOS's awk. `usage` now also exits 1 instead of printing a partial count when Ubicloud can't list the VMs or read a size, and skips a VM destroyed between the two reads. When usage can't be read, the quota check retries while `UBI_QUOTA_WAIT` has time left, then says so in the log and leaves the decision to Ubicloud. (#3679) +- **`reap` and `up` on an empty project.** An empty VM list was read as one VM named `-`, so they died with `invalid VM name '-'`. (#3678) + ## [1.92.40.0] - 2026-11-01 **A subagent's /ship cost now says it is an upper bound.** A Capy subagent cannot read its own jam counter, so its /ship read `"this"`, the root thread, whose counter also holds every sibling lane. BUDGET_FINAL and the notes printed that total as if it were the run's own cost. diff --git a/VERSION b/VERSION index 15ecb1fd0..f0625b8df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.40.0 +1.92.41.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 08a0a099b..72f251a5b 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.40.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.41.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index a51a031d5..caa047811 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1020,7 +1020,9 @@ first (`UBI_REAP=0` skips it). Names outside the `ubirun-` prefix are never reap quota preflight adds the size's vCPUs to the project's running VMs and refuses before the create request when that exceeds the known ceiling (`UBI_QUOTA_VCPUS`, or the maximum the last refusal printed, remembered in the -state root; `UBI_QUOTA_WAIT=` waits for room instead). `run`, `verify` +state root; `UBI_QUOTA_WAIT=` waits for room instead). When the usage read fails (`usage` exits 1 on an +unreadable VM list or size and skips a VM that 404s), the preflight retries +within `UBI_QUOTA_WAIT`, then logs and leaves the decision to the API. `run`, `verify` and `ssh NAME COMMAND` stream the remote output through a watchdog: a heartbeat line every `UBI_HEARTBEAT` seconds (300), and a command silent for `UBI_IDLE_TIMEOUT` seconds (1800) or older than `UBI_RUN_TIMEOUT` (0, unlimited) diff --git a/package.json b/package.json index 888907da6..7b2485382 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.40", + "version": "1.92.41", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/ubicloud/ubi-runner.sh b/scripts/ubicloud/ubi-runner.sh index fc581c34d..bef063f33 100755 --- a/scripts/ubicloud/ubi-runner.sh +++ b/scripts/ubicloud/ubi-runner.sh @@ -740,6 +740,7 @@ run_tiers() { vm_rows() { local loc name owner created expires cli vm list -N -f location,name | while read -r loc name; do + [ -n "$name" ] || continue owner=. created="" expires=- if [[ "$name" =~ ^${PREFIX}-([a-z][a-z0-9]{0,11})-([0-9]{10})- ]]; then owner=${BASH_REMATCH[1]} created=${BASH_REMATCH[2]} @@ -804,21 +805,39 @@ cmd_list() { } # usage: VMs and vCPUs per owner, from each VM's size (standard-16 = 16 vCPUs). +# Exits 1 with no table when the VM list or any VM's size cannot be read, so a +# partial count is never taken for the project's usage. A VM destroyed between +# the list and its size read (404) no longer counts and is not a failure. cmd_usage() { - local tmp loc name owner i=0 + local tmp rows loc name owner f i=0 unread=0 + rows=$(vm_rows) || { log "usage: could not list the project's VMs"; return 1; } tmp=$(mktemp -d) while read -r loc name owner _; do + [ -n "$name" ] || continue i=$(( i + 1 )) - cli vm "$loc/$name" show 2>/dev/null | sed -n "s/^size: /$owner /p" >"$tmp/$i" & + { + case $(api "$tmp/$i.show" vm "$loc/$name" show) in + 200) sed -n "s/^size: /$owner /p" "$tmp/$i.show" >"$tmp/$i" ;; + 404) ;; + *) : >"$tmp/$i" ;; + esac + rm -f "$tmp/$i.show" + } & [ $(( i % 8 )) -ne 0 ] || wait - done < <(vm_rows) + done <<<"$rows" wait - cat "$tmp"/* 2>/dev/null | awk -v me="$OWNER" ' + for f in "$tmp"/*; do [ ! -e "$f" ] || [ -s "$f" ] || unread=$(( unread + 1 )); done + if [ "$unread" -gt 0 ]; then + rm -rf "$tmp" + log "usage: could not read the size of $unread of $i VMs" + return 1 + fi + find "$tmp" -type f -exec cat {} + | awk -v me="$OWNER" ' { n = $2; sub(/^.*-/, "", n); o = $1 == "-" ? "(untagged)" : $1 == "." ? "(other)" : $1 vms[o]++; cpu[o] += n; total += n; count++ } END { printf "%-14s %4s %6s\n", "OWNER", "VMS", "VCPUS" - for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], o == me ? " (you)" : "" | "sort -k3,3nr" + for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], (o == me ? " (you)" : "") | "sort -k3,3nr" close("sort -k3,3nr") printf "%-14s %4d %6d\n", "total", count, total }' @@ -881,8 +900,17 @@ quota_preflight() { # between them: a slow first usage read must not shave the printed budget. remaining=$QUOTA_WAIT while :; do - used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}') - [[ "$used" =~ ^[0-9]+$ ]] || return 0 + if ! used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}') || ! [[ "$used" =~ ^[0-9]+$ ]]; then + if [ "$remaining" -le 0 ]; then + log "quota preflight: could not read the project's vCPU usage; the API's own answer decides" + return 0 + fi + log "quota: could not read the project's vCPU usage; retrying, up to ${remaining}s more" + step=$(( remaining < 30 ? remaining : 30 )) + sleep "$step" + remaining=$(( remaining - step )) + continue + fi [ $(( used + need )) -gt "$max" ] || return 0 if [ "$remaining" -le 0 ]; then log "quota preflight refused $location/$name ($size): it needs $need vCPUs and the project's VMs already use $used of $max (ceiling from $source; other usage such as managed runners counts on top)" @@ -1074,7 +1102,12 @@ cmd_down() { # pointer into the main repository, useless on the VM; the same snapshot # replaces it, on the worktree's own branch and index. Not carried: submodule # repositories (.git/modules) and a partial clone's promisor (the fetch pulls -# every missing blob first); gstack has neither. +# every missing blob first); gstack has neither. The archive is built in two +# passes inside the snapshot directory because macOS's bsdtar stops reading +# options at the first operand and resolves -T names after a -C, so no single +# tar command line works on both it and GNU tar (#3680). COPYFILE_DISABLE=1 +# keeps Apple's tar from adding ._* AppleDouble entries for files with +# extended attributes, which the VM would see as untracked files. cmd_pack() { local src tmp gitdir branch head entry src=$(cd "${1:-.}" && pwd) @@ -1119,15 +1152,18 @@ cmd_pack() { fi ( cd "$src" + export COPYFILE_DISABLE=1 git ls-files -z -co --exclude-standard \ | while IFS= read -r -d '' f; do if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi done \ - | tar --null -T - -c -C "$tmp" .git -zf - + | tar --null -T - -cf "$tmp/files.tar" \ + && tar -rf "$tmp/files.tar" -C "$tmp" .git \ + && gzip -c "$tmp/files.tar" ) rm -rf "$tmp"; trap - EXIT else - tar -C "$src" -czf - . + COPYFILE_DISABLE=1 tar -C "$src" -czf - . fi } diff --git a/test/helpers/ubi-runner-fake-vm.ts b/test/helpers/ubi-runner-fake-vm.ts index 20a6a8514..53db8146a 100644 --- a/test/helpers/ubi-runner-fake-vm.ts +++ b/test/helpers/ubi-runner-fake-vm.ts @@ -39,8 +39,8 @@ export function fakeVm(opts: { listed?: string; refuse?: string } = {}) { 'case "$body" in\n' + ' *\'"create"\'*) if [ -n ' + JSON.stringify(opts.refuse ?? '') + ' ]; then printf \'%s\' ' + JSON.stringify(opts.refuse ?? '') + ' > "$out"; printf 400; else echo "$loc $name" >> "$listed"; : > "$out"; printf 200; fi ;;\n' + ' *destroy*) : > "$out"; if [ -f "$listed.destroy-fails" ]; then printf 500; else grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; printf 200; fi ;;\n' + - ' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" > "$out"; printf 200; fi ;;\n' + - ' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' + + ' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" "$listed.ghost" > "$out" 2>/dev/null; printf 200; fi ;;\n' + + ' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if [ -f "$listed.show-fails" ]; then echo down > "$out"; printf 503; elif grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' + ' *) : > "$out"; printf 200 ;;\n' + 'esac\n'); writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\n' + diff --git a/test/regression-issue3680-ubicloud-bsd-userland.test.ts b/test/regression-issue3680-ubicloud-bsd-userland.test.ts new file mode 100644 index 000000000..a6fd98a86 --- /dev/null +++ b/test/regression-issue3680-ubicloud-bsd-userland.test.ts @@ -0,0 +1,122 @@ +/** + * Regression tests for #3680 and #3679: scripts/ubicloud/ubi-runner.sh on the + * BSD userland macOS ships. + * + * #3680 pack built its archive with `tar --null -T - -c -C "$tmp" .git -zf -`. + * bsdtar stops reading options at the first operand (so -zf and - were + * file names) and resolves -T names after the -C (so the work-tree + * files were looked up in the temp dir): exit 1 and an archive holding + * only .git, after the VM was already created. + * #3679 usage's END block printed `o == me ? " (you)" : ""` unparenthesized + * inside a printf list, a syntax error in the one-true-awk, so usage + * exited 2 and the vCPU quota preflight read no total and let every + * create through. + * + * Both need the real tools: macOS's /usr/bin/tar and /usr/bin/awk, or on Linux + * `bsdtar` (libarchive-tools) and `original-awk`. Where a tool is missing its + * cases are skipped, and the skip names the package. No network, no VM. + */ +import { describe, expect, test } from 'bun:test'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dir, '..'); +const RUNNER = join(ROOT, 'scripts/ubicloud/ubi-runner.sh'); +const darwin = process.platform === 'darwin'; +const bsdTar = darwin ? '/usr/bin/tar' : Bun.which('bsdtar'); +const bwkAwk = darwin ? '/usr/bin/awk' : Bun.which('original-awk'); + +/** A directory to put first on PATH whose `tar` or `awk` is the BSD one. */ +function bsdPath(root: string, tools: { tar?: string | null; awk?: string | null }) { + const bin = join(root, 'bsd-bin'); + mkdirSync(bin, { recursive: true }); + if (tools.tar) symlinkSync(tools.tar, join(bin, 'tar')); + if (tools.awk) symlinkSync(tools.awk, join(bin, 'awk')); + return `${bin}:${process.env.PATH}`; +} + +const gitEnv = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + +describe(`ubi-runner pack under bsdtar (#3680)${bsdTar ? '' : ': skipped, no bsdtar (install libarchive-tools)'}`, () => { + // Value: protects=pack writes a whole checkout under bsdtar (macOS's default tar), not an archive of .git alone; fails_when=pack passes tar options after an operand or resolves its -T list after a -C again; why_new=test/ubicloud-runner.test.ts runs pack only under GNU tar on Linux, so #3680 shipped green; seam=PATH (tar resolves to bsdtar); no_claim=the VM side's tar -xzf (GNU tar on Ubuntu, exercised by ubicloud-runner.test.ts) + test.skipIf(!bsdTar)('pack ships tracked, staged, untracked, symlinked, spaced and option-like names, a linked worktree and an empty file list', () => { + const d = mkdtempSync(join(tmpdir(), 'ubi-bsd-pack-')); + try { + const PATH = bsdPath(d, { tar: bsdTar }); + const sh = (cmd: string, cwd: string) => { + const r = Bun.spawnSync(['bash', '-c', cmd], { cwd, timeout: 30_000, env: { ...process.env, ...gitEnv, PATH } }); + expect(r.exitCode, `${cmd}\n${r.stderr.toString()}`).toBe(0); + return r.stdout.toString(); + }; + expect(sh('tar --version', d)).toContain('bsdtar'); + const pack = (src: string, out: string) => sh(`bash ${JSON.stringify(RUNNER)} pack ${JSON.stringify(src)} > ${JSON.stringify(out)}`, d); + const unpack = (tgz: string, dir: string) => { mkdirSync(join(d, dir)); sh(`tar -xzf ${JSON.stringify(tgz)} -C ${JSON.stringify(dir)}`, d); return join(d, dir); }; + const status = (cwd: string) => sh('git status --short', cwd).split('\n').filter(Boolean).sort(); + + sh('git init -q plain && cd plain && echo a > a && echo b > gone && echo s > "with space" && ln -s a link && echo o > ./-C && echo o > ./--files-from=a && git add -A && git commit -qm one && rm gone && echo st > staged && git add staged && echo u > untracked && echo x > "untracked space"', d); + pack('plain', 'plain.tgz'); + const listing = sh('tar -tzf plain.tgz', d).split('\n'); + expect(listing.some(l => l.endsWith('files.tar'))).toBe(false); + const vm = unpack('plain.tgz', 'vm-plain'); + expect(status(vm)).toEqual(status(join(d, 'plain'))); + expect(status(vm)).toEqual([' D gone', '?? "untracked space"', '?? untracked', 'A staged']); + expect(readFileSync(join(vm, 'with space'), 'utf8')).toBe('s\n'); + expect(sh('readlink link', vm).trim()).toBe('a'); + expect(existsSync(join(vm, '-C')) && existsSync(join(vm, '--files-from=a'))).toBe(true); + + sh('git init -q main && cd main && echo a > a && git add a && git commit -qm one && git branch feat && git worktree add -q ../wt feat', d); + sh('echo b > b && git add b && git commit -qm two && echo u > u && echo ignored > ig && echo ig > .gitignore', join(d, 'wt')); + pack('wt', 'wt.tgz'); + const vmWt = unpack('wt.tgz', 'vm-wt'); + expect(existsSync(join(vmWt, 'ig'))).toBe(false); + expect(sh('git rev-parse --abbrev-ref HEAD && git status --short && git log --oneline | wc -l', vmWt).replace(/\s+/g, ' ').trim()).toBe('feat ?? .gitignore ?? u 2'); + + sh('git init -q empty && cd empty && echo a > a && git add a && git commit -qm one && rm a', d); + pack('empty', 'empty.tgz'); + expect(status(unpack('empty.tgz', 'vm-empty'))).toEqual([' D a']); + } finally { rmSync(d, { recursive: true, force: true }); } + }, 60_000); +}); + +describe(`ubi-runner usage and the quota preflight under the one-true-awk (#3679)${bwkAwk ? '' : ': skipped, no one-true-awk (install original-awk)'}`, () => { + /** Fake Ubicloud API: two VMs, a standard-4 owned by lane7 and a standard-16 another tool named; every call body goes to calls.log. */ + function fakeApi() { + const root = mkdtempSync(join(tmpdir(), 'ubi-bsd-awk-')); + const bin = join(root, 'bin'), log = join(root, 'calls.log'); + mkdirSync(bin); mkdirSync(join(root, 'state')); + writeFileSync(join(bin, 'curl'), `#!/usr/bin/env bash +out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done +body=$(cat); echo "$body" >> ${JSON.stringify(log)} +case "$body" in + *'"list"'*) printf 'eu-central-h1 ubirun-lane7-1761900000-ab12-t3\\neu-north-h1 someone-elses-vm\\n' > "$out" ;; + *lane7*'"show"'*) printf 'state: running\\nsize: standard-4\\n' > "$out" ;; + *'"show"'*) printf 'state: running\\nsize: standard-16\\n' > "$out" ;; + *) : > "$out" ;; +esac +printf 200 +`); + chmodSync(join(bin, 'curl'), 0o755); + const PATH = bsdPath(root, { awk: bwkAwk }); + const env = { ...process.env, PATH: `${bin}:${PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(root, 'state'), UBI_VM_RECORD: join(root, 'vms.tsv'), UBI_OWNER: 'lane7', UBI_REAP: '0', UBI_POLL_SECONDS: '1' }; + const run = (args: string[], extra: Record = {}) => Bun.spawnSync(['bash', RUNNER, ...args], { env: { ...env, ...extra }, timeout: 60_000 }); + return { root, run, env, calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : '') }; + } + + // Value: protects=usage runs under macOS's awk and the vCPU preflight refuses over its ceiling there; fails_when=an awk program in the runner uses syntax the one-true-awk rejects, so usage dies and the preflight silently passes; why_new=the runner's awk ran only under mawk/gawk in CI, so #3679 shipped green; seam=PATH (awk resolves to the one-true-awk) and a fake curl; no_claim=real Ubicloud sizes and quota + test.skipIf(!bwkAwk)('usage prints the per-owner table, and up refuses a create that would pass UBI_QUOTA_VCPUS', () => { + const f = fakeApi(); + try { + expect(Bun.spawnSync(['awk', '--version'], { env: f.env, timeout: 10_000 }).stdout.toString()).toContain('awk version'); + const usage = f.run(['usage']); + expect(usage.stderr.toString()).toBe(''); + expect(usage.exitCode).toBe(0); + expect(usage.stdout.toString()).toBe('OWNER VMS VCPUS\n(other) 1 16\nlane7 1 4 (you)\ntotal 2 20\n'); + const up = f.run(['up', '-s', 'standard-4'], { UBI_QUOTA_VCPUS: '16' }); + expect(up.exitCode).toBe(1); + expect(up.stderr.toString()).toContain('it needs 4 vCPUs and the project\'s VMs already use 20 of 16'); + expect(up.stderr.toString()).toContain('create refused before any request: vCPU quota exhausted'); + expect(f.calls()).not.toContain('"create"'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/test/ubicloud-runner-verify-quota.test.ts b/test/ubicloud-runner-verify-quota.test.ts index 92966c925..e099b9638 100644 --- a/test/ubicloud-runner-verify-quota.test.ts +++ b/test/ubicloud-runner-verify-quota.test.ts @@ -125,6 +125,60 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', ( } finally { rmSync(f.root, { recursive: true, force: true }); } }, 90_000); + // Value: protects=the first up on a fresh project reaches its create with the default reap on; fails_when=the reap before up dies on an empty list again (#3678); why_new=no up case ran against a project with no VMs; seam=none; no_claim=real create and boot (fake API and ssh) + test('up on an empty project reaps nothing, creates its VM and comes up (#3678)', () => { + const f = fakeVm({ listed: '\n' }); + try { + const up = run(f, ['up']); + expect(up.stderr.toString()).toContain('reap: no expired VMs'); + expect(up.exitCode).toBe(0); + expect(f.calls()).toContain('"create"'); + expect(f.calls()).not.toContain('destroy'); + const name = up.stdout.toString().trim(); + expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/); + expect(run(f, ['down', name]).exitCode).toBe(0); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + // Value: protects=usage never reports a partial or empty count as the project's real usage, and the quota preflight says when it cannot read usage instead of passing silently; fails_when=a failed vm list or vm show prints a total again (the empty-project fix's `|| true` did that) or the preflight drops its log line; why_new=only successful reads were tested, and #3679 showed an unreadable usage switching the preflight off without a word; seam=none; no_claim=the real API's failure bodies + test('usage exits 1 without a total when the VM list or a size read fails, skips a VM destroyed since the list, and the quota preflight logs an unreadable usage and leaves the call to the API', () => { + const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); + const listed = join(f.root, 'listed'); + try { + writeFileSync(`${listed}.list-fails`, ''); + const noList = run(f, ['usage']); + expect(noList.exitCode).toBe(1); + expect(noList.stdout.toString()).not.toContain('total'); + expect(noList.stderr.toString()).toContain("usage: could not list the project's VMs"); + const up = run(f, ['up'], { UBI_REAP: '0', UBI_QUOTA_VCPUS: '16' }); + expect(up.stderr.toString()).toContain("quota preflight: could not read the project's vCPU usage; the API's own answer decides"); + expect(f.calls()).toContain('"create"'); + // With a wait budget, an unreadable usage is retried until the budget is spent, as a full project is. + const waited = run(f, ['up'], { UBI_REAP: '0', UBI_QUOTA_VCPUS: '16', UBI_QUOTA_WAIT: '2' }); + expect(waited.stderr.toString()).toContain("quota: could not read the project's vCPU usage; retrying, up to 2s more"); + expect(waited.stderr.toString()).toContain("quota preflight: could not read the project's vCPU usage; the API's own answer decides"); + const waitedName = waited.stdout.toString().trim(); + rmSync(`${listed}.list-fails`); + for (const name of [up.stdout.toString().trim(), waitedName]) { + expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/); + expect(run(f, ['down', name]).exitCode).toBe(0); + } + + writeFileSync(`${listed}.show-fails`, ''); + const noShow = run(f, ['usage']); + expect(noShow.exitCode).toBe(1); + expect(noShow.stdout.toString()).not.toContain('total'); + expect(noShow.stderr.toString()).toContain('usage: could not read the size of 1 of 1 VMs'); + rmSync(`${listed}.show-fails`); + + // A VM destroyed between the list and its size read (show answers 404) is gone, not unread. + writeFileSync(`${listed}.ghost`, 'eu-central-h1 ubirun-gone-9999999999-dddd-t3\n'); + const ghost = run(f, ['usage']); + expect(ghost.exitCode).toBe(0); + expect(ghost.stdout.toString()).toBe('OWNER VMS VCPUS\npeer 1 16\ntotal 1 16\n'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + // Value: protects=the quota wait budget counts seconds slept, not usage reads: a slow usage read keeps the printed budget, a wait above 30 s counts down in 30 s steps, and the wait releases when room appears; fails_when=the budget goes back to a wall-clock deadline taken before the first usage read (the first log line shrinks to 1s), the step cap or the decrement is lost, or room found mid-wait still refuses; why_new=the WAIT=3 case above passes under both the old deadline and the new countdown because its fake usage read is instant and it never sees a second step; seam=none (the fixture's bin/ is already first on PATH, so sleep and curl are stubbed there); no_claim=real elapsed time of the long wait (the 70 s case stubs sleep) test('quota wait: a slow usage read does not shave the printed budget; a long wait counts down in 30s steps and releases when room appears', () => { const slow = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); diff --git a/test/ubicloud-runner.test.ts b/test/ubicloud-runner.test.ts index ae075c25c..71d1de15e 100644 --- a/test/ubicloud-runner.test.ts +++ b/test/ubicloud-runner.test.ts @@ -250,6 +250,25 @@ esac } finally { rmSync(f.root, { recursive: true, force: true }); } }, 30_000); + // Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb) + test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => { + for (const listed of ['\n', ' \n']) { + const f = fakeApi({ destroy: 200, listed }); + try { + const reap = f.run('reap'); + expect(reap.stderr.toString()).toContain('reap: no expired VMs'); + expect(reap.stderr.toString()).not.toContain('invalid VM name'); + expect(reap.exitCode).toBe(0); + const usage = f.run('usage'); + expect(usage.exitCode).toBe(0); + expect(usage.stdout.toString()).toBe('OWNER VMS VCPUS\ntotal 0 0\n'); + expect(f.run('list', '--mine').stdout.toString()).toBe(''); + expect(f.calls()).not.toContain('destroy'); + expect(f.calls()).not.toContain('"show"'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + } + }); + test('up and run refuse without UBI_OWNER before any API call; list, usage, reap and down still work without it', () => { const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 vm-a\n', owner: '' }); try { @@ -376,6 +395,25 @@ test('pack ships a linked worktree with a self-contained .git on the checked-out } finally { rmSync(d, { recursive: true, force: true }); } }, 90_000); + // Value: protects=pack never lets macOS tar add AppleDouble ._* entries (files with extended attributes), which the VM's tree check would count as untracked files and fail the run with UBI_VM_TREE_MISMATCH; fails_when=a tar call in pack runs without COPYFILE_DISABLE=1; why_new=only Apple's tar reads the variable, so no Linux run can see the ._* entries themselves; seam=PATH (a tar shim records its environment, then runs the real tar); no_claim=Apple tar's metadata behavior itself (checked by hand on a Mac) + test('pack runs every tar with COPYFILE_DISABLE=1, for a checkout and for a plain directory', () => { + const d = mkdtempSync(join(tmpdir(), 'ubi-pack-copyfile-')); + try { + const realTar = Bun.which('tar'); + expect(realTar).toBeTruthy(); + mkdirSync(join(d, 'bin')); + writeFileSync(join(d, 'bin/tar'), `#!/usr/bin/env bash\necho "COPYFILE_DISABLE=${'$'}{COPYFILE_DISABLE:-unset}" >> ${JSON.stringify(join(d, 'tar.log'))}\nexec ${JSON.stringify(realTar)} "$@"\n`); + chmodSync(join(d, 'bin/tar'), 0o755); + const env = { ...process.env, PATH: `${join(d, 'bin')}:${process.env.PATH}`, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + const sh = (cmd: string) => { const r = Bun.spawnSync(['bash', '-c', cmd], { cwd: d, env, timeout: 30_000 }); expect(r.exitCode, r.stderr.toString()).toBe(0); }; + sh(`git init -q repo && cd repo && echo a > a && git add a && git commit -qm one && bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack . > ../repo.tgz`); + sh(`mkdir plain && echo p > plain/p && bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack plain > plain.tgz`); + const lines = readFileSync(join(d, 'tar.log'), 'utf8').trim().split('\n'); + expect(lines.length).toBe(3); + expect(lines.every(l => l === 'COPYFILE_DISABLE=1')).toBe(true); + } finally { rmSync(d, { recursive: true, force: true }); } + }); + test('up records the VM in the scratch file before the create request is sent', () => { const runner = read('scripts/ubicloud/ubi-runner.sh'); const create = runner.indexOf('cli "${args[@]}" >/dev/null 2>"$dir/create.err"'); From 5225e35cc60312ae1b675704e7f2ffdac1ccf40e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 13:26:59 -0800 Subject: [PATCH 03/11] v1.92.42.0 fix(ship): notes-only review passes bind; carry handles large diffs; scoped carry route (GST-430, GST-448, GST-442, GST-444) (#3694) * fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: trim two phrases for headroom with #3662 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship goldens Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251) gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: no test edit skips review; guard packet writes; exclusive attempt state A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: attempt prints gstack's install command; carry checks the product diff outside context paths The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(unattended): carry's scoped-review refusal and what it keeps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals - A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account for the claimed issue and critical counts (GST-430). - The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442). - patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448). - Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the Step 16 --docs-requested route with its --docs-result Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept - deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot dispose of a gate finding the unattended logger forces open (outside-voice#1). - reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses; noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL. - patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT - isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition. - reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore bin/gstack-ship-receipt after the main merge The merge commit b10e2db wrote a truncated copy of the file. This is the three-way merge of both sides with the refused() union: main's codes plus RECEIPT_CARRY_SCOPED_REVIEW. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: trim a Step 3 sentence so the skeleton fits maxSkeletonBytes with train 77 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: keep the no-edit rule in the trimmed Step 3 sentence; regenerate the ship goldens Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 22 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ship-receipt | 63 ++++++-- docs/ship-policy.md | 24 ++- docs/troubleshooting.md | 34 ++++- docs/unattended.md | 5 +- lib/headless-artifacts.ts | 9 +- lib/result-codes-release.ts | 2 +- lib/review-evidence.ts | 26 +++- lib/ship-preflight.ts | 4 + lib/tree-receipt.ts | 7 +- package.json | 2 +- ship/SKILL.md | 26 ++-- ship/SKILL.md.tmpl | 26 ++-- test/fixtures/golden/agy-ship-SKILL.md | 26 ++-- test/fixtures/golden/claude-ship-SKILL.md | 26 ++-- test/fixtures/golden/codex-ship-SKILL.md | 26 ++-- test/fixtures/golden/factory-ship-SKILL.md | 26 ++-- test/review-notes-only-pass.test.ts | 144 ++++++++++++++++++ test/ship-control-flow.test.ts | 12 +- test/ship-receipt-carry-large-diff.test.ts | 90 +++++++++++ test/ship-rereview-triggers.test.ts | 43 +++++- .../ship/gst-430-notes-only-pass.json | 4 + test/train-gate.test.ts | 17 +++ 25 files changed, 556 insertions(+), 112 deletions(-) create mode 100644 test/review-notes-only-pass.test.ts create mode 100644 test/ship-receipt-carry-large-diff.test.ts create mode 100644 test/size-ledger/ship/gst-430-notes-only-pass.json diff --git a/CHANGELOG.md b/CHANGELOG.md index abd27e4eb..c44245e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.92.42.0] - 2026-11-01 + +**A /ship review pass that only left notes now binds at Step 11.5.** A pass whose findings were all fixed, deferred or noted has no open defect, but a driver could still log it as `issues_found`. Step 11.5 then refused it, and the only way out was an extra rebind pass (CAPY-2, qm#2837). When the tree changes during a pass, the refusal now names the start and end trees and the cheapest repair (GST-430). + +**Receipt carry and write handle results PRs with thousands of files (GST-448).** A PR that adds about 8,300 run receipts (harness-evals#147) refused every carry with `git could not compute HEAD's product patch-id: git diff … failed`, and every base move cost it about 3 hours of /ship. The name-only listing ran past a 1 MB output buffer. + +### Fixed +- **`gstack-ship-receipt carry` and `write`** read git's path listings with a 256 MB buffer instead of the 1 MB default, so a large PR's `product_patch_id` is recorded and carried. The product patch-id streams `git diff` through a file under the git directory (`.git/gstack-patch-id`) into `git patch-id --verbatim` and no longer holds the diff in memory; a directory a killed run left is removed after an hour. The per-path walk compares only touched paths that changed at either head or either base, so a 6,000-path PR refuses or carries in seconds. +- **/ship Step 1's carry check takes the scoped-review route (GST-442).** When the base edited context next to a reviewed hunk, `gstack-ship-preflight` now prints `NEXT: carry route`: fix what the base merge broke, commit and push, write the packet, one fix-check, then `carry --scoped-review`, instead of running the full loop. `carry --dry-run` writes no packet and offers this route only when none of carry's other checks would refuse. +- **Step 16 names finish's scoped-review STOP and the `--docs-requested` fix (GST-444).** Stage 2 sends a STOP that names a scoped-review packet to its fix-check and `--context-reviewed` rerun, and stage 3 offers a fix the audit's result named before a re-audit or a named risk. The RECEIPT_CARRY_PRODUCT_CHANGED fix text points to the scoped-review refusal. Trims in Steps 3, 12 and 16 keep ship/SKILL.md under its cap. +- **`gstack-tree-receipt`** fails closed (`EVIDENCE_IDENTITY_UNKNOWN`) when git cannot produce the diff between the gated and current heads, and names git's reason. Before, it read a failed diff as no changes. +- **`gstack-review-log`** records a completed `review` or `review-fix-check` row claimed as `issues_found` as `clean` with zero counts when every finding's logger-stamped action is `fixed` or `auto-fixed`, or `deferred` or `noted` on a finding marked `blocking: false` that is not CRITICAL, and the findings account for the claimed issue and critical counts. A skipped finding, a gate finding left open, a CRITICAL or blocking note, a finding with no action, or an incomplete pass keeps the claimed status. +- **Findings files** (`--findings`) count `auto-fixed` as resolved, the same as `fixed`, and `deferred` or `noted` only below the floor. Their gate rows are stamped like inline ones, so an unattended run's gate stays open whatever its status or disposition says. +- **`review_freshness`** reads a claimed clean row as UNVERIFIED when a finding is a gate left `open`, a CRITICAL or blocking deferral or note, or any other unresolved CRITICAL finding. +- **`review_freshness`** for a pass whose tree moved now reads `content changed during review: the tree moved from to while ran …`, followed by the repair: keep the checkout frozen until every reviewer returns, then rerun on the frozen tree (docs/troubleshooting.md#review-content-changed). + +### Tests +- `test/ship-rereview-triggers.test.ts`: preflight on a founderstar-style adjacent edit prints the scoped-review `NEXT: carry route`, and its dry run writes no packet. `test/ship-control-flow.test.ts`: Step 1.4 and Step 16 stages 2 and 3 name the routes. +- `test/ship-receipt-carry-large-diff.test.ts`: a PR that adds 6,000 receipts under a long directory name writes a receipt with its product patch-id and carries across a base merge. Negative control: one rescored receipt still refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`. +- `test/review-notes-only-pass.test.ts`: a pass with three notes is recorded clean and reads CURRENT. Negative controls: a skipped finding, an open gate finding (also under a caller's resolved status), a CRITICAL or blocking note, a note with no `blocking` field, counts the findings do not cover, a finding with no action, and an incomplete pass keep `issues_found`. A claimed clean row with an open gate, a CRITICAL deferral, or a skipped or action-less CRITICAL finding is not CURRENT, while kept and restored gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT. A `--findings` gate row stays open in an unattended run. Also checks the reason a pass gets when its tree moved. +- `test/ship-receipt-carry-large-diff.test.ts` also: the patch-id file lives under the git directory (an unwritable TMPDIR still carries; an unwritable spot refuses by name), and a stale directory is swept. `test/train-gate.test.ts`: a diff git cannot produce refuses the tree receipt with git's reason. `test/ship-rereview-triggers.test.ts`: a dry run the receipt would refuse anyway names that refusal. + ## [1.92.41.0] - 2026-11-01 **The Ubicloud runner works on macOS and on a project with no VMs (#3680, #3679, #3678).** On a Mac, `gstack-ubi pack` failed, so every `sync`, `run` and `verify` broke after the VM was already created, and `gstack-ubi usage` was a syntax error that also switched off the vCPU quota check. On any machine, an empty Ubicloud project made `reap` and every `up` die. All three were reported by Jared. diff --git a/VERSION b/VERSION index f0625b8df..bb114e4c2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.41.0 +1.92.42.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 72f251a5b..0f21b65e0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.41.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.42.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 1079fd9df..b2d902b65 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -32,6 +32,7 @@ */ 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 { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts'; import { resultLine, resultLineWithDocs } from '../lib/result-codes'; @@ -119,7 +120,7 @@ function jsonArg(raw: string | undefined): Record | undefined { return parsed; } function git(args: string[], cwd?: string): string | undefined { - const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout.trim() : undefined; } function gh(args: string[]): { ok: boolean; out: string; err: string } { @@ -399,12 +400,37 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un */ function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } { const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback; - const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 }); - if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` }; - if (!diff.stdout.trim()) return ''; - const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 }); - if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` }; - return id.stdout.trim().split(/\s+/)[0] ?? ''; + const root = path.resolve(repoRoot, git(['rev-parse', '--git-path', 'gstack-patch-id'], repoRoot)?.trim() || '.git/gstack-patch-id'); + sweepStalePatchDirs(root); + let dir: string; + try { fs.mkdirSync(root, { recursive: true }); dir = fs.mkdtempSync(path.join(root, 'run-')); } catch (e) { return { error: `cannot create a temp file under ${root} (${firstLine((e as Error).message, 'mkdtemp failed')}); make that directory writable` }; } + const file = path.join(dir, 'diff.patch'); + let fd: number | undefined; + try { + const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', `--output=${file}`, from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000 }); + if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` }; + fd = fs.openSync(file, 'r'); + const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 120_000 }); + if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` }; + return id.stdout.trim().split(/\s+/)[0] ?? ''; + } finally { + if (fd !== undefined) fs.closeSync(fd); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** A carry killed mid patch-id (preflight's timeout) skips patchId's `finally`; its product diff is removed by the next run once an hour old. */ +let swept = false; +function sweepStalePatchDirs(root: string): void { + if (swept) return; + swept = true; + const cutoff = Date.now() - 60 * 60 * 1000; + let names: string[] = []; + try { names = fs.readdirSync(root).filter(n => n.startsWith('run-')); } catch { return; } + for (const n of names) { + const p = path.join(root, n); + try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true }); } catch { /* another run removed it */ } + } } /** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */ @@ -581,6 +607,7 @@ function cmdCarry(rest: string[], json: boolean): number { const reviewedBase = receipt.base.replace(/^origin\//, ''); const retarget = reviewedBase !== base.replace(/^origin\//, ''); let plan: ReturnType; + let scopedDryRun: string | undefined; if (!ancestor) { // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded. const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' }; @@ -620,10 +647,14 @@ function cmdCarry(rest: string[], json: boolean): number { else if (productFrom !== productHead && !retarget) { // planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the // branch itself touched against the base at either head: a base merge brings in every path the base changed, none of - // which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks + // which can differ between the two base diffs. A touched path with the same blob at both heads and at both bases has + // the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks // (lib/ship-patch.ts) needs a scoped review instead of the loop. const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }; - for (const p of [...touched].sort()) { + const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)]; + if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); + const [movedHead, movedBase] = moved as Set[]; + for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); if (cls !== 'product') continue; @@ -644,7 +675,10 @@ function cmdCarry(rest: string[], json: boolean): number { if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`)); if (packetBody && !changedProduct.length) { const record = flag(rest, '--scoped-review'); - if (!record) { + if (!record && rest.includes('--dry-run')) { + scopedDryRun = `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: carry without --dry-run writes the scoped-review packet for one fix-check reviewer, then carry --scoped-review ; nothing written`; + scopedReview = `base context moved (scoped review pending: ${contextPaths.join(', ')})`; + } else if (!record) { const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot); const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`); try { @@ -655,9 +689,11 @@ function cmdCarry(rest: string[], json: boolean): number { } return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review ({"head":"${head}","paths":[…],"reviewer":"","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`); } - const review = readScopedReview(record, { head }, contextPaths); - if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`); - scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`; + if (record) { + const review = readScopedReview(record, { head }, contextPaths); + if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`); + scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`; + } } const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. @@ -698,6 +734,7 @@ function cmdCarry(rest: string[], json: boolean): number { 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); const line = receiptSummaryLine(carried); + if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun); if (rest.includes('--dry-run')) { process.stderr.write(line + '\n'); process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e7d658833..7cd24136a 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1345,7 +1345,12 @@ falling back to the whitespace-stripping default, and a `git diff` or patch-ids differ, the refusal names up to `CARRY_NAMED_PATHS` (20) product paths whose patch against the base changed, walking only the paths the branch itself touched at either head (a base merge brings in every path the base -changed, none of which can differ between the two base diffs). Product means every changed path that is not test code (`test/**`, +changed, none of which can differ between the two base diffs), and of those +only the paths that changed between the two heads or the two bases (a path with +the same blob on both sides has the same patch). The product diff streams +through a file under the git directory (`.git/gstack-patch-id`) into +`git patch-id --verbatim`, so a PR with thousands of paths is not held in +memory; a directory a killed run left there is removed after an hour. Product means every changed path that is not test code (`test/**`, `spec/**`, `e2e/**`, `**/*.test.`, `**/*.spec.` and the other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each @@ -1383,7 +1388,11 @@ along the receipt's own history, `rewritten` once any hop was a rebase; `CARRY: carried from to ()` and the `GSTACK_RESULT: skill=ship status=complete` line, and its summary line ends with `carried_from=`. `CARRY: current` means the receipt already names HEAD; -`--dry-run` prints `CARRY: would carry …` and writes nothing. +`--dry-run` prints `CARRY: would carry …` and writes nothing. When the only +difference is base context next to the branch's unchanged hunks and none of +carry's other checks would refuse, `--dry-run` refuses +`RECEIPT_CARRY_SCOPED_REVIEW` without writing the packet; carry without +`--dry-run` writes it. A changed product path, or a product patch-id that differs, is [`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed) @@ -1416,7 +1425,12 @@ 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 --base --dry-run`. `CARRY: would - carry` is eligibility only; a refusal continues the full ship. + carry` is eligibility only; a refusal continues the full ship, except + `RECEIPT_CARRY_SCOPED_REVIEW`, for which `gstack-ship-preflight` prints + `NEXT: carry route`: fix what the base merge broke, commit and push, run carry + without `--dry-run` to write the packet, have one fix-check reviewer read it, + then carry with `--scoped-review ` and `--pregate`. A blocking + finding 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 @@ -1706,7 +1720,9 @@ hand edit), and the replacement the current one, so a dependency pin that happens to equal the old version is `changed`; `stamp_patterns` templates count as stamp forms too). With `stamp_files` set, a file outside it is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever -its diff. Any other byte is a change. +its diff. Any other byte is a change. A diff git cannot produce between the two +heads fails closed with `EVIDENCE_IDENTITY_UNKNOWN` and git's reason; it never +reads as no change. Equivalence is decided per gate. A lane in `stamp_sensitive_gates` always `rerun`s; a verified lane is `reusable` only when the tree is same-modulo-stamps diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 80496ac85..b63323749 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -550,6 +550,25 @@ local `origin/` at ``. **Fix.** Run `git fetch origin ` outside the sandbox and rerun /review if the base has moved. + +### `review_freshness: STALE content changed during review: the tree moved from to while ran ...` / **Review records missing or mismatched** + +**Meaning.** The working tree changed between the pass's `gstack-review-log --start` and +its logged row: an edit, commit or merge landed while reviewers were still reading. The +reviewers may not have seen that change, so the row's `review_binding.state` is `changed`. +Step 11.5 cannot bind it and inserts `9 → 10 → 11 → 11.5`. Logging the same results again +cannot repair it. + +**Fix.** Keep the checkout frozen (no edits, commits, merges or pushes) from a pass's start +until every reviewer in it has returned and its row is logged. If the mid-pass change was +your own fix to that pass's findings, log the pass with `"converged":false` and the fix +as `"action":"fixed"`. `gstack-review-plan` then picks the repeat: one fix-check reading +the touched hunks when every fix is below the blocking floor, and 11.5 accepts that +fix-check record as Step 9's. Rerun Step 11's native pass on the same frozen tree. + +**Expected result.** `gstack-review-read` shows both records at one tree with +`review_freshness` `CURRENT`, and Step 11.5 continues to Step 12. + ### `gstack-wtree: cannot create a temp index under ; set TMPDIR to a writable directory` / `gstack-review-log: cannot create a private object directory under ; no working-tree fingerprint. Set TMPDIR to a writable directory.` @@ -3140,9 +3159,14 @@ or pass `--force-gate ` in `/ship` to run the full gate again. **Meaning.** The bundle (or ledger record) lacks a committed tree id, runtime versions, lockfile hashes or the command hash, so no reuse decision can rest on it. Imported assertions without verification count as unknown too. +`gstack-tree-receipt` also fails with this code when git cannot produce the +diff between the gated and current heads (a missing object, a timeout): the +error names git's reason, and a failed diff is never read as no change. **Fix.** Rebuild the bundle on the gate machine after the lanes ran on a -committed head: `gstack-evidence bundle --out --label ...`. +committed head: `gstack-evidence bundle --out --label ...`. For +the tree receipt, fix what git's reason names (fetch a missing head with +`git fetch origin `), then rerun. ### `EVIDENCE_BUNDLE_INVALID` @@ -5530,7 +5554,9 @@ compare (a receipt written before `head_tree`, whose head is not in this clone), the PR was retargeted to the base after its stack parent landed but the receipt records no `product_patch_id` and `origin/` no longer resolves, so the reviewed range cannot be recomputed, or git could not -compare the two product diffs or read the working tree's status. It also refuses when an owner exception the receipt records +compare the two product diffs (including when carry cannot create its temp +directory under `.git/gstack-patch-id`; the message names it) or read the +working tree's status. It also refuses when an owner exception the receipt records no longer stands, and, for a receipt from a gstack that records review depth, when HEAD needs a deeper review than the receipt records, `--head` is not the checked-out HEAD, or the receipt records `gstack_version` without @@ -5623,6 +5649,10 @@ the tree it is bound to. Finish also stops this way for test code changed since review with no gate edit (`tests=N`): the packet holds those test hunks, and the reviewer checks that each test still tests what it did. A packet that cannot be written (an unwritable `.git`) is a STOP or `RECEIPT_CARRY_REFUSED` naming the path. +`carry --dry-run` reports this code without writing the packet, and only when +none of carry's other checks would refuse; carry without `--dry-run` writes it. +In /ship Step 1, `gstack-ship-preflight` prints the route as `NEXT: carry +route (Step 1.4)`. ### `RECEIPT_PROSE_MISMATCH` diff --git a/docs/unattended.md b/docs/unattended.md index be924d334..398b9fd59 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -240,8 +240,9 @@ now names HEAD with a `carry` object holding the `git range-diff` evidence, and counters are kept, because the reviewed product diff is unchanged or its only difference passed a scoped review. `CARRY: current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)` means the base edited context -next to reviewed hunks: one fix-check reviewer reads the packet the refusal -names, and a clean record reruns carry with `--scoped-review ` +next to reviewed hunks. Carry without `--dry-run` writes the packet the +refusal names (a dry run writes none), one fix-check reviewer reads it, and a +clean record reruns carry with `--scoped-review ` ([troubleshooting](troubleshooting.md#receipt-carry-scoped-review)). `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`, `(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product diff changed, or history was rewritten onto a diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index be4708a97..2bd2a2dc1 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -551,9 +551,16 @@ export interface DerivedReviewStatus { findings_total: number; findings_open: number; findings_resolved: number; } export function isOpenFinding(row: Record): boolean { + if (row.action === 'open') return true; if (typeof row.disposition === 'string') return !RESOLVED.includes(row.disposition); if (typeof row.status === 'string') return !['resolved', 'fixed', 'closed'].includes(row.status); - return row.action !== 'fixed'; + return isOpenByAction(row); +} +/** A finding's `action` alone: fixed and auto-fixed resolve it; deferred and noted resolve only one marked `blocking: false` that is not CRITICAL (review policy's floor). */ +export function isOpenByAction(row: Record): boolean { + if (row.action === 'fixed' || row.action === 'auto-fixed') return false; + if (row.action === 'deferred' || row.action === 'noted') return row.blocking !== false || isCriticalFinding(row); + return true; } export function isCriticalFinding(row: Record): boolean { return typeof row.severity === 'string' && row.severity.toLowerCase() === 'critical'; diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 9dc34904f..9c6496756 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -383,7 +383,7 @@ export const RELEASE_RESULT_CODES = { RECEIPT_CARRY_PRODUCT_CHANGED: { anchor: 'receipt-carry-product-changed', summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD', - fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged', + fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged; base context next to unchanged reviewed hunks is RECEIPT_CARRY_SCOPED_REVIEW instead (one scoped fix-check, no /ship)', }, RECEIPT_CARRY_SCOPED_REVIEW: { anchor: 'receipt-carry-scoped-review', diff --git a/lib/review-evidence.ts b/lib/review-evidence.ts index 777971f50..60dc4e669 100644 --- a/lib/review-evidence.ts +++ b/lib/review-evidence.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { deriveReviewStatus, readJsonl } from './headless-artifacts'; +import { deriveReviewStatus, isCriticalFinding, isOpenByAction, readJsonl } from './headless-artifacts'; import { resultLine, type ResultCodeName } from './result-codes'; const DIFF_REVIEWS = new Set(['review', 'review-fix-check', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']); @@ -248,11 +248,12 @@ const DERIVED_KEYS = ['status', 'unresolved', 'issues_found', 'critical_gaps'] a * severity + disposition/action). A claimed value that disagrees rejects the * row with REVIEW_STATUS_MISMATCH; the file is the record, never the claim. */ -export function deriveFromFindings(rec: Record, findingsPath: string): Record { +export function deriveFromFindings(rec: Record, findingsPath: string, sessionKind?: string): Record { let read; try { read = readJsonl(findingsPath); } catch (e: any) { throw new ReviewLogError('ARTIFACT_MISSING', resultLine('review-log', 'ARTIFACT_MISSING', `${findingsPath}: ${e.message}`)); } if (read.errors.length) throw new ReviewLogError('ARTIFACT_MALFORMED_JSONL', resultLine('review-log', 'ARTIFACT_MALFORMED_JSONL', `${read.errors[0]!.path}: ${read.errors[0]!.message}`)); + stampGateFindings(read.rows, sessionKind); const derived = deriveReviewStatus(read.rows); const mismatches = DERIVED_KEYS.filter(key => rec[key] !== undefined && rec[key] !== derived[key]) .map(key => `claimed ${key} ${JSON.stringify(rec[key])}, findings file has ${JSON.stringify(derived[key])}`); @@ -266,11 +267,20 @@ export function bindReview(rec: Record, token: string, env = proces for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key]; // The findings file is validated BEFORE the start token is consumed below: // a rejected row must leave the token for the retry. - rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' }; + rec = findingsPath ? deriveFromFindings(rec, findingsPath, env.GSTACK_STAMP_SESSION_KIND) : { ...rec, status_source: 'claimed' }; if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL; if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE; if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true'; stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND); + // A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army + // item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the + // logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue. + if (!findingsPath && (rec.skill === 'review' || rec.skill === 'review-fix-check') && rec.status === 'issues_found' && rec.completed === true && + Array.isArray(rec.findings) && rec.findings.length > 0 && rec.findings.length >= Number(rec.issues_found ?? 0) && + rec.findings.filter(f => record(f) && isCriticalFinding(f)).length >= Number(rec.critical ?? 0) && + rec.findings.every(f => record(f) && typeof f.action === 'string' && !isOpenByAction(f))) { + Object.assign(rec, { status: 'clean', issues_found: 0, critical: 0, informational: 0 }); + } if (!DIFF_REVIEWS.has(rec.skill)) { if (env.GSTACK_STAMP_WTREE) rec.wtree = env.GSTACK_STAMP_WTREE; return rec; @@ -326,7 +336,10 @@ export function reviewFreshness(rec: Record, currentWtree: string): if (!DIFF_REVIEWS.has(rec.skill)) return; if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' }; const binding = rec.review_binding; - if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' }; + if (binding?.state === 'changed') { + const short = (w: unknown) => typeof w === 'string' ? w.slice(0, 12) : 'unknown'; + return { status: 'STALE', reason: `content changed during review: the tree moved from ${short(binding.start_wtree)} to ${short(binding.end_wtree)} while ${rec.skill} ran (an edit, commit or merge landed before its row was logged), so the record cannot bind. Repair: freeze the checkout until every reviewer returns, then rerun the steps 11.5 names on the frozen tree; when the edit was your own fix, log it as a fix so gstack-review-plan picks the repeat depth (docs/troubleshooting.md#review-content-changed)` }; + } if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' }; if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true || !rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) { @@ -340,7 +353,10 @@ export function reviewFreshness(rec: Record, currentWtree: string): // issues_found, and the legacy `critical` and Codex counters stay as they were. const open = typeof rec.findings_open === 'number' ? rec.findings_open : rec.issues_found; if (rec.status !== 'clean' || open > 0 || rec.critical > 0 || - (rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0))) { + (rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0)) || + (Array.isArray(rec.findings) && rec.findings.some(f => record(f) && (f.action === 'open' || + ((f.action === 'deferred' || f.action === 'noted') && (f.blocking === true || isCriticalFinding(f))) || + (!f.gate && isCriticalFinding(f) && isOpenByAction(f)))))) { return { status: 'UNVERIFIED', reason: 'review has unresolved findings or did not finish clean' }; } return { status: 'CURRENT', reason: 'completed clean pass on unchanged content' }; diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index dd1f51d5e..ed1f48f50 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -53,6 +53,10 @@ function integrate(cwd: string, base: string, r: Report): boolean { r.next(`carry route (Step 1.4): fix what the base merge broke (CHANGELOG, size caps), gstack-pregate --allow-repo-commands, then gstack-ship-receipt carry --pr ${n} --base ${base} --pregate .gstack/tmp/pregate.json`); return false; } + if (line === 'CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)') { + r.next(`carry route (Step 1.4): the base edited context next to reviewed hunks: fix what the base merge broke (CHANGELOG, size caps), commit and push; then gstack-ship-receipt carry --pr ${n} --base ${base} writes the packet for that head; one fix-check reviewer reads it; gstack-pregate --allow-repo-commands, then carry again with --scoped-review --pregate .gstack/tmp/pregate.json; a blocking finding runs Steps 2-16`); + return false; + } } else r.add('PR: none open'); const apple = git(cwd, ['ls-files', '--', '*.xcodeproj/*', '*.xcworkspace/*', 'Package.swift']).stdout.split('\n').filter(Boolean).map(p => p.replace(/(\.xcodeproj|\.xcworkspace)\/.*$/, '$1')); if (apple.length) r.add(`APPLE: ${short([...new Set(apple)], 3)}`).next('Step 0.9: for an App Store/TestFlight ask, confirm the app target and follow apple-release.md; otherwise ignore'); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index de94d0004..c946ffa71 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -170,7 +170,12 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; const versionRel = o.versionRel ?? ('rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION'); const oldV = 'version' in gv ? gv.version : null; const newV = 'version' in cv ? cv.version : null; - const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? ''; + const d = spawnSync('git', ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); + if (d.status !== 0) { + const why = (d.error as NodeJS.ErrnoException | undefined)?.code ?? (d.signal ? `killed by ${d.signal}` : (d.stderr ?? '').trim().split('\n')[0]?.slice(0, 200) || `exit ${d.status}`); + return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' }; + } + const diffText = d.stdout.replace(/\n$/, ''); const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); diff --git a/package.json b/package.json index 7b2485382..8acc970b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.41", + "version": "1.92.42", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/SKILL.md b/ship/SKILL.md index 32de83e7d..c53c541b5 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line. `CARRY: would carry` (base merge committed, same product patch-id): skip review, fix what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and - ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16. + ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no + `NEXT: carry route` re-runs Steps 2–16. 5. Display historical readiness using the dashboard below, then finish Step 1. Prior CLEAR reviews or dashboard skips never replace Step 9's gates. @@ -457,7 +458,7 @@ not web services already deployed. The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). -**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. +**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight. **Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base ` (docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes @@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun - **FRESH** → use the recorded level or choose it in item 2, then check the queue and write. - **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing, use the first changed component from `baseVersion` to `currentVersion` - (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3, - not another automatic bump. + (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again. - **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify. Success follows ALREADY_BUMPED, including its queue check; failure stops. Repair alone never re-bumps. @@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun **MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks). AskUserQuestion: recommended level with rationale, smaller level, or cancel. Wait; cancel stops before release writes or push and preserves existing work. - Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number - forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. + Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level. 3. **Queue-aware pick:** ```bash @@ -696,7 +695,8 @@ Classify the comparison in this order: Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step. This repair excludes Step 14.5 because the rebuild can change generated docs. Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides - documentation freshness. Further repairs use the same work list. + documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet + instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item. 2. **Only authored docs or release metadata changed:** Keep Step 8's original child report and counts. Recheck affected plan items using their recorded verification and append current evidence to the invocation record. If a classification is no @@ -719,6 +719,7 @@ audit or risk decision never qualifies. Report changed inputs, blockers and attempts used: +- **A fix the audit's result named:** rerun finish with `--docs-requested --docs-result `. - **An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count. Validate the outcome before Step 15, then restart Step 16 stage 1. @@ -756,9 +757,9 @@ every allowed edit is release metadata: |---|---| | FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. | | STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label -- ''`, read the result and recheck once. Handle failures as described below. | -| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | +| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | | `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. | -| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | +| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | No test lanes: require Step 5's explicit untested-scope approval for final content, or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1. @@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass. **New, changed or unwaived test failure:** STOP publication. Run Steps 5–15, starting with Step 5's triage, then return to Step 16 stage 1. This recovery also -applies if a failure appears while reporting in stage 5. Reentry to Step 14.5 -keeps its existing audit count; it does not authorize a third attempt. +covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and +does not authorize a third attempt. ### 5. Report, then push @@ -777,8 +778,7 @@ Preserve unrelated user files. Paste build/docs/test results. Reuse waivers only for the same verified pre-existing failures and approved scope; cite the actual approval and failing -counts, never FRESH or all-green. A new, changed or unwaived test failure uses -stage 4's recovery before publication. +counts, never FRESH or all-green. Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and its records: diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 4b9491940..45a5c5232 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -142,7 +142,8 @@ act on every `STOP:` and `NEXT:` line. `CARRY: would carry` (base merge committed, same product patch-id): skip review, fix what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and - ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16. + ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no + `NEXT: carry route` re-runs Steps 2–16. 5. Display historical readiness using the dashboard below, then finish Step 1. Prior CLEAR reviews or dashboard skips never replace Step 9's gates. @@ -187,7 +188,7 @@ not web services already deployed. The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). -**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. +**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight. **Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base ` (docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes @@ -252,8 +253,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun - **FRESH** → use the recorded level or choose it in item 2, then check the queue and write. - **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing, use the first changed component from `baseVersion` to `currentVersion` - (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3, - not another automatic bump. + (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again. - **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify. Success follows ALREADY_BUMPED, including its queue check; failure stops. Repair alone never re-bumps. @@ -272,8 +272,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun **MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks). AskUserQuestion: recommended level with rationale, smaller level, or cancel. Wait; cancel stops before release writes or push and preserves existing work. - Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number - forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. + Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level. 3. **Queue-aware pick:** ```bash @@ -417,7 +416,8 @@ Classify the comparison in this order: Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step. This repair excludes Step 14.5 because the rebuild can change generated docs. Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides - documentation freshness. Further repairs use the same work list. + documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet + instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item. 2. **Only authored docs or release metadata changed:** Keep Step 8's original child report and counts. Recheck affected plan items using their recorded verification and append current evidence to the invocation record. If a classification is no @@ -440,6 +440,7 @@ audit or risk decision never qualifies. Report changed inputs, blockers and attempts used: +- **A fix the audit's result named:** rerun finish with `--docs-requested --docs-result `. - **An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count. Validate the outcome before Step 15, then restart Step 16 stage 1. @@ -477,9 +478,9 @@ every allowed edit is release metadata: |---|---| | FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. | | STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label -- ''`, read the result and recheck once. Handle failures as described below. | -| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | +| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | | `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. | -| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | +| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | No test lanes: require Step 5's explicit untested-scope approval for final content, or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1. @@ -487,8 +488,8 @@ Report the gap, never FRESH; builds must pass. **New, changed or unwaived test failure:** STOP publication. Run Steps 5–15, starting with Step 5's triage, then return to Step 16 stage 1. This recovery also -applies if a failure appears while reporting in stage 5. Reentry to Step 14.5 -keeps its existing audit count; it does not authorize a third attempt. +covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and +does not authorize a third attempt. ### 5. Report, then push @@ -498,8 +499,7 @@ Preserve unrelated user files. Paste build/docs/test results. Reuse waivers only for the same verified pre-existing failures and approved scope; cite the actual approval and failing -counts, never FRESH or all-green. A new, changed or unwaived test failure uses -stage 4's recovery before publication. +counts, never FRESH or all-green. Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and its records: diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index b3aa51ef4..431fd2735 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line. `CARRY: would carry` (base merge committed, same product patch-id): skip review, fix what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and - ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16. + ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no + `NEXT: carry route` re-runs Steps 2–16. 5. Display historical readiness using the dashboard below, then finish Step 1. Prior CLEAR reviews or dashboard skips never replace Step 9's gates. @@ -457,7 +458,7 @@ not web services already deployed. The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). -**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. +**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight. **Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base ` (docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes @@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun - **FRESH** → use the recorded level or choose it in item 2, then check the queue and write. - **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing, use the first changed component from `baseVersion` to `currentVersion` - (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3, - not another automatic bump. + (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again. - **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify. Success follows ALREADY_BUMPED, including its queue check; failure stops. Repair alone never re-bumps. @@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun **MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks). AskUserQuestion: recommended level with rationale, smaller level, or cancel. Wait; cancel stops before release writes or push and preserves existing work. - Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number - forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. + Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level. 3. **Queue-aware pick:** ```bash @@ -696,7 +695,8 @@ Classify the comparison in this order: Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step. This repair excludes Step 14.5 because the rebuild can change generated docs. Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides - documentation freshness. Further repairs use the same work list. + documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet + instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item. 2. **Only authored docs or release metadata changed:** Keep Step 8's original child report and counts. Recheck affected plan items using their recorded verification and append current evidence to the invocation record. If a classification is no @@ -719,6 +719,7 @@ audit or risk decision never qualifies. Report changed inputs, blockers and attempts used: +- **A fix the audit's result named:** rerun finish with `--docs-requested --docs-result `. - **An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count. Validate the outcome before Step 15, then restart Step 16 stage 1. @@ -756,9 +757,9 @@ every allowed edit is release metadata: |---|---| | FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. | | STALE/MISSING: changed content, command or age, or no proven run | Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-evidence run --label -- ''`, read the result and recheck once. Handle failures as described below. | -| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | +| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | | `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. | -| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | +| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | No test lanes: require Step 5's explicit untested-scope approval for final content, or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1. @@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass. **New, changed or unwaived test failure:** STOP publication. Run Steps 5–15, starting with Step 5's triage, then return to Step 16 stage 1. This recovery also -applies if a failure appears while reporting in stage 5. Reentry to Step 14.5 -keeps its existing audit count; it does not authorize a third attempt. +covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and +does not authorize a third attempt. ### 5. Report, then push @@ -777,8 +778,7 @@ Preserve unrelated user files. Paste build/docs/test results. Reuse waivers only for the same verified pre-existing failures and approved scope; cite the actual approval and failing -counts, never FRESH or all-green. A new, changed or unwaived test failure uses -stage 4's recovery before publication. +counts, never FRESH or all-green. Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and its records: diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 32de83e7d..c53c541b5 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line. `CARRY: would carry` (base merge committed, same product patch-id): skip review, fix what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and - ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16. + ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no + `NEXT: carry route` re-runs Steps 2–16. 5. Display historical readiness using the dashboard below, then finish Step 1. Prior CLEAR reviews or dashboard skips never replace Step 9's gates. @@ -457,7 +458,7 @@ not web services already deployed. The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). -**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. +**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight. **Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base ` (docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes @@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun - **FRESH** → use the recorded level or choose it in item 2, then check the queue and write. - **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing, use the first changed component from `baseVersion` to `currentVersion` - (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3, - not another automatic bump. + (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again. - **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify. Success follows ALREADY_BUMPED, including its queue check; failure stops. Repair alone never re-bumps. @@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun **MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks). AskUserQuestion: recommended level with rationale, smaller level, or cancel. Wait; cancel stops before release writes or push and preserves existing work. - Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number - forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. + Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level. 3. **Queue-aware pick:** ```bash @@ -696,7 +695,8 @@ Classify the comparison in this order: Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step. This repair excludes Step 14.5 because the rebuild can change generated docs. Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides - documentation freshness. Further repairs use the same work list. + documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet + instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item. 2. **Only authored docs or release metadata changed:** Keep Step 8's original child report and counts. Recheck affected plan items using their recorded verification and append current evidence to the invocation record. If a classification is no @@ -719,6 +719,7 @@ audit or risk decision never qualifies. Report changed inputs, blockers and attempts used: +- **A fix the audit's result named:** rerun finish with `--docs-requested --docs-result `. - **An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count. Validate the outcome before Step 15, then restart Step 16 stage 1. @@ -756,9 +757,9 @@ every allowed edit is release metadata: |---|---| | FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. | | STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label -- ''`, read the result and recheck once. Handle failures as described below. | -| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | +| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | | `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. | -| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | +| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | No test lanes: require Step 5's explicit untested-scope approval for final content, or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1. @@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass. **New, changed or unwaived test failure:** STOP publication. Run Steps 5–15, starting with Step 5's triage, then return to Step 16 stage 1. This recovery also -applies if a failure appears while reporting in stage 5. Reentry to Step 14.5 -keeps its existing audit count; it does not authorize a third attempt. +covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and +does not authorize a third attempt. ### 5. Report, then push @@ -777,8 +778,7 @@ Preserve unrelated user files. Paste build/docs/test results. Reuse waivers only for the same verified pre-existing failures and approved scope; cite the actual approval and failing -counts, never FRESH or all-green. A new, changed or unwaived test failure uses -stage 4's recovery before publication. +counts, never FRESH or all-green. Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and its records: diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index e3b16338d..44868bb8a 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -334,7 +334,8 @@ act on every `STOP:` and `NEXT:` line. `CARRY: would carry` (base merge committed, same product patch-id): skip review, fix what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and - ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16. + ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no + `NEXT: carry route` re-runs Steps 2–16. 5. Display historical readiness using the dashboard below, then finish Step 1. Prior CLEAR reviews or dashboard skips never replace Step 9's gates. @@ -456,7 +457,7 @@ not web services already deployed. The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). -**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. +**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight. **Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base ` (docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes @@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun - **FRESH** → use the recorded level or choose it in item 2, then check the queue and write. - **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing, use the first changed component from `baseVersion` to `currentVersion` - (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3, - not another automatic bump. + (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again. - **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify. Success follows ALREADY_BUMPED, including its queue check; failure stops. Repair alone never re-bumps. @@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun **MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks). AskUserQuestion: recommended level with rationale, smaller level, or cancel. Wait; cancel stops before release writes or push and preserves existing work. - Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number - forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. + Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level. 3. **Queue-aware pick:** ```bash @@ -698,7 +697,8 @@ Classify the comparison in this order: Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step. This repair excludes Step 14.5 because the rebuild can change generated docs. Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides - documentation freshness. Further repairs use the same work list. + documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet + instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item. 2. **Only authored docs or release metadata changed:** Keep Step 8's original child report and counts. Recheck affected plan items using their recorded verification and append current evidence to the invocation record. If a classification is no @@ -721,6 +721,7 @@ audit or risk decision never qualifies. Report changed inputs, blockers and attempts used: +- **A fix the audit's result named:** rerun finish with `--docs-requested --docs-result `. - **An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count. Validate the outcome before Step 15, then restart Step 16 stage 1. @@ -759,9 +760,9 @@ $GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '' --la |---|---| | FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. | | STALE/MISSING: changed content, command or age, or no proven run | Run `$GSTACK_ROOT/bin/gstack-evidence run --label -- ''`, read the result and recheck once. Handle failures as described below. | -| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | +| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | | `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. | -| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | +| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | No test lanes: require Step 5's explicit untested-scope approval for final content, or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1. @@ -769,8 +770,8 @@ Report the gap, never FRESH; builds must pass. **New, changed or unwaived test failure:** STOP publication. Run Steps 5–15, starting with Step 5's triage, then return to Step 16 stage 1. This recovery also -applies if a failure appears while reporting in stage 5. Reentry to Step 14.5 -keeps its existing audit count; it does not authorize a third attempt. +covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and +does not authorize a third attempt. ### 5. Report, then push @@ -780,8 +781,7 @@ Preserve unrelated user files. Paste build/docs/test results. Reuse waivers only for the same verified pre-existing failures and approved scope; cite the actual approval and failing -counts, never FRESH or all-green. A new, changed or unwaived test failure uses -stage 4's recovery before publication. +counts, never FRESH or all-green. Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and its records: diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 148a7b6fb..dc9aa3ebf 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line. `CARRY: would carry` (base merge committed, same product patch-id): skip review, fix what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and - ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16. + ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no + `NEXT: carry route` re-runs Steps 2–16. 5. Display historical readiness using the dashboard below, then finish Step 1. Prior CLEAR reviews or dashboard skips never replace Step 9's gates. @@ -458,7 +459,7 @@ not web services already deployed. The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). -**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. +**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight. **Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base ` (docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes @@ -531,8 +532,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun - **FRESH** → use the recorded level or choose it in item 2, then check the queue and write. - **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing, use the first changed component from `baseVersion` to `currentVersion` - (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3, - not another automatic bump. + (major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again. - **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify. Success follows ALREADY_BUMPED, including its queue check; failure stops. Repair alone never re-bumps. @@ -551,8 +551,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun **MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks). AskUserQuestion: recommended level with rationale, smaller level, or cancel. Wait; cancel stops before release writes or push and preserves existing work. - Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number - forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. + Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level. 3. **Queue-aware pick:** ```bash @@ -700,7 +699,8 @@ Classify the comparison in this order: Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step. This repair excludes Step 14.5 because the rebuild can change generated docs. Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides - documentation freshness. Further repairs use the same work list. + documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet + instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item. 2. **Only authored docs or release metadata changed:** Keep Step 8's original child report and counts. Recheck affected plan items using their recorded verification and append current evidence to the invocation record. If a classification is no @@ -723,6 +723,7 @@ audit or risk decision never qualifies. Report changed inputs, blockers and attempts used: +- **A fix the audit's result named:** rerun finish with `--docs-requested --docs-result `. - **An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count. Validate the outcome before Step 15, then restart Step 16 stage 1. @@ -761,9 +762,9 @@ $GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '' --la |---|---| | FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. | | STALE/MISSING: changed content, command or age, or no proven run | Run `$GSTACK_ROOT/bin/gstack-evidence run --label -- ''`, read the result and recheck once. Handle failures as described below. | -| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | +| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. | | `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. | -| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | +| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. | No test lanes: require Step 5's explicit untested-scope approval for final content, or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1. @@ -771,8 +772,8 @@ Report the gap, never FRESH; builds must pass. **New, changed or unwaived test failure:** STOP publication. Run Steps 5–15, starting with Step 5's triage, then return to Step 16 stage 1. This recovery also -applies if a failure appears while reporting in stage 5. Reentry to Step 14.5 -keeps its existing audit count; it does not authorize a third attempt. +covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and +does not authorize a third attempt. ### 5. Report, then push @@ -782,8 +783,7 @@ Preserve unrelated user files. Paste build/docs/test results. Reuse waivers only for the same verified pre-existing failures and approved scope; cite the actual approval and failing -counts, never FRESH or all-green. A new, changed or unwaived test failure uses -stage 4's recovery before publication. +counts, never FRESH or all-green. Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and its records: diff --git a/test/review-notes-only-pass.test.ts b/test/review-notes-only-pass.test.ts new file mode 100644 index 000000000..fb58821e6 --- /dev/null +++ b/test/review-notes-only-pass.test.ts @@ -0,0 +1,144 @@ +/** + * GST-430 (CAPY-2, qm#2837): a /ship review pass whose only findings were notes or deferrals has no + * open defect, so the logger records it clean with issues_found 0 and Step 11.5 can bind it. A finding + * still open (skipped, gate open, no action) keeps the claimed status. A row whose tree moved during + * the pass names that cause and the cheapest repair. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { isOpenFinding } from '../lib/headless-artifacts'; +import { reviewFreshness } from '../lib/review-evidence'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-review-log'); + +let home: string; +beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-notes-only-')); }); +afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); }); + +function run(args: string[]) { + const r = spawnSync(BIN, args, { cwd: ROOT, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_HOME: home, GSTACK_SESSION_KIND: 'unattended' } }); + return { code: r.status, err: r.stderr, out: r.stdout }; +} +function lastRow(): any { + const projects = path.join(home, 'projects'); + const rows = fs.readdirSync(projects).flatMap(slug => fs.readdirSync(path.join(projects, slug)).filter(f => f.endsWith('-reviews.jsonl')) + .flatMap(f => fs.readFileSync(path.join(projects, slug, f), 'utf8').trim().split('\n').map(l => JSON.parse(l)))); + return rows.at(-1); +} +const note = (i: number, action: string, extra: Record = {}) => + ({ fingerprint: `lib/a.ts:${i}:testing`, severity: 'INFORMATIONAL', action, locus: 'diff', kind: 'test-gap', blocking: false, raised_by: 'specialist-batched', ...extra }); +const pass = (findings: unknown[], extra: Record = {}) => JSON.stringify({ + skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', status: 'issues_found', issues_found: findings.length, critical: 0, + informational: findings.length, quality_score: 9, specialists: {}, findings, completed: true, converged: true, cycles: 0, pass: 2, + review_scope: 'frozen', range_base: null, ...extra, +}); + +describe('a notes-only /ship review pass (GST-430)', () => { + // Value: protects=Step 11.5 binding a pass whose 3 notes are not defects; catches=a notes-only pass logged as + // issues_found and left UNVERIFIED, which cost CAPY-2 an extra rebind pass. + test('three noted findings are recorded clean with 0 issues and read CURRENT', () => { + const token = run(['--start', 'review']).out.trim(); + const r = run([pass([note(1, 'noted'), note(2, 'noted'), note(3, 'deferred', { issue: null })]), '--finish', token]); + expect(r.code).toBe(0); + const row = lastRow(); + expect([row.status, row.issues_found, row.critical, row.informational, row.review_binding.state]).toEqual(['clean', 0, 0, 0, 'verified']); + expect(reviewFreshness(row, row.wtree)).toEqual({ status: 'CURRENT', reason: 'completed clean pass on unchanged content' }); + }); + + // Value: protects=open defects still blocking 11.5; catches=a normalization that clears a skipped finding, a + // gate finding the unattended logger forced open, a finding with no action, or an incomplete pass. + test('negative controls keep issues_found', () => { + const cases: Array<[string, string]> = [ + ['skipped', pass([note(1, 'noted'), note(2, 'skipped')])], + ['gate open', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r' })])], + ['no action', pass([note(1, 'noted'), { fingerprint: 'lib/a.ts:9:testing', severity: 'CRITICAL' }])], + ['incomplete', pass([note(1, 'noted')], { completed: false })], + ['caller status over a stamped-open gate', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL', status: 'resolved' })])], + ['critical noted', pass([note(1, 'noted'), note(2, 'noted', { severity: 'CRITICAL' })])], + ['blocking deferred', pass([note(1, 'noted'), note(2, 'deferred', { blocking: true, issue: null })])], + ['counts beyond the findings', pass([note(1, 'noted')], { issues_found: 2, critical: 1 })], + ['noted with no blocking field', pass([note(1, 'noted'), note(2, 'noted', { blocking: undefined })])], + ]; + for (const [name, row] of cases) { + expect(run([row]).code).toBe(0); + expect([name, lastRow().status]).toEqual([name, 'issues_found']); + } + }); + + // Value: protects=a claimed clean row cannot carry a gate finding the unattended logger stamped open past Step 11.5; + // catches=a reader that trusts the claimed status and counts over the stamped finding (red-team#2, pass 1). + test('a claimed clean row with a stamped-open gate finding is not CURRENT', () => { + const token = run(['--start', 'review']).out.trim(); + const gate = note(1, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL' }); + expect(run([pass([gate], { status: 'clean', issues_found: 0, critical: 0, informational: 0 }), '--finish', token]).code).toBe(0); + const row = lastRow(); + expect([row.status, row.findings[0].action, row.review_binding.state]).toEqual(['clean', 'open', 'verified']); + expect(reviewFreshness(row, row.wtree)?.status).toBe('UNVERIFIED'); + }); + + // Value: protects=a claimed clean row reads open findings by the same rule the rewrite uses; catches=a CRITICAL + // deferral or a skipped finding under a claimed clean status reading CURRENT (adversarial-native#3, Step 11). + // Kept and restored interactive gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT, as before this PR + // (fix-check and adversarial-native, Step 11 round 2). + test('a claimed clean row with a CRITICAL deferral is not CURRENT; resolved gate, skipped and below-floor actions are', () => { + const w = 'c'.repeat(40); + const row = (findings: unknown[]) => ({ skill: 'review', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: w, + review_binding: { state: 'verified', start_wtree: w, end_wtree: w }, findings, status_source: 'claimed' }); + expect(reviewFreshness(row([note(1, 'deferred', { severity: 'CRITICAL', issue: null })]), w)?.status).toBe('UNVERIFIED'); + expect(reviewFreshness(row([note(1, 'deferred', { blocking: true })]), w)?.status).toBe('UNVERIFIED'); + expect(reviewFreshness(row([note(1, 'open', { gate: 'RH1' })]), w)?.status).toBe('UNVERIFIED'); + expect([note(1, 'skipped', { severity: 'CRITICAL' }), { fingerprint: 'lib/a.ts:2:x', severity: 'CRITICAL' }, note(1, 'mystery', { severity: 'CRITICAL' })] + .map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['UNVERIFIED', 'UNVERIFIED', 'UNVERIFIED']); + expect(reviewFreshness(row([note(1, 'kept', { gate: 'RH1', reason: 'r', actor: 'interactive', severity: 'CRITICAL' })]), w)?.status).toBe('CURRENT'); + const gate = (action: string) => note(1, action, { gate: 'RH1', reason: 'r', actor: 'interactive' }); + expect([gate('kept'), gate('restored'), note(1, 'skipped'), note(1, 'deferred', { blocking: undefined }), note(1, 'noted')] + .map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['CURRENT', 'CURRENT', 'CURRENT', 'CURRENT', 'CURRENT']); + }); + + // Value: protects=a findings file cannot dispose of a gate finding the unattended logger must force open; catches=gate rows + // from --findings derived as resolved without stamping (outside-voice#1, Step 11). + test('a --findings file gate row is stamped open before its status is derived', () => { + const file = path.join(home, 'findings.jsonl'); + fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g' }) + '\n'); + const token = run(['--start', 'review']).out.trim(); + run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]); + const row = lastRow(); + expect([row.findings_open, row.status === 'clean']).toEqual([1, false]); + expect(reviewFreshness(row, row.wtree)?.status).not.toBe('CURRENT'); + }); + + // Value: protects=the forced-open stamp wins over a findings file's own status or disposition; catches=a gate row marked + // status resolved deriving clean after it was stamped open (outside-voice#1, Step 11 round 2). + test('a --findings gate row stamped open stays open whatever its status or disposition says', () => { + for (const extra of [{ status: 'resolved' }, { disposition: 'fixed' }]) { + const file = path.join(home, 'findings.jsonl'); + fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g', ...extra }) + '\n'); + const token = run(['--start', 'review']).out.trim(); + run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]); + expect([JSON.stringify(extra), lastRow().findings_open]).toEqual([JSON.stringify(extra), 1]); + } + }); + + test('isOpenFinding: fixed and auto-fixed are not open; noted and deferred only below the floor; skipped and missing actions are', () => { + expect(['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', undefined].map(action => isOpenFinding({ action, blocking: false }))) + .toEqual([false, false, false, false, true, true]); + expect(['deferred', 'noted'].map(action => isOpenFinding({ action }))).toEqual([true, true]); + }); +}); + +describe('a tree that changed during the pass (GST-430)', () => { + // Value: protects=a refusal that says why the record cannot bind and what the cheapest repair is; catches=the + // bare 'content changed during review' that sent CAPY-2 through a ~$20 full pass without saying why. + test('the STALE reason names both trees, the cause and the repair', () => { + const s = 'a'.repeat(64), e = 'b'.repeat(64); + const fresh = reviewFreshness({ skill: 'adversarial-review', review_binding: { state: 'changed', start_wtree: s, end_wtree: e } }, e); + expect(fresh?.status).toBe('STALE'); + expect(fresh?.reason).toContain(`content changed during review: the tree moved from ${s.slice(0, 12)} to ${e.slice(0, 12)} while adversarial-review ran`); + expect(fresh?.reason).toContain('freeze the checkout until every reviewer returns'); + expect(fresh?.reason).toContain('docs/troubleshooting.md#review-content-changed'); + }); +}); diff --git a/test/ship-control-flow.test.ts b/test/ship-control-flow.test.ts index 9c0048eec..b6da27c93 100644 --- a/test/ship-control-flow.test.ts +++ b/test/ship-control-flow.test.ts @@ -55,7 +55,7 @@ describe('ship source controller', () => { test('Step 1 carries a receipt only after a dry run and a pregate at HEAD', () => { const carry = compact(entry.slice(entry.indexOf('4. **Carry check.**'), entry.indexOf('5. Display historical readiness'))); - const order = ['carry --pr --base --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.']; + const order = ['carry --pr --base --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `current`, or `refused` with no `NEXT: carry route` re-runs Steps 2–16.']; const at = order.map(t => carry.indexOf(t)); expect(at.every(i => i >= 0)).toBe(true); expect(at).toEqual([...at].sort((a, b) => a - b)); @@ -338,6 +338,16 @@ describe('ship source controller', () => { expect(docs).toMatch(/never silently refresh hashes/i); }); + // Value: protects=finish's scoped-review STOP and --docs-requested carry are routes Step 16 names, so a driver that reads + // the template instead of the STOP text keeps the GST-384/GST-391 saving; fails_when=stage 2 sends every tests=/context= + // STOP to the full loop or stage 3 offers only a re-audit or a named risk (GST-444); seam=none; no_claim=the bins' own STOP text + test('Step 16 routes the scoped-review STOP and the --docs-requested fix (GST-444)', () => { + const behavior = gate.slice(gate.indexOf('1. **Behavior'), gate.indexOf('2. **Only authored')); + expect(behavior).toContain('A STOP naming a scoped-review packet instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.'); + const docs = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.')); + expect(docs).toContain('**A fix the audit\'s result named:** rerun finish with `--docs-requested --docs-result `.'); + }); + test('tests, absent test approval and new writes reopen final verification', () => { const tests = gate.slice(gate.indexOf('### 4.'), gate.indexOf('### 5.')); expect(tests).toMatch(/\*\*New, changed or unwaived test failure:\*\* stop publication\. Run Steps 5–15/i); diff --git a/test/ship-receipt-carry-large-diff.test.ts b/test/ship-receipt-carry-large-diff.test.ts new file mode 100644 index 000000000..d1976ee35 --- /dev/null +++ b/test/ship-receipt-carry-large-diff.test.ts @@ -0,0 +1,90 @@ +/** + * gstack-ship-receipt carry on a PR whose own diff is large (GST-448, harness-evals#147): a results PR that adds thousands + * of run-receipt files has a name-only listing and a product diff over the child-process output buffer, so carry refused + * with "git could not compute HEAD's product patch-id: git diff … failed" after every base move. + */ +import { afterEach, describe, expect, setDefaultTimeout, 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 as makeRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; +const makeFixtureRepo = (): FixtureRepo => { const r = makeRepo(); r.git('config', 'core.longpaths', 'true'); return r; }; + +setDefaultTimeout(120_000); +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const DIR = `results/${'harness-eval-run-receipts-'.repeat(8)}`; +/** `count` run receipts under a long directory name: about 1.4 MB of paths and 2 MB of diff, past the 1 MB default buffer. */ +const receipts = (count: number, tag: string) => Object.fromEntries(Array.from({ length: count }, (_, i) => [`${DIR}/${tag}-run-${String(i).padStart(5, '0')}.json`, `{"run":${i},"tag":"${tag}","score":0.${i % 10}}\n`])); + +describe('carry with a large product diff (GST-448)', () => { + // Value: protects=a results PR keeps its receipt across a base move when its own diff is megabytes of added files + // (harness-evals#147 paid ~3h of /ship per base move); fails_when=carry buffers git's name-only listing or the diff in + // a bounded child-process buffer and refuses; seam=none; no_claim=gh pr edit (--body stands in) + test('a base merge carries the receipt of a PR that adds 6000 files', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, receipts(6000, 'base'), 'results: 6000 run receipts'); + const body = path.join(repo.root, 'body.md'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]); + expect(w.status).toBe(EXIT.ok); + const reviewed = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.product_patch_id; + expect(reviewed).toMatch(/^[0-9a-f]{40}$/); + repo.git('checkout', '-q', 'main'); + write(repo.work, 'docs/README.md', '# docs moved on main\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:')), out.err.split('\n').find(l => l.includes('RECEIPT_CARRY'))]).toEqual([EXIT.ok, expect.stringMatching(/^CARRY: carried/), undefined]); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.product_patch_id).toBe(reviewed); + }); + + // Value: protects=negative control: the large-diff path still refuses when the PR's own receipts change after review; + // fails_when=the streamed patch-id reads as unchanged for a changed diff; seam=none; no_claim=none + test('one changed receipt in the large diff still refuses the carry', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, receipts(6000, 'base'), 'results: 6000 run receipts'); + const body = path.join(repo.root, 'body.md'); + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok); + commit(repo, { [`${DIR}/base-run-00042.json`]: '{"run":42,"tag":"base","score":0.9}\n' }, 'results: rescore run 42'); + const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)']); + }); + + // Value: protects=the patch-id diff lives under the git dir (on disk, not a tmpfs TMPDIR), and a spot it cannot write is a + // named refusal, never a stack trace preflight reads as 'CARRY: check failed'; fails_when=patchId writes to TMPDIR or + // creates its directory outside its error handling; seam=TMPDIR, a file where the directory goes; no_claim=none + test('patch-id diffs go under the git dir: an unwritable TMPDIR still carries, an unwritable git-dir spot refuses by name', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, receipts(3, 'base'), 'results: 3 run receipts'); + const body = path.join(repo.root, 'body.md'); + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok); + repo.git('checkout', '-q', 'main'); + write(repo.work, 'docs/README.md', '# docs moved on main\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run'], { TMPDIR: path.join(repo.root, 'no-such-dir') }); + expect([dry.status, dry.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.ok, expect.stringMatching(/^CARRY: would carry/)]); + fs.rmSync(path.join(repo.work, '.git', 'gstack-patch-id'), { recursive: true, force: true }); + fs.writeFileSync(path.join(repo.work, '.git', 'gstack-patch-id'), 'not a directory\n'); + const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run']); + expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']); + expect(out.err).toContain('cannot create a temp file under'); + }); + + // Value: protects=a carry killed mid patch-id (preflight's 120 s timeout) does not leave product diffs in TMPDIR for good; + // fails_when=nothing removes a gstack-patch-id-* directory a killed run left; seam=TMPDIR, an aged directory; no_claim=none + test('a stale patch-id directory a killed run left is swept; a fresh one is kept', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, receipts(3, 'base'), 'results: 3 run receipts'); + const tmp = path.join(repo.work, '.git', 'gstack-patch-id'); fs.mkdirSync(tmp, { recursive: true }); + const stale = path.join(tmp, 'run-stale'); const fresh = path.join(tmp, 'run-fresh'); + for (const d of [stale, fresh]) { fs.mkdirSync(d); fs.writeFileSync(path.join(d, 'diff.patch'), 'diff\n'); } + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(stale, old, old); + const body = path.join(repo.root, 'body.md'); + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok); + expect([fs.existsSync(stale), fs.existsSync(fresh)]).toEqual([false, true]); + }); +}); diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 82cac144a..f06b65074 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -11,7 +11,7 @@ import { EXIT } from '../lib/headless-artifacts'; import { comparePath } from '../lib/ship-patch'; import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; -import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; +import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; setDefaultTimeout(90_000); const BIN = path.dirname(RELEASE); @@ -484,3 +484,44 @@ describe('test-only and audit-requested edits after the docs audit (GST-391, gbr expect([reset.status, reset.stdout.includes('ATTEMPT: counted (a second install refusal in this invocation)')]).toEqual([3, true]); }); }); + +describe('preflight carry route for an adjacent base edit (GST-442)', () => { + // Value: protects=/ship Step 1's carry check offers the scoped review when the base edited context next to a reviewed hunk, + // instead of treating the refusal as "run the full loop", and its dry run writes nothing; fails_when=preflight acts only on + // 'CARRY: would carry' or carry --dry-run writes the packet; seam=gstack-ship-receipt carry --dry-run; no_claim=the reviewer + test('preflight routes RECEIPT_CARRY_SCOPED_REVIEW to one scoped review, and the dry run writes no packet', () => { + const src = (edit: Record = {}) => Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `exports.v${i + 1} = ${i + 1};`).join('\n') + '\n'; + const f = fx({ 'lib/rows.js': src() }, { 'lib/rows.js': src({ 6: 'exports.v6 = 600;' }) }); + const reviewed = f.git('rev-parse', 'HEAD').stdout.trim(); + f.advanceBase({ 'lib/rows.js': src({ 5: 'exports.v5 = "five";' }) }); + mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) }); + const head = f.git('rev-parse', 'HEAD').stdout.trim(); + const view = path.join(f.root, 'view.json'); + fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head })); + f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`); + const r = f.run(PREFLIGHT, ['--base', 'main']); + expect(lineOf(r.stdout, 'CARRY')).toBe('CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)'); + expect(r.stdout).toMatch(/^NEXT: carry route \(Step 1\.4\): .*fix what the base merge broke.*commit and push.*writes the packet.*one fix-check reviewer.*--scoped-review /m); + expect(fs.existsSync(path.join(f.work, '.git', 'gstack', `scoped-review-${head.slice(0, 12)}.md`))).toBe(false); + const wrote = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main']); + expect([wrote.status, fs.existsSync(path.join(f.work, '.git', 'gstack', `scoped-review-${head.slice(0, 12)}.md`))]).toEqual([EXIT.refused, true]); + }); + + // Value: protects=the dry run offers the scoped route only when nothing else would refuse the carry, so preflight never sends + // the agent through a commit, packet, reviewer and pregate that end in another refusal (adversarial-native#5, Step 11 + // round 2); fails_when=the dry run returns the scoped refusal before the receipt's other carry checks; seam=gh; no_claim=none + test('a dry run that the receipt would refuse anyway names that refusal, not the scoped route', () => { + const src = (edit: Record = {}) => Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `exports.v${i + 1} = ${i + 1};`).join('\n') + '\n'; + const f = fx({ 'lib/rows.js': src() }, { 'lib/rows.js': src({ 6: 'exports.v6 = 600;' }) }); + const reviewed = f.git('rev-parse', 'HEAD').stdout.trim(); + f.advanceBase({ 'lib/rows.js': src({ 5: 'exports.v5 = "five";' }) }); + mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) }); + const head = f.git('rev-parse', 'HEAD').stdout.trim(); + const view = path.join(f.root, 'view.json'); + fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', gstack_version: '1.0.0' } as any), headRefOid: head })); + f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`); + const r = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main', '--dry-run']); + expect([r.status, lineOf(r.stdout, 'CARRY')]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']); + }); +}); + diff --git a/test/size-ledger/ship/gst-430-notes-only-pass.json b/test/size-ledger/ship/gst-430-notes-only-pass.json new file mode 100644 index 000000000..ac715c577 --- /dev/null +++ b/test/size-ledger/ship/gst-430-notes-only-pass.json @@ -0,0 +1,4 @@ +{ + "bytes": -6, + "why": "GST-442/GST-444: Step 1.4 follows a scoped-review NEXT; Step 16 stage 2 and 3 route finish scoped-review STOP and --docs-requested with its --docs-result; trims in Steps 3, 12 and 16 keep the skill under its cap, and a Step 3 merge-conflict trim keeps the skeleton under maxSkeletonBytes with train 77 on main" +} diff --git a/test/train-gate.test.ts b/test/train-gate.test.ts index 32757a46d..17702d1f0 100644 --- a/test/train-gate.test.ts +++ b/test/train-gate.test.ts @@ -224,6 +224,23 @@ describe('prefix trees modulo stamps', () => { expect(stampFreeTree(git, 'f'.repeat(40), repo.work, policy)).toHaveProperty('error'); }); + // Value: protects=a gated-to-current diff git cannot produce refuses with its cause instead of reading as 'no changes' + // (GST-448); fails_when=buildReceipt treats a failed diff as an empty one or drops why it failed; seam=a missing blob; no_claim=none + test('a diff git cannot produce refuses the receipt with EVIDENCE_IDENTITY_UNKNOWN and git\'s reason', () => { + const policy = loadPolicyFromHead(repo.work).policy; + repo.git('checkout', '-q', '-B', 'lost-blob', sha.t1!); + fs.writeFileSync(path.join(repo.work, 'lost.txt'), 'a blob this test deletes from the object store\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'add a blob'); + const head = repo.git('rev-parse', 'HEAD'); + const blobId = repo.git('rev-parse', `${head}:lost.txt`); + const gitDir = repo.git('rev-parse', '--absolute-git-dir'); + fs.rmSync(path.join(gitDir, 'objects', blobId.slice(0, 2), blobId.slice(2))); + const r = buildReceipt({ repoRoot: repo.work, gated: sha.t1!, current: head, policy, policyLabel: 'fixture' }) as { error?: string; code?: string }; + expect(r.code).toBe('EVIDENCE_IDENTITY_UNKNOWN'); + expect(r.error).toMatch(/^git diff [0-9a-f]{12} [0-9a-f]{12} failed \(.+\), so the changed files cannot be classified/); + repo.git('checkout', '-q', '-f', 'squash'); + }); + // #3512: a stamp path carrying a tentative `*_KEY_VERSION = ''` (gstack's own convention since #3511) digests the // same before and after the slot stamp moves it, so train-backstop finds the record that covers main; a base-version mention // on a line the base already carried digests the same at the prefix (where it equals VERSION) and on the stamped main. From 775fe2737c38013ec02b09161ff3d2d24a69369b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 14:35:42 -0800 Subject: [PATCH 04/11] =?UTF-8?q?v1.92.43.0=20docs:=20support=20tiers=20?= =?UTF-8?q?=E2=80=94=20full=20for=20macOS=20and=20Linux,=20minimal=20for?= =?UTF-8?q?=20Windows=20and=20Gemini=20(R50)=20(#3812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: support tiers — full for macOS/Linux with Claude and OpenAI, minimal for Windows and Gemini (R50) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(support-tiers): the Windows suite can also be started by hand Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Windows CI section says what runs today and what changes once windows-smoke lands Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- AGENTS.md | 3 +++ CHANGELOG.md | 8 ++++++++ README.md | 2 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/support-tiers.md | 31 +++++++++++++++++++++++++++++++ package.json | 2 +- 7 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 docs/support-tiers.md diff --git a/AGENTS.md b/AGENTS.md index abe32fea9..febab17af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,9 @@ Agents poll `eval:bg:*` logs for the `### gstack-detach EXIT= ###` sentine ## Platform support +Support tiers (full: macOS, Linux; minimal: Windows, Gemini models) are in +[docs/support-tiers.md](docs/support-tiers.md). + - **macOS** + **Linux**: full test suite supported. - **Windows**: the curated Windows-safe subset runs in the `windows-free-tests` CI workflow across six `windows-latest` jobs, packed by Windows-measured diff --git a/CHANGELOG.md b/CHANGELOG.md index c44245e20..c7c6f6500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.92.43.0] - 2026-11-01 + +**New page: [docs/support-tiers.md](docs/support-tiers.md) says what gstack supports fully and what it supports minimally.** macOS and Linux, with Claude and OpenAI models, get full support: the whole free suite and the paid evals. Windows (Git Bash or MSYS) and Gemini models get minimal support: install and the core skills (`/ship`, `/review`, `/qa-only`, `/investigate`, `/browse` on gstack's own browser) work without errors, checked on Windows by the `windows-smoke` job, which replaces the six-shard suite as the required Windows check when it lands, and by hand or one cheap smoke run on Gemini. Windows-only and Gemini-only reports that don't break install, startup or a core workflow close as won't fix with a link to the page. + +### Documentation +- `docs/support-tiers.md`: the two tiers, what we stop doing, the Windows CI shape today (the six-shard `windows-free-tests` suite required on every pull request) and once `windows-smoke` lands (`windows-smoke` required, the six-shard suite nightly on main and non-blocking) and what doesn't change (eval models, the cross-family review voice). +- README's platform notes and AGENTS.md's platform support section link to it. + ## [1.92.42.0] - 2026-11-01 **A /ship review pass that only left notes now binds at Step 11.5.** A pass whose findings were all fixed, deferred or noted has no open defect, but a driver could still log it as `issues_found`. Step 11.5 then refused it, and the only way out was an extra rebind pass (CAPY-2, qm#2837). When the tree changes during a pass, the refusal now names the start and end trees and the cheapest repair (GST-430). diff --git a/README.md b/README.md index 011832a81..47fb9c848 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,8 @@ Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guid ## Platform notes +macOS and Linux get full support. Windows and Gemini models get minimal support: install and the core skills work, and nothing more is promised. See [support tiers](docs/support-tiers.md). + **macOS:** everything runs. With the [Aside](https://aside.com) browser (macOS 15+) open and signed in, the browser skills, `/make-pdf` and `/diagram` use it and your real sessions; otherwise they use the bundled browser `./setup` builds. gstack never installs Aside. **Linux:** everything runs on the bundled browser; Aside is macOS-only, so the readiness probe says `NEEDS_ASIDE: Linux` once and the skills use gstack's own Chromium. If Chromium installs but cannot launch because the host blocks unprivileged user namespaces (Ubuntu 24.04+ AppArmor default), set `GSTACK_CHROMIUM_NO_SANDBOX=1`. diff --git a/VERSION b/VERSION index bb114e4c2..8cd35b06d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.42.0 +1.92.43.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0f21b65e0..1c19592b1 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.42.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.43.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/support-tiers.md b/docs/support-tiers.md new file mode 100644 index 000000000..e919aaf4d --- /dev/null +++ b/docs/support-tiers.md @@ -0,0 +1,31 @@ +# Support tiers + +gstack has two support tiers. Full support gets the whole test suite, paid evals and new work. Minimal support means gstack is not totally broken, and nothing more. Garry Tan set this on 2026-11-01: "we don't want it to be totally broken, but we just don't need to invest that much into it." + +## Full support + +- **Operating systems:** macOS and Linux. +- **Models:** Claude and OpenAI models, both as the host agent and as a reviewer. +- **Testing:** the full free suite and the paid evals. + +## Minimal support + +- **Windows** (Git Bash or MSYS): with a default Git for Windows install, `./setup` installs, and the core skills start without errors. The core skills are `/ship`, `/review`, `/qa-only`, `/investigate` and `/browse` on gstack's own browser. Once the `windows-smoke` CI job lands, it checks this on every pull request (see Windows CI below). +- **Gemini models:** the core workflows run to the end without errors when Gemini is the host agent or a reviewer. We check this by hand or with one cheap smoke run, not with an eval cell. + +That is the whole promise. Other skills, optional parts such as `/cso`'s native helper, non-default shell settings and speed are not covered. + +## What we stop doing + +- No Windows-only or Gemini-only features, parity work, tuning or polish. +- No new Windows or Gemini CI jobs or eval cells. Windows CI tuning stops. +- A Windows-only or Gemini-only report that doesn't break install, startup or a core workflow closes as won't fix ([triage rules](triage.md#closing-an-issue)), with a link to this page. A report that does break one of them gets a small fix. + +## Windows CI + +- **Today:** the six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs on every pull request and every push to main, and it is a required check. +- **Once `windows-smoke` lands:** `windows-smoke` becomes the required Windows check on pull requests. The six-shard suite then runs nightly on main, or when someone starts it by hand, and never blocks a pull request. A red nightly run gets a fix only when it shows an install, startup or core-workflow break. + +## What doesn't change + +These tiers don't change which models gstack's own evals test, or which cross-family review voice gstack uses. diff --git a/package.json b/package.json index 8acc970b4..46e22f914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.42", + "version": "1.92.43", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", From 4d388f44bd157f222de54611cb214a9adb237a22 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 14:50:47 -0800 Subject: [PATCH 05/11] v1.92.44.0 fix(pregate): literals stops treating same-stem sibling files as the changed module (GST-820) (#3814) * fix(pregate): literals stops treating a same-stem sibling file as the changed module (GST-820) A path names the module only when its stem is followed by a quote, the line end, or the module's own or a JS/TS extension; test/.txt no longer makes a file a consumer. Red first on main 4f5b043. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): literals keeps a stem followed by a space, bracket, sentence end or .d.ts as the module (GST-820 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): literals keeps JS/TS aliases to JS/TS modules and stops matching a file name with a further extension (GST-820 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): literals matches the module only for old values longer than one character (GST-820 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/pregate.md | 9 ++++++++- lib/pregate/checks/literals.ts | 14 +++++++++++--- package.json | 2 +- test/pregate-selection.test.ts | 35 ++++++++++++++++++++++++++++++++++ 7 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c6f6500..6c67fdace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.92.44.0] - 2026-11-01 + +**`literals` stops treating a same-name sibling file as the changed module (GST-820).** A test that only mentioned `test/postgres-unit-arms.txt` was counted as a consumer of `scripts/postgres-unit-arms.ts`, so any `12` in it failed `PREGATE_LITERALS` when `ARM_SHARDS` moved from 12 to 18. In gbrain #8372 that gave 17 false files. A path now names the module only when its stem is followed by the module's own extension (for a JS/TS module, any JS/TS extension), or by anything other than a letter, digit, `_`, `-` or another extension, and the full file name no longer matches with a further extension such as `.snap`. Extensionless imports, `.js` imports and the module's own path still count. + +### Fixed +- **`literals` module matching.** A sibling file that shares the module's stem (`.txt`, `.json`, `-v2.ts`, `.snap`, or `.ts` beside a shell script) is no longer the module. + ## [1.92.43.0] - 2026-11-01 **New page: [docs/support-tiers.md](docs/support-tiers.md) says what gstack supports fully and what it supports minimally.** macOS and Linux, with Claude and OpenAI models, get full support: the whole free suite and the paid evals. Windows (Git Bash or MSYS) and Gemini models get minimal support: install and the core skills (`/ship`, `/review`, `/qa-only`, `/investigate`, `/browse` on gstack's own browser) work without errors, checked on Windows by the `windows-smoke` job, which replaces the six-shard suite as the required Windows check when it lands, and by hand or one cheap smoke run on Gemini. Windows-only and Gemini-only reports that don't break install, startup or a core workflow close as won't fix with a link to the page. diff --git a/VERSION b/VERSION index 8cd35b06d..fbca8550c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.43.0 +1.92.44.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1c19592b1..950759039 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.43.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.44.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/pregate.md b/docs/pregate.md index 5d36f49e1..057788ade 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -200,7 +200,14 @@ Two stages with separate clocks, both printed: scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere. - `literals`: for every changed exported constant or named timeout/retry literal, the whole test tree (nightly- and Heavy-only files included) is - searched for the old value; a consumer the branch did not update fails with + searched for the old value in files that name the constant or, when the old + value is longer than one character, its module (the module's file name not + followed by another extension, or a path ending + in its stem followed by its own extension, for a JS/TS module also any JS/TS + extension or `.d.ts`, a sentence-ending `.`, or anything but a word + character, `.` or `-`; so a sibling `.txt`, `-v2.ts` or + `.snap`, or `.ts` beside a shell module, is not the module); a + consumer the branch did not update fails with `file:line`. A version-history row, a line that starts a bracketed row with the old value (`[45, ""]`), is skipped when the same table (a run of consecutive lines starting with `[`) also holds a row for the new value that diff --git a/lib/pregate/checks/literals.ts b/lib/pregate/checks/literals.ts index a769626e4..d3158c481 100644 --- a/lib/pregate/checks/literals.ts +++ b/lib/pregate/checks/literals.ts @@ -61,8 +61,12 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) : /** * Test files that still carry the old literal beside the constant's name or - * its module. The module counts as its full file name (`install.sh`) or as a - * path fragment (`/retry.js`, `/retry'`): a bare stem such as `install` or `retry` is an + * its module. The module counts as its full file name (`install.sh`, not + * `install.sh.tmpl`) or as a path fragment (`/retry.js`, `/retry'`) whose stem is + * followed by the module's own extension (for a JS/TS module also any JS/TS + * extension or `.d.ts`), a sentence-ending `.`, or anything but a word + * character, `.` or `-`, so a sibling `/retry.txt`, `/retry-v2.ts` or + * `/retry.ts.snap`, or `lib/install.ts` for `install.sh`, is not the module (GST-820): a bare stem such as `install` or `retry` is an * English word in half the test tree, and a one-character old value (`2`) * sits in every test, so that value needs the constant's name (a wrapper * version 2 → 3 flagged 575 consumers through the stem alone). @@ -83,7 +87,11 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan const newRowRe = row(change.new); const base = path.basename(change.file); const stem = base.replace(/\.[^.]+$/, ''); - const moduleRe = new RegExp(`(?:(?:^|[/\\\\])${escape(stem)}|(? { { name: 'MAX', old: '3', new: '4', file: 'f.ts' }, { name: 'timeout', old: '5000', new: '9000', file: 'f.ts' }, ]); }); + // Value: protects=a sibling file that shares the module's stem (`postgres-unit-arms.txt` beside `postgres-unit-arms.ts`) is not the + // module, so a test that only names it is not a consumer, while an extensionless import, a JS/TS import extension, the module's own + // file name and a path ending at the stem (then a quote, space, bracket, sentence end or `.d.ts`) still are, and neither `.snap` nor a JS twin of a + // shell module is; fails_when=`/` again accepts any following `.`, the file name accepts a further extension, + // a non-JS module takes JS/TS extensions, or stops accepting a following space, bracket or sentence end + // (GST-820: 17 false files in gbrain #8372 on ARM_SHARDS 12 -> 18); why_new=no test had a same-stem sibling; seam=none; + // no_claim=name-gated hits (a file naming ARM_SHARDS still counts any 12) + test('consumersOf: a sibling file sharing the module stem is not the module; real imports of it still are (GST-820)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-stem-')); + const nl = String.fromCharCode(10); + const files: Record = { + 'test/a.test.ts': ['// listed in test/postgres-unit-arms.txt', 'expect(n).toBe(12);', ''].join(nl), + 'test/json.test.ts': ["const arms = read('fixtures/postgres-unit-arms.json');", 'expect(arms.length).toBe(12);', ''].join(nl), + 'test/longer.test.ts': ["run('scripts/postgres-unit-arms-v2.ts');", 'expect(n).toBe(12);', ''].join(nl), + 'test/import.test.ts': ["import { shards } from '../scripts/postgres-unit-arms';", 'expect(shards()).toBe(12);', ''].join(nl), + 'test/import-js.test.ts': ["import { shards } from '../scripts/postgres-unit-arms.js';", 'expect(shards()).toBe(12);', ''].join(nl), + 'test/own.test.ts': ["const out = run('scripts/postgres-unit-arms.ts');", 'expect(out).toBe(12);', ''].join(nl), + 'test/line-end.test.ts': ['// covers scripts/postgres-unit-arms', 'expect(n).toBe(12);', ''].join(nl), + 'test/prose.test.ts': ['// covers scripts/postgres-unit-arms and more', 'expect(n).toBe(12);', ''].join(nl), + 'test/paren.test.ts': ['// (scripts/postgres-unit-arms)', 'expect(n).toBe(12);', ''].join(nl), + 'test/sentence.test.ts': ['// see scripts/postgres-unit-arms.', 'expect(n).toBe(12);', ''].join(nl), + 'test/dts.test.ts': ["import type { Arms } from '../scripts/postgres-unit-arms.d.ts';", 'expect(n).toBe(12);', ''].join(nl), + 'test/sibling-test.test.ts': ["run('scripts/postgres-unit-arms.test.ts');", 'expect(n).toBe(12);', ''].join(nl), + 'test/snap.test.ts': ["read('scripts/__snapshots__/postgres-unit-arms.ts.snap');", 'expect(n).toBe(12);', ''].join(nl), + 'test/sh-twin.test.ts': ["import { arms } from '../lib/arms-tool.ts';", 'expect(n).toBe(12);', ''].join(nl), + 'test/sh-own.test.ts': ["run('scripts/arms-tool.sh');", 'expect(n).toBe(12);', ''].join(nl), + }; + for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); } + expect(consumersOf(root, Object.keys(files), { name: 'ARM_SHARDS', old: '12', new: '18', file: 'scripts/postgres-unit-arms.ts' })).toEqual([ + 'test/import.test.ts:2', 'test/import-js.test.ts:2', 'test/own.test.ts:2', 'test/line-end.test.ts:2', + 'test/prose.test.ts:2', 'test/paren.test.ts:2', 'test/sentence.test.ts:2', 'test/dts.test.ts:2', + ]); + expect(consumersOf(root, ['test/sh-twin.test.ts', 'test/sh-own.test.ts'], { name: 'ARMS', old: '12', new: '18', file: 'scripts/arms-tool.sh' })).toEqual(['test/sh-own.test.ts:2']); + fs.rmSync(root, { recursive: true, force: true }); + }); test('consumersOf: a module stem is a path fragment, never a bare word, and a one-character old value needs the constant name (GSTA-114 flood: install.sh 2 -> 3 flagged 575 consumers)', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-')); const nl = String.fromCharCode(10); From 589ff24286c8864838584b61ed5b9c517345d580 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 15:05:58 -0800 Subject: [PATCH 06/11] v1.92.45.0 /ship review scales to risk, with a 2-round cap (R51) (#3820) * review: /ship depth scales to risk, not size; two rounds is the cap (R51, GSTA-167) Non-risky code is light at any size; a risk class (money, auth, secret, security, user data) or gate code makes it full. UI or game text counts as docs. A fix-check counts as a round and round 2 never earns a third. The receipt records review_loop.rounds and review_depth.risk. Repins lib/review-depth.ts and pins lib/review-risk.ts under no-loss replay R51 (test/review-packets-removed.test.ts): the saved panel findings re-scored against this plan lose no blocker, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (round 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: UI text and SVGs stay code; a round-2 gate edit stops for the owner; SQL deletes match in any case (R51, Captain choice A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore review-policy.md tail; keep uppercase DELETE FROM (R51 repair, Captain grant) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: R51 fragment matches the final change Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy review-loop cap says two rounds (Step 14.5 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-review-plan | 3 +- docs/review-policy.md | 59 +++++++--- docs/ship-policy.md | 8 +- lib/headless-artifacts.ts | 8 +- lib/review-depth.ts | 52 +++++---- lib/review-risk.ts | 85 ++++++++++++++ lib/review-scope.ts | 3 + lib/review-targeted.ts | 36 +----- lib/ship-budget.ts | 2 +- lib/ship-receipt-depth.ts | 4 +- lib/ship-receipt.ts | 3 +- package.json | 2 +- ship/SKILL.md | 4 +- ship/SKILL.md.tmpl | 4 +- test/fixtures/golden/agy-ship-SKILL.md | 4 +- test/fixtures/golden/claude-ship-SKILL.md | 4 +- test/fixtures/golden/codex-ship-SKILL.md | 4 +- test/fixtures/golden/factory-ship-SKILL.md | 4 +- test/review-depth.test.ts | 65 +++++++---- test/review-packets-removed.test.ts | 4 +- test/review-pass.test.ts | 2 +- test/review-plan-cli.test.ts | 18 +-- test/review-risk-routing.test.ts | 123 +++++++++++++++++++++ test/review-scope-followups.test.ts | 2 +- test/review-scope.test.ts | 6 +- test/review-targeted.test.ts | 34 ++++-- test/ship-frozen-scope-actor.test.ts | 2 +- test/ship-receipt-depth.test.ts | 18 +-- 31 files changed, 430 insertions(+), 147 deletions(-) create mode 100644 lib/review-risk.ts create mode 100644 test/review-risk-routing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c67fdace..c7a2763c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.45.0] - 2026-11-01 + +### /ship review scales to risk, not size + +- **Review depth follows risk.** Code that touches security, user data, money, secrets, auth or the merge checks gets the full review panel. Other code goes to the light tier at any size, with one batched reader plus the native adversarial pass. Docs, tests and eval write-ups get one reader. UI text and SVG files stay code for now. A larger diff never raises the depth by itself. +- **New risk classes.** `lib/review-risk.ts` holds the money, auth and secret matchers the targeted pass already used, plus new security (trust boundaries, shell and HTML sinks, shells run through argv, recursive deletes, `eval`) and user-data (persistence paths, `DELETE FROM`, `deleteMany`, `destroy_all`) signals. +- **At most 2 review rounds.** A fix-check counts as a round. A blocker that is still open or fixed in round 2 stops for the owner, who may grant one scoped fix-check; there is no round 3. A fix in round 2 that edits gate code also stops for the owner. Below-floor fixes in round 2 finish, with the notes listed. +- **Receipts record tier and rounds.** `review_loop.rounds` and `review_depth.risk` are on the receipt, and the summary line prints `rounds=`. `gstack-ship-queue ready` still refuses a receipt below its head's depth. +- Replayed on the 26-unit no-loss panel, no historical blocker was lost. Over the last 100 main commits (an estimate from replaying the depth rules), 8 go from standard to light, 12 go up to full, and no commit with a risk class goes down. + ## [1.92.44.0] - 2026-11-01 **`literals` stops treating a same-name sibling file as the changed module (GST-820).** A test that only mentioned `test/postgres-unit-arms.txt` was counted as a consumer of `scripts/postgres-unit-arms.ts`, so any `12` in it failed `PREGATE_LITERALS` when `ARM_SHARDS` moved from 12 to 18. In gbrain #8372 that gave 17 false files. A path now names the module only when its stem is followed by the module's own extension (for a JS/TS module, any JS/TS extension), or by anything other than a letter, digit, `_`, `-` or another extension, and the full file name no longer matches with a further extension such as `.snap`. Extensionless imports, `.js` imports and the module's own path still count. diff --git a/VERSION b/VERSION index fbca8550c..603bff1c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.44.0 +1.92.45.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 950759039..d00c52753 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.44.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.45.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index 4e5536d6e..93f6aee5e 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -56,7 +56,8 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files -Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money, +Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR; +standard only by --depth-floor or a raise; otherwise light. TARGETED: the money, gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped (a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan from an error exits 0; exit 1 means no plan could be built (git could not list the diff). diff --git a/docs/review-policy.md b/docs/review-policy.md index 6895500fa..1d62b14de 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -93,11 +93,14 @@ incomplete coverage, which blocks until the reviewer is rerun. 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 scoped pass - ([below](#scoped-pass)) is a full pass. 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. +3. **Two rounds is the cap** (R51): a full pass, a scoped pass + ([below](#scoped-pass)) or a fix-check is a round. After round 2 the run + finishes with its open notes listed in the PR body and receipt, or stops for + the owner when a blocking finding is open, or was fixed in round 2 and is + unverified, or a round-2 fix edited a gate-class path, naming it (path, + severity, problem). A third round is the owner's + grant of one scoped fix-check (recorded in `review_loop_exception`), never the + loop's. The cap approves nothing. ### Transitions @@ -112,10 +115,12 @@ incomplete coverage, which blocks until the reviewer is rerun. | a full pass that fixed a blocking finding, below the cap, on a diff below full depth with a fix range of at most 150 runtime lines | scoped pass on the range | | a full pass that fixed a blocking finding, below the cap (full depth, a larger range, or a range or depth it could not read) | 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 | +| a full pass that fixed only below-floor findings | one fix-check (round 2) | +| a light pass 1 whose below-floor fixes change no runtime lines | done | +| round 2 (a full pass or a fix-check) that fixed a blocking finding or edited a gate-class path | stop: name it for the owner, who may grant one scoped fix-check | +| round 2 (a full pass or a fix-check) that fixed only below-floor findings | done: notes in the PR body | | a full pass that leaves a blocking finding open (fixed or not) | stop: name it for the owner | -| a fix-check that found nothing blocking (below-floor fixes before it included) | done | +| 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 @@ -186,10 +191,27 @@ in a data file still schedules the outside voice. | Depth | When | Pass 1 dispatches | |---|---|---| -| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers | -| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (the batched checklists below plus the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | -| **standard** | more than 50 runtime lines | one batched specialist at `medium` and the Red Team at 200 runtime lines (or on a CRITICAL); the batched reader leaves docs and registered outputs out of its diff; adversarial and outside voice read everything | -| **full** | gate class, or any classifier error | one batched specialist on the whole diff, the Red Team always, no hit-rate gating | +| **light**, runtime 0 | docs, tests, eval write-ups, or only registered outputs | one reader: the core checklist; the outside voice instead reads a diff that publishes numbers | +| **light**, runtime above 0 | code with no risk class, at any size (R51: size never raises the depth) | core, one **batched** specialist (the batched checklists below plus the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | +| **standard** | `--depth-floor standard`, or a light diff raised by a fixed blocker or a CRITICAL | one batched specialist at `medium` and the Red Team at 200 runtime lines (or on a CRITICAL or a raise); the batched reader leaves docs and registered outputs out of its diff; adversarial and outside voice read everything | +| **full** | gate class, a [risk class](#risk-classes), or any classifier error | one batched specialist on the whole diff, the Red Team always, no hit-rate gating | + + +**Risk classes (R51).** `lib/review-risk.ts` reads a code diff's paths and its +added and deleted lines; any class makes the depth full (`risk:` in +`reasons=`, `review_depth.risk` in the receipt). money, auth and secret are the +[targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code)'s matchers. +**security** is a trust-boundary path (trust, redact, sanitize, neutralize, +consent, escape) or line (`shell: true`, `execSync(`, `eval`, `new Function(`, +raw HTML sinks, a shell run through argv, recursive deletes). **data** is user data: a path naming pii, gdpr, ccpa, privacy, +personal, account, customer, cookie, persistence, storage or database, or a line +that drops a table or column, deletes rows or bulk-deletes records. Auth scope, +migrations and deleted code paths were already gate class. `RISK_RULES` hashes +the patterns and `DEPTH_RULES` includes it. + +**UI text and SVGs** stay code (light unless a risk class applies). A docs path +for UI copy, locale values and chart SVGs waits for an allowlist classifier: three +rounds of R51 review found bypasses in line-pattern versions of both. **Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist subagent reads Testing and Security on every code or gate diff, plus @@ -230,7 +252,7 @@ and lockfiles stay runtime. To adopt it, commit an entry like this on the base b **Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`, `lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`, -`lib/review-targeted.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, +`lib/review-targeted.ts`, `lib/review-risk.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, `ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or `scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns, @@ -282,6 +304,10 @@ the specialists, audits and watchers at the host default too prompt. At runtime 0 light runs no adversarial pass either. - Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy diff with few runtime lines dispatches fewer specialists. +- R51: size never raises the depth; non-risky code is light at any size, and a risk + class makes it full. Two rounds is the cap, a fix-check + included: a blocker fixed in round 2 stops for the owner instead of earning a + fix-check, and a light pass 1 whose below-floor fixes change no runtime lines is done. - A JSON, CSV, TSV or raster data file no code names is docs, not runtime (GSTA-167 part 4a). A binary image no code names is no longer a `binary_code_path` error that forces full depth; one that code names still is. @@ -354,6 +380,7 @@ puts in the receipt: | `review_loop.fixed` | findings fixed in this PR | | `review_loop.scope` | the `review_scope` the loop ran under | | `review_loop.fix_checks` | fix-checks this ship ran (never passes) | +| `review_loop.rounds` | passes plus fix-checks (R51 caps it at 2; the queue's `REVIEW_DEPTH:` line prints it) | | `review_loop.dispatches` | reviewer dispatches by role: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`, `targeted` | | `review_loop.est_input_tokens` | prompt plus diff bytes the dispatches read, divided by 4: a relative estimate for comparing runs, not a bill | | `review_targeted` | the [targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code): the `classes` it covered, `rules`, `brief_sha`, the receipt's own `product_patch_id`, `status` (`done` only with its output collected and the committed diff it was rendered on still the one at verify, else `failed`; absent when write got no record, which the queue reports as `legacy`), `findings` and `raised_by`; a carry keeps it while the product patch is unchanged | @@ -366,7 +393,7 @@ specialist stats they record). The summary line appends `fix_checks=`, loop prints the old line. Beside the counters, `write` records `review_depth` (the depth, runtime lines, -rules hash, base and registry commits, and the gate scan bound to the head) and +rules hash, base and registry commits, the gate scan bound to the head and the risk classes) and the queue refuses a receipt below the depth its head needs ([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner accepted after a fix-check edit goes in `review_loop_exception`, never in prose @@ -385,8 +412,8 @@ difference passed a scoped review. | 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 | 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 | +| `frozen` (default) | the diff | filed as an issue, listed as deferred | the range since the previous pass, blocking only | 2 rounds (a fix-check is one) | +| `diff-plus-blocking` | the diff | fixed in this PR | the range since the previous pass, blocking only | 2 rounds (a fix-check is one) | | `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 diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 7cd24136a..4e0488229 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1221,13 +1221,13 @@ construction: fix-range`) or in the diff is `scope: fix`; nothing below the floor earns a pass. `NO FINDINGS` is the expected reviewer result for a clean range, never a failed review. -3. **Two full passes plus one fix-check is the cap**, stepped by the - [transition table](review-policy.md#transitions); a - [scoped pass](review-policy.md#scoped-pass) counts as a full pass. A pass that applied no edits +3. **Two rounds is the cap** (R51), stepped by the + [transition table](review-policy.md#transitions); a full pass, a + [scoped pass](review-policy.md#scoped-pass) and a fix-check each count as a round. A pass that applied no edits continues to Step 10: the loop ships with its deferred and noted items named in the PR body. A blocking finding still open after the last allowed step stops the run and names the finding (path, severity, problem) for the owner instead - of a third full pass. + of a third round; the owner may grant one scoped fix-check. `gstack-review-scope summary --since ` reads the branch's review-log rows for this invocation and writes the counters the ship receipt carries diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 2bd2a2dc1..1e184b184 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -104,13 +104,13 @@ 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; fix_checks?: number; dispatches?: Record; est_input_tokens?: number }; + review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record; 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[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }; /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */ head_tree?: string; product_patch_id?: string; /** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */ gstack_version?: string; - review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] }; + review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[] }; /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; /** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */ @@ -236,11 +236,11 @@ 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 … @ 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, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } }, + 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 }, rounds: { 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, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, head_tree: str, product_patch_id: str, gstack_version: str, base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } }, - review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } }, + review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } }, review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } }, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, diff --git a/lib/review-depth.ts b/lib/review-depth.ts index 2cdbd8fd9..714e44183 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -13,6 +13,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ResultCodeName } from './result-codes'; import { matchesAny } from './ship-policy'; +import { matchRisk, RISK_RULES, type RiskClass } from './review-risk'; export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; @@ -23,6 +24,7 @@ export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop'; export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const; export type Specialist = (typeof SPECIALISTS)[number]; +/** The review rounds one /ship invocation runs (R51): a full, scoped or fix-check step is a round. */ export const FULL_PASS_CAP = 2; export const SMALL_DIFF_LINES = 50; export const RED_TEAM_LINES = 200; @@ -53,7 +55,7 @@ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//]; /** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */ const GATE_PATH = [ /(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/, - /^lib\/review-(depth|collect|scope|targeted)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, + /^lib\/review-(depth|collect|scope|targeted|risk)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, ]; /** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */ @@ -66,7 +68,7 @@ const DATA_CONFIG = [ /** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ - SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, + RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), })).digest('hex').slice(0, 16); @@ -129,8 +131,11 @@ export interface ClassInput { * (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code. */ consumed?: readonly string[] | 'all'; + /** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */ + added?: Record; + removed?: Record; } -export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null } +export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[] } const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); @@ -166,30 +171,33 @@ export function classifyDiff(i: ClassInput): ClassResult { for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; } if (i.registry?.error) errors.push(i.registry.error); } - if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null }; + const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true' }) : new Map(); + const risk = [...riskHits.keys()]; + reasons.push(...[...riskHits.values()].map(r => `risk: ${r}`)); + if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null, risk }; let runtimeLines = 0; for (const e of i.perPath) { if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue; if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" }); else runtimeLines += e.lines; } - return { class: cls, reasons, errors, runtimeLines }; + return { class: cls, reasons, errors, runtimeLines, risk }; } -/** Any classifier error or gate class is full; runtime above SMALL_DIFF_LINES is standard; otherwise light. A floor only raises. */ -export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; floor?: Depth }): Depth { - const d: Depth = o.errors.length || o.cls === 'gate' ? 'full' : o.runtimeLines > SMALL_DIFF_LINES ? 'standard' : 'light'; +/** Risk, not size (R51): any classifier error, gate class or risk class is full; everything else is light. A floor only raises; a fixed blocker or a CRITICAL raises light to standard for the next step (planReview). */ +export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; risk?: readonly RiskClass[]; floor?: Depth }): Depth { + const d: Depth = o.errors.length || o.cls === 'gate' || o.risk?.length ? 'full' : 'light'; return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d; } -export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string } +export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string; risk: RiskClass[] } function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult { - const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors }); - const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), `runtime=${runtimeLines}${runtimeLines > SMALL_DIFF_LINES ? '>' : '<='}${SMALL_DIFF_LINES}`]; + const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk }); + const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), ...c.risk.map(r => `risk:${r}`), `runtime=${runtimeLines}`]; if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`); const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))]; - return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES }; + return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES, risk: c.risk }; } /** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */ @@ -249,17 +257,23 @@ export interface NextStep { step: Step; reason: string } * The transition table (docs/review-policy.md#transitions). Pass 1 is full. A fixed * blocking finding earns a second full pass on the range, scoped when `depth` (known * only to planReview) is below full and the fix range has at most SCOPED_RANGE_LINES - * runtime lines; any other fix earns one fix-check; two full passes is the cap (a - * scoped pass is one); a blocker still open after any step stops for the owner. + * runtime lines; any other fix earns one fix-check, except a light pass whose fix range + * changes no runtime lines; two rounds is the cap (R51: a scoped pass or a fix-check is + * one): after round 2, below-floor fixes finish with notes, and a blocker open or fixed + * there, or a gate-class edit there, stops for the owner, whose grant may add one scoped fix-check. */ export function nextStep(h: History, depth?: Depth): NextStep { if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' }; 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' } : { step: 'done', reason: 'the fix-check found nothing blocking' }; - 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` }; + if (h.last === 'fix-check') return h.blockingOpen || h.fixedBlocking || h.touchedGate ? { step: 'stop', reason: `the fix-check ${h.touchedGate && !h.blockingOpen && !h.fixedBlocking ? 'edited a gate-class path' : 'found a blocking finding'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` } : { step: 'done', reason: fixed ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' }; + if (h.blockingOpen && h.fullPasses >= FULL_PASS_CAP) return { step: 'stop', reason: `a blocking finding is open after ${FULL_PASS_CAP} rounds: name it for the owner` }; if (h.blockingOpen) return { step: 'stop', reason: 'a blocking finding is still open in fix scope: name it for the owner' }; if (!fixed) return { step: 'done', reason: 'the step applied no edits' }; - if ((h.fixedBlocking || h.touchedGate) && h.fullPasses < FULL_PASS_CAP) { + if (h.fullPasses >= FULL_PASS_CAP) return h.fixedBlocking || h.touchedGate + ? { step: 'stop', reason: `${h.fixedBlocking ? 'a blocking finding was fixed' : 'a fix edited a gate-class path'} in round ${FULL_PASS_CAP}, the cap: name it for the owner, whose grant may add one scoped fix-check (review_loop_exception)` } + : { step: 'done', reason: `round cap ${FULL_PASS_CAP} reached: the below-floor fixes ship as notes in the PR body and receipt` }; + if (!h.fixedBlocking && !h.touchedGate && depth === 'light' && h.rangeRuntimeLines === 0) return { step: 'done', reason: 'light depth: the below-floor fixes change no runtime lines, so they ship as notes without a fix-check' }; + if (h.fixedBlocking || h.touchedGate) { if (!h.fixedBlocking) return { step: 'full', reason: 'a fix touched a gate-class path: full pass on the range' }; const n = h.rangeRuntimeLines; const keep = h.touchedGate ? 'a fix touched a gate-class path' : depth === undefined ? 'the depth is unknown' : h.pass1Depth === 'full' ? 'pass 1 ran at full depth' : depth === 'full' ? 'the diff is at full depth' @@ -267,7 +281,7 @@ export function nextStep(h: History, depth?: Depth): NextStep { if (keep) return { step: 'full', reason: `a blocking finding was fixed and ${keep}: full pass on the range` }; return { step: 'scoped', reason: `a blocking finding was fixed on a ${depth} diff with ${n} runtime lines in the fix range (<= ${SCOPED_RANGE_LINES}): scoped pass on the range` }; } - 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' }; + return { step: 'fix-check', reason: 'only below-floor fixes: one fix-check reads the touched hunks (round 2)' }; } // --------------------------------------------------------------------------- @@ -401,7 +415,7 @@ export function planReview(i: PlanInput): ReviewPlan { } else if (next.step === 'full') { const full = stepDepth === 'full'; const dcls: DiffClass = full ? 'gate' : cls; - const raised = !full && runtimeLines <= SMALL_DIFF_LINES; + const raised = !full && (raise || runtimeLines <= SMALL_DIFF_LINES); const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0); const checklists = batchChecklists(dcls, i, skipped); if (checklists.length) add('specialist', 'batched', specialist, full ? [] : docsAndOutputs, { checklists }); diff --git a/lib/review-risk.ts b/lib/review-risk.ts new file mode 100644 index 000000000..c1ed328a1 --- /dev/null +++ b/lib/review-risk.ts @@ -0,0 +1,85 @@ +/** + * review-risk — the risk signals behind /ship's review depth (R51, docs/review-policy.md#review-depth) and the + * targeted pass (lib/review-targeted.ts). A leaf module: callers pass only code-class paths with their added + * and deleted lines, so it imports neither the depth nor the targeted module. + * + * money, auth and secret are the targeted pass's matchers (GST-257). security is a trust-boundary path or line + * (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink); data is user data (a path + * naming personal or stored data, or a line that drops or bulk-deletes it). Path and identifier signals are + * token-bounded: costume never names cost. + */ +import { createHash } from 'node:crypto'; + +export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const; +export type RiskClass = (typeof RISK_CLASSES)[number]; + +export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices']; +/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */ +export const MONEY_LINE = [ + /\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i, + /\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/, + /\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/, +]; +export const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles']; +export const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz']; +export const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i; +/** An environment name whose underscore-separated segments include a key word: OPENAI_API_KEY, GH_TOKEN; never MAX_TOKENS. */ +export const KEY_NAME = String.raw`(?:[A-Z0-9]+_)*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS?)(?:_[A-Z0-9]+)*\b`; +export const SECRET_LINE = [ + new RegExp(String.raw`\b(?:process\.env|Bun\.env|import\.meta\.env)(?:\.|\[\s*['"\x60])${KEY_NAME}`), + new RegExp(String.raw`\b(?:os\.environ(?:\.get\(|\[)|os\.getenv\(|ENV(?:\.fetch\(|\[))\s*['"]${KEY_NAME}`), + new RegExp(String.raw`\$\{?${KEY_NAME}`), + /\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/, +]; + +const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape']; +const SECURITY_LINE = [ + /\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(? [k, v.map(r => '/' + r.source + '/' + r.flags)]), +})).digest('hex').slice(0, 16); + +export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + +export interface RiskInput { + /** Code-class paths only: the caller drops docs, tests and data its own classifier set aside. */ + codePaths: readonly string[]; + added?: Record; + removed?: Record; + /** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */ + scopeAuth: boolean; +} + +/** The risk classes the code paths touch, the first reason per class, in RISK_CLASSES order. Nothing fails closed here: input errors are the caller's. */ +export function matchRisk(i: RiskInput): Map { + const why = new Map(); + const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); }; + for (const p of i.codePaths) { + const t = tokens(p); + if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p); + if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p); + if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', 'money path: ' + p); + if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p); + if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p); + for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) { + if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', 'money line: ' + p); + if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p); + if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p); + if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p); + } + } + return new Map(RISK_CLASSES.filter(c => why.has(c)).map(c => [c, why.get(c)!])); +} diff --git a/lib/review-scope.ts b/lib/review-scope.ts index e011ea4e7..833562591 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -300,6 +300,8 @@ 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; + /** Review rounds (R51): passes plus fix-checks; the loop caps them at 2. */ + rounds?: number; /** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check, targeted). */ dispatches?: Record; /** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */ @@ -370,6 +372,7 @@ export function summarizeReviewLoop(rows: readonly Record[], since: 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; + out.rounds = passes.length + fixChecks; const byRole: Record = {}; let bytes = 0; let measured = false; for (const row of rows.filter(r => inRun(r) && DISPATCH_SKILLS.includes(r.skill))) { diff --git a/lib/review-targeted.ts b/lib/review-targeted.ts index ef037c683..49d27462a 100644 --- a/lib/review-targeted.ts +++ b/lib/review-targeted.ts @@ -21,29 +21,11 @@ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { classifyPath } from './review-depth'; +import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk'; export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const; export type TargetedClass = (typeof TARGETED_CLASSES)[number]; -const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices']; -/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */ -const MONEY_LINE = [ - /\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i, - /\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/, - /\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/, -]; -const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles']; -const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz']; -const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i; -/** An environment name whose underscore-separated segments include a key word: OPENAI_API_KEY, GH_TOKEN; never MAX_TOKENS. */ -const KEY_NAME = String.raw`(?:[A-Z0-9]+_)*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS?)(?:_[A-Z0-9]+)*\b`; -const SECRET_LINE = [ - new RegExp(String.raw`\b(?:process\.env|Bun\.env|import\.meta\.env)(?:\.|\[\s*['"\x60])${KEY_NAME}`), - new RegExp(String.raw`\b(?:os\.environ(?:\.get\(|\[)|os\.getenv\(|ENV(?:\.fetch\(|\[))\s*['"]${KEY_NAME}`), - new RegExp(String.raw`\$\{?${KEY_NAME}`), - /\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/, -]; - /** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */ export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({ TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, @@ -65,8 +47,6 @@ export interface TargetedInput { } export interface TargetedResult { classes: TargetedClass[]; reasons: string[]; errors: string[]; rules: string } -const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); - /** The classes a diff touches, in TARGETED_CLASSES order, with one reason per class; any input error returns all four. */ export function targetedClasses(i: TargetedInput): TargetedResult { const errors = (i.errors ?? []).map(e => `${e.code} (${e.input})`); @@ -77,18 +57,8 @@ export function targetedClasses(i: TargetedInput): TargetedResult { if (code.length && i.gateTagged === null) errors.push('gate scan unavailable'); if (errors.length) return { classes: [...TARGETED_CLASSES], reasons: errors.map(e => `fail-closed: ${e}`), errors, rules: TARGETED_RULES }; const why = new Map(); - const hit = (c: TargetedClass, reason: string) => { if (!why.has(c)) why.set(c, reason); }; - if ((i.gateTagged ?? 0) > 0) hit('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); - for (const p of code) { - const t = tokens(p); - if (s!.SCOPE_AUTH === 'true' && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', `auth path: ${p}`); - if (SECRET_PATH.test(p)) hit('secret', `secret path: ${p}`); - if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', `money path: ${p}`); - for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) { - if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', `money line: ${p}`); - if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', `secret line: ${p}`); - } - } + if ((i.gateTagged ?? 0) > 0) why.set('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); + for (const [c, reason] of matchRisk({ codePaths: code, added: i.added, removed: i.removed, scopeAuth: s!.SCOPE_AUTH === 'true' })) if ((TARGETED_CLASSES as readonly string[]).includes(c)) why.set(c as TargetedClass, reason); const classes = TARGETED_CLASSES.filter(c => why.has(c)); return { classes, reasons: classes.map(c => why.get(c)!), errors: [], rules: TARGETED_RULES }; } diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index ae354cf40..1245f6b9c 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -302,7 +302,7 @@ export function segmentEstimate(cal: Calibration, unit: string, from: string, to * section reads, reconcile: gstack-evals #34's two children cost 4.53M, 2-4.5M each) to save the parent's * context above the host floor on each call of the segment. #34's parent calls ran 0.2-0.3M over a 0.15M * floor, so a fresh child saves at most about 3/8 of a call, and a segment under CHILD_FIXED_COST over that - * share (12M) cannot pay for its child. A light-depth diff (runtime lines at most SMALL_DIFF_LINES) keeps + * share (12M) cannot pay for its child. A small diff (runtime lines at most SMALL_DIFF_LINES) keeps * its segment when the segment's calibrated calls at this run's own per-call charge stay under that. */ export const SMALL_RUN_LINES = SMALL_DIFF_LINES; diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index 15d4bdbbf..ad8933cfb 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -70,7 +70,7 @@ export function receiptDepthAtHead(repoRoot: string, base: string, binDir: strin tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null; } const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged }); - return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) }; + return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12), risk: d.risk }; } export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need]; @@ -182,7 +182,7 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged }); const have = rd?.depth; const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`; - const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`; + const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs} rounds=${r.review_loop?.rounds ?? (r.review_loop ? r.review_loop.passes + (r.review_loop.fix_checks ?? 0) : 'none')}`; if (have && !depthBelow(have, need.depth)) return { ok: true, line }; const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one'; const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; '); diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 70462347b..aba864ed5 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -129,7 +129,7 @@ export function receiptSummaryLine(r: ShipReceipt): string { 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}` - + (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : '')); + + (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.rounds !== undefined ? ` rounds=${loop.rounds}` : '') + (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)}`); if (r.base_red && typeof r.base_red.base === 'string') parts.push(`base_red=${r.base_red.base.slice(0, 12)}`); return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`; @@ -326,6 +326,7 @@ export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined { 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('rounds') !== undefined) out.rounds = n('rounds'); 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) diff --git a/package.json b/package.json index 9cc0bdbc0..9b0d4049c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.44", + "version": "1.92.45", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/SKILL.md b/ship/SKILL.md index c53c541b5..de5eaa671 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -210,8 +210,8 @@ not a commit ID. Use `git diff ` to compare these snapshots **Review policy** (docs/review-policy.md): only a finding that would fail a test, break production, or lose data or secrets blocks a pass; the rest is a PR-body note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only -raises it). Pass 1 reads the diff, later passes the range; the cap is two full -passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) +raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds, +fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 45a5c5232..68483a085 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -76,8 +76,8 @@ not a commit ID. Use `git diff ` to compare these snapshots **Review policy** (docs/review-policy.md): only a finding that would fail a test, break production, or lose data or secrets blocks a pass; the rest is a PR-body note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only -raises it). Pass 1 reads the diff, later passes the range; the cap is two full -passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) +raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds, +fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 431fd2735..8d7459135 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -210,8 +210,8 @@ not a commit ID. Use `git diff ` to compare these snapshots **Review policy** (docs/review-policy.md): only a finding that would fail a test, break production, or lose data or secrets blocks a pass; the rest is a PR-body note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only -raises it). Pass 1 reads the diff, later passes the range; the cap is two full -passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) +raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds, +fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index c53c541b5..de5eaa671 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -210,8 +210,8 @@ not a commit ID. Use `git diff ` to compare these snapshots **Review policy** (docs/review-policy.md): only a finding that would fail a test, break production, or lose data or secrets blocks a pass; the rest is a PR-body note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only -raises it). Pass 1 reads the diff, later passes the range; the cap is two full -passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) +raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds, +fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 44868bb8a..a79b3abb0 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -208,8 +208,8 @@ not a commit ID. Use `git diff ` to compare these snapshots **Review policy** (docs/review-policy.md): only a finding that would fail a test, break production, or lose data or secrets blocks a pass; the rest is a PR-body note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only -raises it). Pass 1 reads the diff, later passes the range; the cap is two full -passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) +raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds, +fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index dc9aa3ebf..1099c7e0c 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -210,8 +210,8 @@ not a commit ID. Use `git diff ` to compare these snapshots **Review policy** (docs/review-policy.md): only a finding that would fail a test, break production, or lose data or secrets blocks a pass; the rest is a PR-body note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only -raises it). Pass 1 reads the diff, later passes the range; the cap is two full -passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) +raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds, +fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/review-depth.test.ts b/test/review-depth.test.ts index 801353112..4769dd47c 100644 --- a/test/review-depth.test.ts +++ b/test/review-depth.test.ts @@ -44,8 +44,23 @@ describe('nextStep: the transition table', () => { 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 fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }).step).toBe('fix-check')); 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 still gets a fix-check, never a third full pass', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('fix-check')); + // R51: two rounds is the cap, a fix-check included. A blocker open or fixed in round 2 stops for the owner (a grant may add + // one scoped fix-check); below-floor fixes in round 2 finish with notes; a light pass whose fixes change no runtime lines is done. + test('a blocker fixed in round 2 stops for the owner, never a third round', () => { + expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('scoped fix-check') }); + expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('stop'); + }); + test('below-floor fixes in round 2 finish with notes, a fix-check included', () => { + expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBelow: true })).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') }); + expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBelow: true })).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') }); + expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBelow: true, fixedBlocking: true }).step).toBe('stop'); + }); + test('a light pass 1 whose below-floor fixes change no runtime lines is done; a runtime fix or a deeper diff keeps the fix-check', () => { + expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 0 }, 'light').step).toBe('done'); + expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 3 }, 'light').step).toBe('fix-check'); + expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 0 }, 'full').step).toBe('fix-check'); + expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }, 'light').step).toBe('fix-check'); + }); 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', () => { @@ -83,8 +98,12 @@ describe('planReview', () => { expect(p.depth).toBe('light'); expect(roles(p)).toEqual([]); }); - test('code class pass 1: specialists at medium, the native adversarial at full, the outside voice at its own', () => { - const p = planReview(base({})); + test('non-risky code pass 1 is light at any size (R51): one batched reader carrying the Red Team prompt and the native adversarial', () => { + const p = planReview(base({ diffLines: 900 })); + expect([p.depth, roles(p)]).toEqual(['light', ['specialist:batched:full', 'adversarial-native:adversarial:full']]); + }); + test('code class pass 1 at standard: specialists at medium, the native adversarial at full, the outside voice at its own', () => { + const p = planReview(base({ floor: 'standard' })); expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']); expect(batch(p)).toEqual(['testing', 'security']); expect(p.skipped).toContain('maintainability, performance, simplification (lean ensemble: read only when forced)'); @@ -162,10 +181,10 @@ describe('GSTA-131 pass-1 review repairs', () => { expect(scanNumbers({ 'test/fixtures/bench.json': ['{ "ratio": 0.74 }'] }).publishes).toBe(true); }); test('--all-specialists forces every specialist; a code diff past 200 lines or with a CRITICAL adds the Red Team', () => { - const all = planReview(base({ force: 'all' })); + const all = planReview(base({ floor: 'standard', force: 'all' })); expect(all.dispatches.filter(d => d.role === 'specialist').length).toBe(1); expect([...batch(all)!].sort()).toEqual(['api-contract', 'data-migration', 'design', 'maintainability', 'performance', 'security', 'simplification', 'state-machines', 'testing']); - const has = (o: Partial) => roles(planReview(base(o))).some(r => r.startsWith('red-team')); + const has = (o: Partial) => roles(planReview(base({ floor: 'standard', ...o }))).some(r => r.startsWith('red-team')); expect(has({ diffLines: 120 })).toBe(false); expect(has({ diffLines: 201 })).toBe(true); expect(has({ diffLines: 120, criticalFound: true })).toBe(true); @@ -175,20 +194,20 @@ describe('GSTA-131 pass-1 review repairs', () => { describe('GSTA-131 coverage: Red Team and specialist arms', () => { const roles = (p: ReturnType) => p.dispatches.map(d => `${d.role}:${d.name}`); test('Red Team runs on code past 200 lines or after a CRITICAL, and on every gate diff', () => { - expect(roles(planReview(base({ diffLines: 120 })))).not.toContain('red-team:red-team'); - expect(roles(planReview(base({ diffLines: 201 })))).toContain('red-team:red-team'); - expect(roles(planReview(base({ diffLines: 120, criticalFound: true })))).toContain('red-team:red-team'); + expect(roles(planReview(base({ diffLines: 120, floor: 'standard' })))).not.toContain('red-team:red-team'); + expect(roles(planReview(base({ diffLines: 201, floor: 'standard' })))).toContain('red-team:red-team'); + expect(roles(planReview(base({ diffLines: 120, floor: 'standard', criticalFound: true })))).toContain('red-team:red-team'); expect(roles(planReview(base({ diffLines: 20, gateTagged: 1 })))).toContain('red-team:red-team'); }); test('scope arms select their specialist, state machines default on when scope is unknown, and --force all reaches docs class', () => { - const picked = (scope: Record) => batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false', ...scope } }))); + const picked = (scope: Record) => batch(planReview(base({ floor: 'standard', scope: { SCOPE_STATE_MACHINE: 'false', ...scope } }))); expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('data-migration'); expect(picked({ SCOPE_API: 'true' })).toContain('api-contract'); expect(picked({ SCOPE_FRONTEND: 'true' })).toEqual(['testing', 'security', 'design']); expect(batch(planReview(base({ scope: {} })))).toContain('state-machines'); expect(batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('state-machines'); - expect(batch(planReview(base({ force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']); - expect(batch(planReview(base({ gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']); + expect(batch(planReview(base({ floor: 'standard', force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']); + expect(batch(planReview(base({ floor: 'standard', gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']); const docsAll = planReview(base({ paths: ['docs/a.md'], force: 'all' })); expect(docsAll.class).toBe('docs'); expect(docsAll.dispatches.filter(d => d.role === 'specialist').length).toBeGreaterThan(5); @@ -215,8 +234,12 @@ describe('review depth: the matrix', () => { expect(p.skipped.join('; ')).toContain('red-team (light depth'); expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false); }); - test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => { + test('runtime 51 non-risky code stays light (R51: size never raises the depth)', () => { const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 })); + expect([p.depth, p.runtimeLines, roles(p)]).toEqual(['light', 51, ['specialist:batched:full', 'adversarial-native:adversarial:full']]); + }); + test('standard (floor or raise): thresholds read runtime lines, reviewers read their own hunks', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454, floor: 'standard' })); expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]); expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']); expect(batch(p)).toEqual(['testing', 'security']); @@ -274,14 +297,16 @@ describe('review depth: the matrix', () => { expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full'); expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard'); expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full'); - expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('standard'); + expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('light'); + expect(depthFor({ cls: 'code', runtimeLines: 3, errors: [], risk: ['data'] })).toBe('full'); const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' })); expect(p.depth).toBe('full'); expect(p.depthReasons).toContain('floor:full'); }); test('depthOfCommitInput gives the same depth as the plan for the same input', () => { const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 }; - expect(depthOfCommitInput(i)).toMatchObject({ depth: 'standard', runtimeLines: 51, rules: DEPTH_RULES, errors: [] }); + expect(depthOfCommitInput(i)).toMatchObject({ depth: 'light', runtimeLines: 51, rules: DEPTH_RULES, errors: [], risk: [] }); + expect(depthOfCommitInput({ ...i, added: { 'src/app.ts': ['const spend_cap_usd = 5;'] } })).toMatchObject({ depth: 'full', risk: ['money'] }); expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full'); }); }); @@ -300,6 +325,7 @@ describe('review depth: diff commands and the rules hash', () => { try { const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8'); fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts')); + fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts')); const hashOf = (text: string) => { fs.writeFileSync(path.join(dir, 'review-depth.ts'), text); fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n"); @@ -369,7 +395,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () = expect(r(p)).toEqual(['fix-check:fix-check', 'red-team:red-team', 'adversarial-native:adversarial']); }); test('the scoped pass counts toward the cap, and a blocker it leaves stops for the owner', () => { - expect(nextStep(fixed(10, { fullPasses: FULL_PASS_CAP }), 'standard').step).toBe('fix-check'); + expect(nextStep(fixed(10, { fullPasses: FULL_PASS_CAP }), 'standard').step).toBe('stop'); expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true, rangeRuntimeLines: 10 }, 'light').step).toBe('stop'); const at = (m: number) => new Date(Date.UTC(2026, 9, 28, 10, m)).toISOString(); const rows = [ @@ -397,6 +423,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () = const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8'); expect(src).toContain('SCOPED_RANGE_LINES = 150'); fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts')); + fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts')); fs.writeFileSync(path.join(dir, 'review-depth.ts'), src.replace('SCOPED_RANGE_LINES = 150', 'SCOPED_RANGE_LINES = 151')); fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n"); const res = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 }); @@ -491,11 +518,11 @@ describe('lean ensemble: one batched specialist at standard and full depth (GSTA expect(specialists(full).map(d => [d.name, d.effort, d.checklists])).toEqual([['batched', 'medium', ['testing', 'security', 'state-machines']]]); }); test('a standard code plan reads one batched reader on its own hunks, and a forced checklist joins it', () => { - const p = planReview(base({ diffLines: 400, scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } })); + const p = planReview(base({ diffLines: 400, floor: 'standard', scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } })); expect(specialists(p).map(d => [d.name, d.checklists])).toEqual([['batched', ['testing', 'security']]]); expect(roles(p)).toContain('red-team:red-team:full'); - expect(batch(planReview(base({ force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']); - expect(specialists(planReview(base({ tiers: false })))[0]!.effort).toBe('host'); + expect(batch(planReview(base({ floor: 'standard', force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']); + expect(specialists(planReview(base({ floor: 'standard', tiers: false })))[0]!.effort).toBe('host'); }); }); diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts index ab7ff8240..747723390 100644 --- a/test/review-packets-removed.test.ts +++ b/test/review-packets-removed.test.ts @@ -56,6 +56,7 @@ const NO_LOSS_REPLAYS = { 'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)", 'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)", 'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged", + 'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged", } as const; type Replay = keyof typeof NO_LOSS_REPLAYS; const REVIEWER_BRIEFS: Record = { @@ -77,7 +78,8 @@ const REVIEWER_BRIEFS: Record = { 'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' }, 'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' }, 'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' }, - 'lib/review-depth.ts': { sha: '4542785a1e217d8dd054985978b42c055b9fd79082579cb7664f187f7066e48e', replay: 'GST-257' }, + 'lib/review-depth.ts': { sha: 'd649569a163c03476c6e0c5ea295f5d02088f96aab85fbc7d558f2ac2b27006a', replay: 'R51' }, + 'lib/review-risk.ts': { sha: 'dbef7475d467e17340b21a2bacb52a16a7ccb1ee66291900ed2d36dfb5cfb7f2', replay: 'R51' }, }; describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => { diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index a4d25d144..46f3df0f7 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -522,7 +522,7 @@ describe('Capy pass program (fake host, real bins)', () => { fs.mkdirSync(files, { recursive: true }); const fc: Record = {}; for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n'], ['callers', 'none\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); } - const planFlags = ['--pass', '2', '--last', 'full', '--fixed-blocking']; + const planFlags = ['--pass', '1', '--last', 'full', '--fixed-below']; const missing = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fc0'), pass: 'x19fc0', planFlags }); expect(missing).toContain('STOP: gstack-review-dispatch pass exited'); expect(missing).toContain('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests'); diff --git a/test/review-plan-cli.test.ts b/test/review-plan-cli.test.ts index f81dc7d7a..eee3e5159 100644 --- a/test/review-plan-cli.test.ts +++ b/test/review-plan-cli.test.ts @@ -35,18 +35,18 @@ describe('gstack-review-plan', () => { }); test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders specialists and audits at 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); + const plan = JSON.parse(run(r, ['--json', '--depth-floor', 'standard']).stdout); expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 }); expect(plan.dispatches.filter((d: any) => d.role === 'specialist').map((d: any) => d.effort)).toEqual(expect.arrayContaining(['medium'])); expect(plan.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'medium')).toBe(true); expect(plan.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full'); expect(plan.audit_effort).toBe('light'); - expect(run(r, []).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines'); - const host = JSON.parse(run(r, ['--json', '--no-tiers']).stdout); + expect(run(r, ['--depth-floor', 'standard']).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines'); + const host = JSON.parse(run(r, ['--json', '--no-tiers', '--depth-floor', 'standard']).stdout); expect(host.audit_effort).toBe('host'); expect(host.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'host')).toBe(true); expect(host.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full'); - expect(run(r, ['--no-tiers']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines'); + expect(run(r, ['--no-tiers', '--depth-floor', 'standard']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines'); }); 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']); @@ -132,7 +132,7 @@ describe('gstack-review-plan: review depth', () => { const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) }); const out = run(r, []).stdout; const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); - expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m); + expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10 codes=none$/m); expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`); expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,security,state-machines,red-team'); expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'); @@ -149,7 +149,7 @@ describe('gstack-review-plan: review depth', () => { }); test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => { const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' }); - const plan = JSON.parse(run(r, ['--json']).stdout); + const plan = JSON.parse(run(r, ['--json', '--depth-floor', 'standard']).stdout); expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } }); expect(plan.rules).toMatch(/^[0-9a-f]{16}$/); expect(plan.dispatches.find((d: any) => d.name === 'batched').exclude).toEqual([':(exclude)README.md']); @@ -157,7 +157,7 @@ describe('gstack-review-plan: review depth', () => { }); test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => { const r = repo({ 'src/a.ts': code(5) }); - expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5<=50,floor:full /m); + expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5,floor:full /m); const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' }); expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m); expect(run(r, ['--depth-floor', 'light']).status).toBe(2); @@ -190,7 +190,7 @@ describe('gstack-review-plan: the scoped second pass (GSTA-152 D6)', () => { const out = run(r, [...pass2, '--range-base', rangeBase]).stdout; expect(out).toMatch(/^REVIEW_PLAN: class=code pass=2 next=scoped dispatches=3 /m); expect(out).toContain(`FIX_RANGE: runtime_lines=24 base=${rangeBase}`); - expect(out).toMatch(/^NEXT: scoped \(a blocking finding was fixed on a standard diff with 24 runtime lines in the fix range/m); + expect(out).toMatch(/^NEXT: scoped \(a blocking finding was fixed on a light diff with 24 runtime lines in the fix range/m); expect(out).toContain('DISPATCH: fix-check fix-check effort=full'); expect(out).toContain('DISPATCH: red-team red-team effort=full'); expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full'); @@ -221,7 +221,7 @@ describe('gstack-review-plan: the scoped second pass (GSTA-152 D6)', () => { fs.writeFileSync(path.join(r.dir, 'src', 'b.ts'), code(2, 'b')); git('add', '.'); git('commit', '-qm', 'fix: revert the workflow edit'); const out = run(r, [...pass2, '--range-base', rangeBase]).stdout; - expect(out).toMatch(/^REVIEW_DEPTH: standard /m); + expect(out).toMatch(/^REVIEW_DEPTH: light .* step=standard$/m); expect(out).toMatch(/^NEXT: full \(a blocking finding was fixed and a fix touched a gate-class path/m); expect(JSON.parse(run(r, [...pass2, '--range-base', rangeBase, '--json']).stdout)).toMatchObject({ next: { step: 'full' }, fix_range: { touched_gate: true } }); }, 30_000); diff --git a/test/review-risk-routing.test.ts b/test/review-risk-routing.test.ts new file mode 100644 index 000000000..7466c3bdb --- /dev/null +++ b/test/review-risk-routing.test.ts @@ -0,0 +1,123 @@ +/** + * R51: review scales to risk, not size (docs/review-policy.md#review-depth, #risk-classes). The risk classes + * (lib/review-risk.ts), the round cap, and the routing table: every gate, + * security, user-data and money fixture still plans the full panel, and non-risky code is light at any size. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { classifyDiff, nextStep, planReview, scanNumbers, type PlanInput } from '../lib/review-depth'; +import { matchRisk, RISK_RULES } from '../lib/review-risk'; +import { targetedClasses } from '../lib/review-targeted'; +import { parseReviewLoop, receiptSummaryLine } from '../lib/ship-receipt'; + +const ROOT = path.join(import.meta.dir, '..'); +const risk = (codePaths: string[], added: Record = {}, scopeAuth = false) => [...matchRisk({ codePaths, added, removed: {}, scopeAuth }).keys()]; + +describe('risk classes', () => { + // Value: protects=security and user-data code reaches the full panel; fails_when=a trust-boundary or data-loss signal is missed, + // or an unrelated path or line raises a class; seam=matchRisk + test('security and data: paths and lines, each with a negative', () => { + expect(risk(['src/core/trust/owner-actions.ts'])).toEqual(['security']); + expect(risk(['lib/redact-patterns.ts'])).toEqual(['security']); + expect(risk(['src/run.ts'], { 'src/run.ts': ['const child = spawn(cmd, { shell: true });'] })).toEqual(['security']); + expect(risk(['src/view.tsx'], { 'src/view.tsx': ['
'] })).toEqual(['security']); + expect(risk(['bin/x.sh'], { 'bin/x.sh': ['eval "$CMD"'] })).toEqual(['security']); + expect(risk(['src/run.ts'], { 'src/run.ts': ['const m = re.exec(line);', 'const s = trustee.name;'] })).toEqual([]); + expect(risk(['src/core/persistence/git-door.ts'])).toEqual(['data']); + expect(risk(['src/db/users.ts'], { 'src/db/users.ts': ['await sql`DELETE FROM users WHERE id = ${id}`;'] })).toEqual(['data']); + expect(risk(['app/models/user.rb'], { 'app/models/user.rb': ['User.where(stale: true).destroy_all'] })).toEqual(['data']); + expect(risk(['src/list.ts'], { 'src/list.ts': ['// delete from the list when done', 'arr.splice(i, 1);'] })).toEqual([]); + for (const line of ['db.exec("delete from users");', 'await sql`delete from sessions where id = 1`;', 'DELETE FROM users;', 'drop database app;', 'DROP SCHEMA s;', + 'await sql`DELETE FROM ${table} WHERE id = ?`;', 'DELETE FROM users u WHERE u.stale', 'DELETE FROM sessions USING users WHERE x', 'DELETE FROM users RETURNING id', 'const q = "DELETE FROM " + table;']) + expect(risk(['src/db.ts'], { 'src/db.ts': [line] })).toEqual(['data']); + for (const line of ["const r = spawn('sh', ['-c', cmd], { timeout: 1 });", "Bun.spawn(['bash', '-lc', x]);", "fs.rmSync(t, { recursive: true, force: true });", 'rm -rf "$DIR"']) + expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual(['security']); + expect(risk(['scripts/test-pr-profile.ts', 'lib/storyboard.ts'])).toEqual([]); + expect(RISK_RULES).toMatch(/^[0-9a-f]{16}$/); + }); + // Value: protects=the targeted pass keeps its exact classes now that its matchers live in lib/review-risk.ts; fails_when=the move + // changes a money, auth or secret verdict; seam=targetedClasses over matchRisk + test('money, auth and secret come from the same matchers the targeted pass reads', () => { + const lines = { 'src/run.ts': ['const spend_cap_usd = 5;', 'const k = process.env.OPENAI_API_KEY;'] }; + expect(risk(['src/run.ts', 'src/auth/session.ts'], lines, true)).toEqual(['money', 'auth', 'secret']); + expect(targetedClasses({ paths: ['src/run.ts', 'src/auth/session.ts'], added: lines, removed: {}, scope: { SCOPE_AUTH: 'true' }, gateTagged: 0 }).classes).toEqual(['money', 'auth', 'secret']); + }); +}); + +describe('UI text and SVGs stay code (R51: the docs paths are deferred to an allowlist classifier)', () => { + // Value: protects=a copy edit, a Vue binding or a namespaced SVG script never plans as docs; fails_when=any of them leaves the code + // class or its lines skip the risk scan; seam=classifyDiff + test.each([ + ['a JSX copy edit', 'src/App.tsx', ['

Old copy

'], ['

New copy

']], + ['a Vue binding', 'src/App.vue', ['
Profile
'], ['
Profile
']], + ['a locale value', 'locales/en.yml', ['save: "Save"'], ['save: ""']], + ['a namespaced SVG script', 'docs/chart.svg', [], ['window.x=42']], + ])('%s is code', (_n, p, removed, added) => { + expect(classifyDiff({ paths: [p], gateTagged: 0, added: { [p]: added }, removed: { [p]: removed }, perPath: [{ path: p, lines: removed.length + added.length, binary: false }] }).class).toBe('code'); + }); +}); + +describe('round 2 never ships an unread gate edit', () => { + // Value: protects=a gate-class edit after round 1 always gets the owner's eyes; fails_when=a round-2 or fix-check gate edit + // returns done; seam=nextStep + test('a gate edit in round 2 or in the fix-check stops for the owner; a plain below-floor fix there finishes', () => { + expect(nextStep({ fullPasses: 2, last: 'full', fixedBelow: true, touchedGate: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('gate-class') }); + expect(nextStep({ fullPasses: 1, last: 'fix-check', touchedGate: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('gate-class') }); + expect(nextStep({ fullPasses: 2, last: 'full', fixedBelow: true }).step).toBe('done'); + }); +}); + +const none = scanNumbers({}); +const plan = (o: Partial) => planReview({ paths: ['src/app.ts'], scope: { SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 10, numbers: none, history: { fullPasses: 0, last: 'none' }, ...o }); +const roles = (p: ReturnType) => [...new Set(p.dispatches.map(d => d.role))]; +const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice']; +const captured = (name: string): Partial => { + const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-depth', name + '.json'), 'utf8')); + return { paths: x.paths, deleted: x.deleted, stampOnly: x.stamp_only ?? [], scope: x.scope, gateTagged: x.gate_tagged, diffLines: x.diff_lines, numbers: x.numbers, perPath: x.per_path }; +}; +const one = (p: string, lines: string[] = ['export const a = 1;']) => ({ paths: [p], added: { [p]: lines }, removed: {}, perPath: [{ path: p, lines: lines.length, binary: false }] }); + +describe('routing: every gate, security, user-data and money fixture plans the full panel', () => { + // Value: protects=R51 never lowers review on risky code; fails_when=a gate or risk fixture plans less than outside voice, Red Team, + // batched specialist and the native adversarial pass, or loses the targeted reader it had; seam=planReview + test.each([ + ['captured gate fixture', captured('code'), []], + ['gate path', one('.github/workflows/ci.yml', ['on: push']), []], + ['gate tag', { ...one('src/a.ts'), gateTagged: 2 }, ['gate']], + ['deleted test', { ...one('src/a.ts'), deleted: ['test/a.test.ts'] }, []], + ['auth scope', { ...one('src/auth/session.ts'), scope: { SCOPE_AUTH: 'true' } }, ['auth']], + ['migration scope', { ...one('db/migrate/001.sql', ['ALTER TABLE users ADD COLUMN email text;']), scope: { SCOPE_MIGRATIONS: 'true' } }, []], + ['secret path', one('lib/credentials.ts'), ['secret']], + ['secret line', one('src/client.ts', ['const k = process.env.OPENAI_API_KEY;']), ['secret']], + ['money path', one('src/billing/invoice.ts'), ['money']], + ['money line', one('src/charge.ts', ['export const spend_cap_usd = 5;']), ['money']], + ['security path', one('lib/redact-patterns.ts'), []], + ['security line', one('src/run.ts', ['const run = new Function(body);']), []], + ['user-data path', one('src/core/persistence/store.ts'), []], + ['user-data line', one('src/db/purge.ts', ['await db.users.deleteMany({ where: { stale: true } });']), []], + ] as const)('%s', (_n, o, targeted) => { + const p = plan({ ...(o as Partial), targeted: { classes: [...targeted] } }); + expect(p.depth).toBe('full'); + expect(FULL_PANEL.filter(r => !roles(p).includes(r as never))).toEqual([]); + expect(roles(p).includes('targeted')).toBe(targeted.length > 0); + }); + test('non-risky code is light at any size: one batched reader with the Red Team prompt and the native adversarial', () => { + const p = plan({ ...one('src/util.ts', Array.from({ length: 900 }, (_, n) => `export const v${n} = ${n};`)), diffLines: 900 }); + expect([p.depth, roles(p)]).toEqual(['light', ['specialist', 'adversarial-native']]); + const docs = plan({ paths: ['docs/evals/run.md'], perPath: [{ path: 'docs/evals/run.md', lines: 400, binary: false }], numbers: scanNumbers({ 'docs/evals/run.md': ['recall 33/41'] }) }); + expect([docs.depth, roles(docs)]).toEqual(['light', ['outside-voice']]); + }); +}); + +describe('the receipt records the tier and the rounds', () => { + // Value: protects=a receipt carries review_loop.rounds and the summary line prints depth and rounds; fails_when=the parser drops + // rounds or a legacy loop without it gains one; seam=parseReviewLoop and receiptSummaryLine + test('rounds parse and print; a legacy loop prints no rounds', () => { + expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, rounds: 2 })).toMatchObject({ rounds: 2 }); + const r = { pr: 7, head: 'a'.repeat(40), base: 'main', review_depth: { depth: 'light' }, review_loop: { passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, rounds: 2 } } as never; + expect(receiptSummaryLine(r)).toContain('depth=light passes=1 blocking_found=0 deferred=0 fix_checks=1 rounds=2'); + const legacy = { pr: 7, head: 'a'.repeat(40), base: 'main', review_loop: { passes: 2, blocking_found: 1, deferred: 0 } } as never; + expect(receiptSummaryLine(legacy)).not.toContain('rounds='); + }); +}); diff --git a/test/review-scope-followups.test.ts b/test/review-scope-followups.test.ts index ec72f31c5..d2ff1aed5 100644 --- a/test/review-scope-followups.test.ts +++ b/test/review-scope-followups.test.ts @@ -30,7 +30,7 @@ describe('late defers from Step 11', () => { { skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:25:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true, action: 'deferred' }] }, ]; const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z'); - expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen' }); + expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen', rounds: 2 }); expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=2 noted=0 fixed=1 scope=frozen'); expect(LATE_DEFER_SKILL).toBe('review-late-defer'); // A `review` row logged without a start token would be the newest review row and read UNVERIFIED; the late-defer row is not a diff review at all. diff --git a/test/review-scope.test.ts b/test/review-scope.test.ts index ec9108865..e6bdc2ace 100644 --- a/test/review-scope.test.ts +++ b/test/review-scope.test.ts @@ -208,7 +208,7 @@ describe('loop summary', () => { { skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true }] }, ]; const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z'); - expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' }); + expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 }); expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen'); }); @@ -223,7 +223,7 @@ describe('loop summary', () => { { skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] }, { skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] }, ]; - expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen' }); + expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3 }); }); }); @@ -421,7 +421,7 @@ describe('gstack-review-scope (bin)', () => { { skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] }, ]; fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n'); - const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' }; + const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 }; const out = path.join(repo.root, 'review-loop.json'); const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']); expect(r.status).toBe(EXIT.ok); diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts index a3232418e..375cf1d01 100644 --- a/test/review-targeted.test.ts +++ b/test/review-targeted.test.ts @@ -110,8 +110,8 @@ describe('planReview dispatches the targeted reader', () => { test('at light, standard and full depth when a class hit, never when none did', () => { expect(plan({ targeted: { classes: ['money'] } })).toMatchObject({ depth: 'light' }); expect(roles(plan({ targeted: { classes: ['money'] } }))).toContain('targeted full'); - expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }).depth).toBe('standard'); - expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }))).toContain('targeted full'); + expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] }, floor: 'standard' }).depth).toBe('standard'); + expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] }, floor: 'standard' }))).toContain('targeted full'); expect(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }).depth).toBe('full'); expect(roles(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }))).toContain('targeted full'); expect(roles(plan({ targeted: { classes: [] } }))).not.toContain('targeted full'); @@ -183,6 +183,18 @@ function planRepo(f: Fixture) { const run = (args: string[]) => spawnSync(PLAN, ['--base', 'main', ...(args.includes('--gate-tagged') ? [] : ['--gate-tagged', '0']), ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: state } }); return { dir, run, write }; } +/** + * R51 (risk, not size): the depth each fixture plans now. Every risk or gate fixture plans full and keeps every role main + * dispatched, plus the full panel; a non-risky fixture keeps main's reviewers except the roles listed (size no longer raises it). + */ +const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice']; +const R51: Record = { + 'none-light': { depth: 'light', panel: false }, 'none-standard': { depth: 'light', panel: false, dropped: ['outside-voice'] }, 'none-docs': { depth: 'light', panel: false }, + 'none-tests': { depth: 'light', panel: false }, 'none-costume': { depth: 'light', panel: false }, 'none-gate-path': { depth: 'full', panel: true }, + 'secret-light': { depth: 'full', panel: true }, 'secret-path': { depth: 'full', panel: true }, 'secret-removed': { depth: 'full', panel: true }, 'gate-tagged': { depth: 'full', panel: true }, + 'money-light': { depth: 'full', panel: true }, 'money-path': { depth: 'full', panel: true }, 'money-removed': { depth: 'full', panel: true }, 'money-standard': { depth: 'full', panel: true }, + 'auth-path': { depth: 'full', panel: true }, 'money-fix-check': { depth: 'full', panel: false }, 'money-pass2': { depth: 'full', panel: false }, 'gate-repeat': { depth: 'full', panel: false }, +}; const field = (out: string, prefix: string, key: string) => new RegExp(`^${prefix}.* ${key}=(\\S+)`, 'm').exec(out)?.[1]; describe('gstack-review-plan prints TARGETED and dispatches the targeted reader', () => { @@ -190,17 +202,23 @@ describe('gstack-review-plan prints TARGETED and dispatches the targeted reader' // standard and full depth and on fix-check, scoped and repeat steps, is still dispatched unchanged, and the only addition is the // targeted reader on a class diff; fails_when=the change drops, renames or re-efforts any reviewer, or adds the reader on a diff // with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question) - test('the dispatch-superset no-loss check: on every fixture the branch dispatches main\'s reviewers plus only the targeted reader', () => { + // R51 changes the table: risk and gate fixtures plan full (every role main dispatched, plus the full panel), and the one + // non-risky standard fixture drops only the outside voice; fails_when=a risk fixture loses a role main dispatched or misses + // the full panel, or a non-risky fixture loses a role the R51 table does not list + test('the dispatch-superset no-loss check: risk and gate fixtures keep every role main dispatched plus the full panel; non-risky ones drop only what R51 lists', () => { expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort()); - expect(new Set(Object.values(MAIN).map(m => m.depth))).toEqual(new Set(['light', 'standard', 'full'])); + expect(Object.keys(R51).sort()).toEqual(Object.keys(FIXTURES).sort()); + const role = (d: string) => d.split(' ')[0]!; for (const [name, f] of Object.entries(FIXTURES)) { const r = planRepo(f).run(f.args ?? []); expect(r.status, `${name}: ${r.stderr}`).toBe(0); const got = r.stdout.split('\n').filter(l => l.startsWith('DISPATCH: ')).map(l => l.slice('DISPATCH: '.length)); - const want = MAIN[name]!; - expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${want.depth} `); - expect(got.filter(d => !want.main.includes(d)), name).toEqual(want.classes === 'none' ? [] : ['targeted targeted effort=full']); - expect(want.main.filter(d => !got.includes(d)), name).toEqual([]); + const want = MAIN[name]!, now = R51[name]!; + expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${now.depth} `); + const roles = new Set(got.map(role)); + expect([...new Set(want.main.map(role))].filter(x => !roles.has(x)), name).toEqual(now.dropped ?? []); + if (now.panel) expect(FULL_PANEL.filter(x => !roles.has(x)), name).toEqual([]); + expect(roles.has('targeted'), name).toBe(want.classes !== 'none'); expect(field(r.stdout, 'TARGETED:', 'classes'), name).toBe(want.classes); } }, 180_000); diff --git a/test/ship-frozen-scope-actor.test.ts b/test/ship-frozen-scope-actor.test.ts index a970913d7..5c65d6ea9 100644 --- a/test/ship-frozen-scope-actor.test.ts +++ b/test/ship-frozen-scope-actor.test.ts @@ -86,7 +86,7 @@ posixTest('a scripted actor that fixes the diff, defers the pre-existing defect await act(f); const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' }); expect(verdict.failures).toEqual([]); - expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' }); + expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, rounds: 2, scope: 'frozen' }); const rows = verdict.evidence.rows as Array>; expect(rows.map(r => r.pass)).toEqual([1, 2]); expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]); diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts index cb86a7872..4b7cf240f 100644 --- a/test/ship-receipt-depth.test.ts +++ b/test/ship-receipt-depth.test.ts @@ -57,25 +57,26 @@ describe('write records the review depth at the checked-out head', () => { describe('the queue refuses a receipt below the depth its head needs', () => { // Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do; // fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt - test('light on a standard head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => { + // R51: size never raises the depth, so the head that needs more than light is a risk-class one (a money path needs full). + test('standard on a full (money) head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => { const repo = makeFixtureRepo(); repos.push(repo); - const head = commit(repo, { 'lib/big.js': lines(80) }); + const head = commit(repo, { 'lib/billing.js': lines(80) }); const body = path.join(repo.root, 'body.md'); expect(writeReceipt(repo, body).status).toBe(EXIT.ok); - expect(receiptIn(body).review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 }); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', runtime_lines: 80, risk: ['money'] }); const base = repo.git('rev-parse', 'origin/main').slice(0, 12); const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]); const good = bind(fs.readFileSync(body, 'utf8')); expect(good.ok).toBe(true); - expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard runtime_lines=80 class=code'); + expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=full required=full runtime_lines=80 class=code'); - const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; })); + const low = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; })); expect(low.ok).toBe(false); if (low.ok) return; expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); - expect(low.depthLine).toContain('receipt=light required=standard runtime_lines=80'); + expect(low.depthLine).toContain('receipt=standard required=full runtime_lines=80'); expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`); - expect(low.message).toContain('runtime=80>50'); + expect(low.message).toContain('risk:money'); expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`); const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); })); @@ -218,7 +219,8 @@ describe('the queue and a scoped second pass (GSTA-152 D6)', () => { expect(receiptIn(body).review_loop).toMatchObject({ passes: 2, dispatches: loop.dispatches }); const bound = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]); expect(bound.ok).toBe(true); - expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard'); + expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=light required=light'); + expect(bound.ok && bound.depthLine).toMatch(/ rounds=2$/); }, 60_000); }); From dd2416f4eb1aa6099d841c9206dea4f85cec281d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 15:20:55 -0800 Subject: [PATCH 07/11] v1.92.46.0 fix(brain-sync): never commit a synced JSON file that does not parse (#3763) (#3796) * test: synced JSON that does not parse is never committed and is reported (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): hold a synced JSON file that does not parse; --profile and doctor report it (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): read JSON index blobs with one cat-file batch, no python subprocess (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): block instead of committing a one-sided merge when a held file is mid-merge (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): merge-block status names the real recovery and waiting files (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): JSON holds in the --status, held-files and two-machine sections (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(brain-sync): #3704 fixtures write valid JSON to developer-profile.json (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): keep the reviewed JSON paragraph wording after the main merge (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-brain-sync | 79 ++++++++++-- bin/gstack-developer-profile | 12 ++ bin/gstack-doctor | 18 ++- docs/gbrain-sync-errors.md | 9 +- docs/gbrain-sync.md | 12 +- package.json | 2 +- test/brain-sync.test.ts | 176 ++++++++++++++++++++++---- test/gstack-developer-profile.test.ts | 18 +++ test/gstack-doctor.test.ts | 19 +++ test/gstack-skill-start.test.ts | 2 +- 13 files changed, 319 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a2763c2..2619a01d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.92.46.0] - 2026-11-01 + +**brain-sync no longer publishes a synced JSON file that doesn't parse, and the profile and doctor now say when one is broken.** When two machines edit `developer-profile.json` before syncing, git's merge can leave conflict markers in it. brain-sync committed and pushed that file, so every machine got a profile that no longer parsed, while `gstack-developer-profile --profile` and `gstack-doctor` both reported everything ok (#3763). + +### Fixed +- **brain-sync holds back a synced `.json` file that doesn't parse** (`bin/gstack-brain-sync`). Before each commit it parses every staged `*.json` file (today `developer-profile.json` and `projects/*/decisions.active.json`). A file that doesn't parse is held with rule `invalid-json`, along with any file coupled to it (`decisions.jsonl` with its snapshot). It stays queued and syncs on the next run after you repair it; everything else syncs as usual. The fix line says to repair the file. It does not offer `--skip-file`, which would stop the profile syncing for good. +- **A held file in an unfinished git merge stops the sync instead of committing half the merge.** If a held file is part of a merge that git could not finish, committing the merge without it would quietly keep only this machine's version and drop the other machine's edit. brain-sync now commits nothing and reports `blocked` until you repair the file; the merge and the queue are kept. +- **Held-status messages name the right cause.** The skill-start attention line, `--status` and `gstack-doctor` no longer blame the secret scan for every held file; doctor reports how many held files are not valid JSON. +- **`gstack-doctor` warns when a synced JSON file holds conflict markers**, naming the file and how to restore it from the sync repo's history. + +### Changed +- **`gstack-developer-profile --profile` now exits 1 when the profile is not valid JSON.** It prints ` is not valid JSON: ` on stderr (noting conflict markers when it finds them) and nothing on stdout. It used to print the broken file and exit 0, so scripts that read `--profile` should check its exit status. + +### Tests +- `test/brain-sync.test.ts`: a queued profile with conflict markers, a marker-free syntax error, an invalid `decisions.active.json` holding its coupled `decisions.jsonl` until repaired, two machines editing the profile (the other machine's edit stays on the remote), and an unfinished merge with a held profile. `test/gstack-developer-profile.test.ts`: `--profile` on an unparseable profile. `test/gstack-doctor.test.ts`: a conflict-marker warning, and a JSON hold not attributed to the secret scan. All fail on main. + ## [1.92.45.0] - 2026-11-01 ### /ship review scales to risk, not size diff --git a/VERSION b/VERSION index 603bff1c3..cbd266e1d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.45.0 +1.92.46.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d00c52753..7d20d84f4 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.45.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.46.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 25625c2bb..c95c6985e 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -38,11 +38,11 @@ # applies; `ok` and `idle` print none. is this script's absolute path # and the held count. Rows are read by test/gstack-skill-start.test.ts. # -# held trigger: status=held: the secret scan held back flagged files and synced the rest -# line: ARTIFACTS_SYNC: attention: status=held held=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status -# fix: edit the file so the scan no longer matches, or --skip-file (permanent; undo with --unskip-file ) +# held trigger: status=held: the secret scan or the JSON check held back flagged files and synced the rest +# line: ARTIFACTS_SYNC: attention: status=held held=. Sync is holding back file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: --status +# fix: edit the file so the scan no longer matches (or repair a file that is not valid JSON), or --skip-file (permanent; undo with --unskip-file ) # blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed -# line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: --status +# line: ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: --status # fix: edit or skip the files named by --status, or clear the batch with --drop-queue --yes # push_failed trigger: status=push_failed: a commit is saved locally but the push failed # line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: --status @@ -144,7 +144,7 @@ remote_auth_hint() { # Status file schema (one JSON object; readers: --status, --attention, doctor): # status ok | idle | held | collision | blocked | push_failed | error # (collision: files were held only for case-colliding paths) -# held_count files the secret scan held back this drain (hits + dependents) +# held_count files the secret scan or the JSON check held back this drain (hits + dependents) # case_held queued files held because their path differs only by letter # case from another path in the repo or the batch # case_names the colliding path component names, comma-separated and @@ -953,6 +953,8 @@ for rule, path in hits: "rule": rule, "dependents": deps, "fixes": [ + f"{path} is not valid JSON (often git conflict markers from a merge); repair it and the next sync re-checks it" + ] if rule == "invalid-json" else [ f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it", f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})", ], @@ -982,6 +984,52 @@ for path, rows in ((unstage_out, unstage), (wait_out, waiting)): PYEOF } +# Append "invalid-json" to the hits file $2 for every staged *.json +# path in the NUL-separated names file $1 whose index blob does not parse +# (#3763): a git merge can leave conflict markers in a synced JSON file, and +# committing it spreads an unreadable file to every machine. A path the +# secret scan already flagged is held for that reason alone. One +# `git cat-file --batch` reads the index blobs; a staged deletion has none. +append_invalid_json_hits() { + python3 - "$1" "$2" "$1.json" <<'PYEOF' || return 1 +import sys +names_path, hits_path, out_path = sys.argv[1:4] +with open(names_path, "rb") as f: + names = [p for p in f.read().decode("utf-8", "surrogateescape").split("\0") if p.endswith(".json") and "\n" not in p] +with open(hits_path, encoding="utf-8", errors="surrogateescape") as f: + flagged = {l.rstrip("\r\n").split("\t", 1)[-1] for l in f if "\t" in l} +with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out: + for p in names: + if p not in flagged: + out.write(":" + p + "\n") +PYEOF + [ -s "$1.json" ] || return 0 + git -C "$GSTACK_HOME" cat-file --batch < "$1.json" > "$1.json.blobs" 2>/dev/null || return 1 + python3 - "$1.json" "$1.json.blobs" "$2" <<'PYEOF' +import json, sys +batch_path, blobs_path, hits_path = sys.argv[1:4] +with open(batch_path, encoding="utf-8", errors="surrogateescape") as f: + names = [l[1:].rstrip("\n") for l in f if l.startswith(":")] +with open(blobs_path, "rb") as f: + data = f.read() +pos = 0 +with open(hits_path, "a", encoding="utf-8", errors="surrogateescape", newline="\n") as out: + for p in names: + nl = data.index(b"\n", pos) + header = data[pos:nl].split(b" ") + pos = nl + 1 + if header[-1] == b"missing": + continue + size = int(header[2]) + blob = data[pos:pos + size] + pos += size + 1 + try: + json.loads(blob.decode("utf-8")) + except ValueError: + out.write("invalid-json\t" + p + "\n") +PYEOF +} + # Fail closed: unstage everything, keep the queue, status blocked. block_drain() { git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true @@ -994,7 +1042,9 @@ block_drain() { # one scanner pass attributes findings to staged paths, plan_holds decides # what is held or waits, those paths are unstaged and leave $1 (their records # stay queued), then the whole remaining staged diff must re-scan clean. -# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain. +# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain, or +# when a path would be held during an unfinished merge (the merge commit +# would silently keep only this side of it). hold_flagged_paths() { local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out WAITING=0 @@ -1003,6 +1053,7 @@ hold_flagged_paths() { git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1 git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1 [ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; } + [ "$rc" -eq 0 ] && { append_invalid_json_hits "$scan.names" "$scan.hits" || rc=1; } # A successful add need not stage the requested spelling or current bytes # (case-insensitive indexes and assume-unchanged can both report success). # Reuse the scanner's name snapshot; refusal preserves the index and queue. @@ -1023,6 +1074,12 @@ hold_flagged_paths() { ST_HELD=$(cat "$scan.held") WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true) WAITING=${WAITING:-0} + if [ -s "$scan.unstage" ] && git -C "$GSTACK_HOME" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then + ST_DRAINABLE=0 + write_status "blocked" "a git merge in $GSTACK_HOME is unfinished and $(grep -c . "$scan.unstage") file(s) are held back or wait for their coupled group; committing the merge without them would drop the other side's changes. Repair the files held[] names (they stay queued and sync on the next run), or finish the merge yourself; --skip-file and --drop-queue do not clear this. Nothing was committed; the merge and the queue are kept" + echo "BRAIN_SYNC: blocked: unfinished merge with held files" >&2 + return 1 + fi if [ -s "$scan.unstage" ]; then if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then block_drain "git could not unstage the held files" @@ -1347,11 +1404,13 @@ subcmd_once() { hold_flagged_paths "$paths_file" "$groups_file" || exit 0 ST_DRAINABLE=$(( ADD_FAILED + WAITING )) [ "$WAITING" -gt 0 ] && ST_NOTE="$ST_NOTE; $WAITING file(s) of a coupled group wait for a consistent generation (still queued)" - local held_n ok_code="ok" + local held_n json_n ok_code="ok" held_n=$(printf '%s' "$ST_HELD" | grep -o '"rule"' | wc -l | tr -d ' ') + json_n=$(printf '%s' "$ST_HELD" | grep -o '"rule": "invalid-json"' | wc -l | tr -d ' ') if [ "$held_n" -gt 0 ]; then ok_code="held" - ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes" + [ "$held_n" -gt "$json_n" ] && ST_NOTE="$ST_NOTE; the secret scan held back $(( held_n - json_n )) flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes" + [ "$json_n" -gt 0 ] && ST_NOTE="$ST_NOTE; $json_n synced file(s) are not valid JSON (often git conflict markers) and were held back (and any coupled files), NOT synced and still queued; see held[] for each file's fixes" echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2 elif [ "$ST_CASE_HELD" -gt 0 ]; then ok_code="collision" @@ -1552,8 +1611,8 @@ subcmd_attention() { case "$last_push" in $iso) ;; *) last_push="" ;; esac case "$code" in - held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. The secret scan is holding back $held file(s); everything else still syncs. See which files and how to fix them: $bin --status" ;; - blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: $bin --status" ;; + held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. Sync is holding back $held file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: $bin --status" ;; + blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: $bin --status" ;; push_failed) echo "ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: $bin --status" ;; error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;; unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;; diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index b5db05262..f64178e2b 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -394,6 +394,18 @@ do_read() { # ----------------------------------------------------------------------- do_profile() { ensure_profile + local PARSE_ERR + if ! PARSE_ERR=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + const text = require('fs').readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'); + try { JSON.parse(text); } catch (e) { + const markers = /^(<{7}|={7}|>{7})( |\$)/m.test(text) ? ' (it holds git conflict markers from a merge)' : ''; + console.log(e.message + markers); + process.exit(1); + } + " 2>&1); then + echo "gstack-developer-profile: $PROFILE_FILE is not valid JSON: $PARSE_ERR; repair the file or restore it from git -C $GSTACK_HOME log -- developer-profile.json" >&2 + return 1 + fi cat "$PROFILE_FILE" } diff --git a/bin/gstack-doctor b/bin/gstack-doctor index 0690fb3a9..18adb49fc 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -275,7 +275,7 @@ check_codex_probe() { } check_sync() { - local mode file json status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status" + local mode file json json_held status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status" cfg artifacts_sync_mode off; mode=$_cfg if [ ! -d "$STATE_ROOT/.git" ]; then row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init" @@ -285,6 +285,11 @@ check_sync() { row "not configured" "artifacts sync" "artifacts_sync_mode off; cross-machine artifacts sync is unavailable" "$BIN/gstack-config set artifacts_sync_mode artifacts-only" return fi + for f in "$STATE_ROOT/developer-profile.json" "$STATE_ROOT"/projects/*/decisions.active.json; do + [ -f "$f" ] && grep -qE '^(<{7}|>{7})( |$)' "$f" 2>/dev/null || continue + row warn "artifacts sync" "${f#"$STATE_ROOT"/} holds git conflict markers and does not parse" "repair the file, or restore it from git -C $STATE_ROOT log -- ${f#"$STATE_ROOT"/}" + return + done for f in "$STATE_ROOT/.brain-queue.d"/*.json; do [ -f "$f" ] && queue=$((queue + 1)); done for f in "$STATE_ROOT/.brain-queue.jsonl" "$STATE_ROOT/.brain-queue.jsonl.migrating"; do [ -f "$f" ] && queue=$((queue + $(wc -l < "$f" | tr -d ' '))) @@ -306,8 +311,15 @@ check_sync() { [ -n "$push" ] || push=$(epoch_of "$(head -1 "$STATE_ROOT/.brain-last-push" 2>/dev/null)") now=$(date +%s) case "$status" in - held) row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"; return ;; - blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;; + held) + json_held=$(printf '%s' "$json" | grep -o '"rule": *"invalid-json"' | wc -l | tr -d ' ') + if [ "${json_held:-0}" -gt 0 ]; then + row warn "artifacts sync" "status held ($json_held synced file(s) are not valid JSON${held:+; $held held in all})" "$fix" + else + row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix" + fi + return ;; + blocked) row warn "artifacts sync" "status blocked (nothing was committed; --status names the cause)" "$fix"; return ;; push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;; esac if [ -n "$case_names" ]; then diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index cd81aa9dd..2d14d7f07 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -38,8 +38,8 @@ cause, the held files and the fix. | State | Trigger | Attention line (after `ARTIFACTS_SYNC: attention: `) | Fix | |---|---|---|---| -| `held` | The secret scan held back flagged files and synced the rest | `status=held held=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status` | Edit the file so the scan no longer matches, or ` --skip-file ` (permanent; undo with `--unskip-file `) | -| `blocked` | A re-scan still flagged after the hold, so nothing was committed | `status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: --status` | Edit or skip the files `--status` names, or clear the batch with ` --drop-queue --yes` | +| `held` | The secret scan or the JSON check held back flagged files and synced the rest | `status=held held=. Sync is holding back file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: --status` | Edit the file so the scan no longer matches, or ` --skip-file ` (permanent; undo with `--unskip-file `), or repair a file that is not valid JSON | +| `blocked` | A re-scan still flagged after the hold, or a file would be held during an unfinished git merge, so nothing was committed | `status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: --status` | Edit or skip the files `--status` names, or clear the batch with ` --drop-queue --yes`. For an unfinished merge, repair the held files or finish the merge; skipping files or dropping the queue does not clear it | | `push_failed` | A commit is saved locally but the push failed | `status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: --status` | The fix `--status` names (usually `gh auth status`) | | `error` | The drain stopped before committing and kept the queue | `status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: --status` | The cause `--status` names: a git index lock, a commit hook, a `git add` failure | | `unknown` | The status file holds a status code outside this table | `status=unknown. The sync status file is unreadable. See it: --status` | Run ` --once` to rewrite it | @@ -76,6 +76,11 @@ both fixes: ~/.claude/skills/gstack/bin/gstack-brain-sync --drop-queue --yes ``` +A synced `.json` file that does not parse (often git conflict markers from a +merge) is held the same way under rule `invalid-json`. Its only fix is to +repair the file; `--skip-file` is not offered, because it would stop that +file syncing for good. The next sync re-checks it. + **Git index lock.** A git process killed mid-write leaves `~/.gstack/.git/index.lock`. The sync removes it once it is older than 10 minutes, because every gstack writer to `~/.gstack` takes the same sync lock diff --git a/docs/gbrain-sync.md b/docs/gbrain-sync.md index 43d7f2e00..756e8186f 100644 --- a/docs/gbrain-sync.md +++ b/docs/gbrain-sync.md @@ -91,8 +91,8 @@ gstack-brain-sync --status ``` Shows: the status (`ok`, `idle`, `held`, `collision`, `blocked`, -`push_failed`, `error`), files held back by the secret scan with their -fixes, files held because their path differs only by letter case from +`push_failed`, `error`), files held back by the secret scan or because +they are not valid JSON, with their fixes, files held because their path differs only by letter case from another path (`case_held`, `case_names`; see [troubleshooting](troubleshooting.md#artifact-case-collision)), the `drainable` count, the last sync and push times, pending queue depth, and @@ -156,7 +156,7 @@ to it); everything else syncs. The held file stays queued and is re-scanned at every sync, and skill start prints: ``` -ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: --status +ARTIFACTS_SYNC: attention: status=held held=1. Sync is holding back 1 file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: --status ``` To remediate: @@ -200,6 +200,12 @@ is kept in a git stash; `gstack-brain-sync --status` names the stash and the `git -C ~/.gstack --literal-pathspecs restore --source= -- ` command that brings it back. +JSON files such as `developer-profile.json` have no merge driver, so a +merge can leave conflict markers in them. The sync parses every staged +`.json` file and holds back one that does not parse (status `held`, rule +`invalid-json`) until you repair it, and `gstack-doctor` warns when a synced profile or +decision snapshot holds conflict markers. + ## Cross-machine pull cadence The preamble runs `git fetch` + `git merge --ff-only` once per 24 hours diff --git a/package.json b/package.json index 9b0d4049c..95e77f142 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.45", + "version": "1.92.46", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index ff7ee1432..0e5d60fde 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -843,7 +843,7 @@ describe('gstack-brain-sync per-file hold', () => { } function setup() { init(); - write(SHARED, '# shared v1\n'); + write(SHARED, '{"note":"shared v1"}\n'); write(OTHER, '# other v1\n'); expect(drain().status).toBe(0); expect(statusJson().status).toBe('ok'); @@ -856,15 +856,15 @@ describe('gstack-brain-sync per-file hold', () => { test('a dirty tracked file the remote did not touch rides through the merge', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); write(OTHER, '# other, local edit not queued\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); expect(statusJson().status).toBe('ok'); expect(remoteFiles()).toContain(CLEAN); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n'); }); /** The restore command the status names, run as printed. */ @@ -878,17 +878,17 @@ describe('gstack-brain-sync per-file hold', () => { test('a dirty tracked file the remote also changed no longer leaves the push failed', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); - write(SHARED, '# shared, local edit not queued\n', false); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('ok'); expect(remoteFiles()).toContain(CLEAN); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); // The merged version stays in the working tree: no conflict markers, // nothing staged, and nothing stale for a later sync to publish. - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n'); expect(unmerged()).toBe(''); expect(staged()).toBe(''); expect(s.message).toContain(`local edits to ${SHARED} conflicted`); @@ -900,23 +900,23 @@ describe('gstack-brain-sync per-file hold', () => { write(OTHER, '# other v2\n'); expect(drain().status).toBe(0); expect(statusJson().status).toBe('ok'); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); - expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('# shared, local edit not queued\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); + expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('{"note":"shared, local edit not queued"}\n'); // The printed command brings the local edit back. expect(recover(s.message)).toBe(sha!); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a local deletion of a file the remote changed keeps the remote file and stashes the deletion', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); fs.rmSync(path.join(tmpHome, SHARED)); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('ok'); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n'); expect(unmerged()).toBe(''); expect(s.message).toContain(`local edits to ${SHARED} conflicted`); recover(s.message); @@ -934,7 +934,7 @@ describe('gstack-brain-sync per-file hold', () => { } finally { fs.rmSync(peer, { recursive: true, force: true }); } - write(SHARED, '# shared, local edit not queued\n', false); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); @@ -943,7 +943,7 @@ describe('gstack-brain-sync per-file hold', () => { expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false); expect(unmerged()).toBe(''); recover(s.message); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a conflicting file whose name looks like a pattern resets only itself', () => { @@ -954,16 +954,16 @@ describe('gstack-brain-sync per-file hold', () => { expect(git(['merge', '-q', '--ff-only', 'origin/main']).status).toBe(0); peerCommit(GLOB, '# glob from the peer\n'); write(GLOB, '# glob, local edit not queued\n', false); - write(SHARED, '# shared, local edit not queued\n', false); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('ok'); expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob from the peer\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); recover(s.message); expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob, local edit not queued\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a file that never checks out clean does not strand the other local edits', () => { @@ -973,7 +973,7 @@ describe('gstack-brain-sync per-file hold', () => { expect(drain().status).toBe(0); fs.mkdirSync(path.join(tmpHome, '.git', 'info'), { recursive: true }); fs.writeFileSync(path.join(tmpHome, '.git', 'info', 'attributes'), '*.md text eol=lf\n'); - peerCommit(SHARED, '# shared from the peer\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); write(OTHER, '# other, local edit not queued\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); @@ -987,28 +987,28 @@ describe('gstack-brain-sync per-file hold', () => { setup(); write(OTHER, '# other, older stashed work\n', false); expect(git(['stash', 'push', '-q', '-m', 'older work']).status).toBe(0); - peerCommit(SHARED, '# shared from the peer\n'); - write(SHARED, '# shared, local edit not queued\n', false); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); const shim = gitShim('case " $* " in *" stash push "*) exit 0;; esac'); expect(drain(shim.env).status).toBe(0); expect(statusJson().status).toBe('push_failed'); expect(git(['stash', 'list']).stdout).toContain('older work'); expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other v1\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a conflicting merge is aborted and git\'s reason reaches the status', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); - write(SHARED, '# shared, local commit\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); + write(SHARED, '{"note":"shared, local commit"}\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('push_failed'); expect(s.message).toContain(`Merge conflict in ${SHARED}`); expect(mergeHead()).toBe(false); expect(unmerged()).toBe(''); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local commit\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local commit"}\n'); // The next drain is not blocked by a half-finished merge. write(OTHER, '# other v2\n'); expect(drain().status).toBe(0); @@ -1270,6 +1270,130 @@ describe('gstack-brain-sync per-file hold', () => { expect(remoteShow(SNAP).stdout).toContain('"ccc"'); }); }); + + describe('a synced JSON file that does not parse is never committed (#3763)', () => { + const PROFILE = 'developer-profile.json'; + const MARKED = '{\n "sessions": [],\n<<<<<<< HEAD\n "v": "local"\n=======\n "v": "peer"\n>>>>>>> origin/main\n}\n'; + const parses = (text: string) => { try { JSON.parse(text); return true; } catch { return false; } }; + + test('a queued profile holding conflict markers is held back; the rest syncs; a repaired profile syncs next', () => { + init(); + write(PROFILE, MARKED); + write(CLEAN, '# a plan\n'); + expect(drain().status).toBe(0); + expect(remoteFiles()).toContain(CLEAN); + expect(remoteFiles()).not.toContain(PROFILE); + expect(staged()).toBe(''); + expect(spoolText()).toContain(PROFILE); + const s = statusJson(); + expect(s.status).toBe('held'); + expect(s.held.map((h: { path: string; rule: string }) => [h.path, h.rule])).toEqual([[PROFILE, 'invalid-json']]); + expect(s.held[0].fixes[0]).toContain(`${PROFILE} is not valid JSON`); + expect(s.held[0].fixes).toHaveLength(1); + expect(s.message).toContain('not valid JSON'); + expect(s.message).not.toContain('secret scan'); + + write(PROFILE, '{"sessions":[],"v":"repaired"}\n', false); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('ok'); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"repaired"}\n'); + }); + + test('a marker-free syntax error is held too', () => { + init(); + write(PROFILE, '{"sessions": [],}\n'); + expect(drain().status).toBe(0); + expect(remoteFiles()).not.toContain(PROFILE); + expect(statusJson().held.map((h: { rule: string }) => h.rule)).toEqual(['invalid-json']); + expect(spoolText()).toContain(PROFILE); + }); + + test('an unparseable decisions.active.json holds its coupled decisions.jsonl until repaired', () => { + init(); + writeDecisions(['a'], ['a']); + expect(drain().status).toBe(0); + write(LOG, [decide('a'), decide('bb')].join('\n') + '\n', false); + write(SNAP, '[{"id":"a"},\n<<<<<<< HEAD\n{"id":"bb"}\n=======\n{"id":"c"}\n>>>>>>> origin/main\n]\n', false); + run(['gstack-brain-enqueue', LOG]); + expect(drain().status).toBe(0); + const s = statusJson(); + expect(s.status).toBe('held'); + expect(s.held.map((h: { path: string; rule: string; dependents: string[] }) => [h.path, h.rule, h.dependents])).toEqual([[SNAP, 'invalid-json', [LOG]]]); + expect(remoteShow(LOG).stdout).toBe(decide('a') + '\n'); + expect(spoolText()).toContain(LOG); + + writeDecisions(['a', 'bb'], ['a', 'bb']); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('ok'); + expect(remoteShow(LOG).stdout).toBe([decide('a'), decide('bb')].join('\n') + '\n'); + }); + + test('two machines editing the profile never put conflict markers on the remote', () => { + init(); + write(PROFILE, '{"sessions":[],"v":"base"}\n'); + expect(drain().status).toBe(0); + // The retry merge commits with the ambient identity; a CI runner has none. + expect(git(['config', 'user.name', 'fixture']).status).toBe(0); + expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0); + const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-')); + try { + expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0); + fs.writeFileSync(path.join(peer, PROFILE), '{"sessions":[],"v":"peer"}\n'); + expect(git(['add', '-f', '--', PROFILE], peer).status).toBe(0); + expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer profile'], peer).status).toBe(0); + expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0); + } finally { + fs.rmSync(peer, { recursive: true, force: true }); + } + write(PROFILE, '{"sessions":[],"v":"local"}\n'); + expect(drain().status).toBe(0); + // Every later profile write queues the file again. + run(['gstack-brain-enqueue', PROFILE]); + write(CLEAN, '# a plan\n'); + expect(drain().status).toBe(0); + expect(parses(remoteShow(PROFILE).stdout)).toBe(true); + expect(remoteShow(PROFILE).stdout).not.toContain('<<<<<<<'); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"peer"}\n'); + }); + + test('an unfinished merge with a held profile commits nothing until the profile is repaired', () => { + init(); + write(PROFILE, '{"sessions":[],"v":"base"}\n'); + expect(drain().status).toBe(0); + expect(git(['config', 'user.name', 'fixture']).status).toBe(0); + expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0); + const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-')); + try { + expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0); + fs.writeFileSync(path.join(peer, PROFILE), '{"sessions":[],"v":"peer"}\n'); + expect(git(['add', '-f', '--', PROFILE], peer).status).toBe(0); + expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer profile'], peer).status).toBe(0); + expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0); + } finally { + fs.rmSync(peer, { recursive: true, force: true }); + } + fs.writeFileSync(path.join(tmpHome, PROFILE), '{"sessions":[],"v":"local"}\n'); + expect(git(['commit', '-qam', 'local profile']).status).toBe(0); + expect(git(['fetch', '-q', 'origin']).status).toBe(0); + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim(); + expect(git(['merge', '--no-edit', `origin/${branch}`]).status).not.toBe(0); + run(['gstack-brain-enqueue', PROFILE]); + write(CLEAN, '# a plan\n'); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('blocked'); + expect(statusJson().message).toContain('unfinished'); + expect(statusJson().message).not.toContain('--skip-file '); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"peer"}\n'); + expect(remoteFiles()).not.toContain(CLEAN); + expect(git(['rev-parse', '-q', '--verify', 'MERGE_HEAD']).status).toBe(0); + + write(PROFILE, '{"sessions":[],"v":"merged"}\n', false); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('ok'); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"merged"}\n'); + expect(remoteFiles()).toContain(CLEAN); + }); + }); }); // --------------------------------------------------------------- diff --git a/test/gstack-developer-profile.test.ts b/test/gstack-developer-profile.test.ts index 11685bb64..46a945508 100644 --- a/test/gstack-developer-profile.test.ts +++ b/test/gstack-developer-profile.test.ts @@ -628,6 +628,24 @@ describe('gstack-developer-profile --check-mismatch', () => { // Error handling // ----------------------------------------------------------------------- +describe('gstack-developer-profile --profile on an unparseable profile (#3763)', () => { + test('exits non-zero, prints nothing to stdout, and names the file and the conflict markers', () => { + fs.writeFileSync(path.join(tmpHome, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "sessions": []\n=======\n "sessions": [1]\n>>>>>>> origin/main\n}\n'); + const r = runDev('--profile'); + expect(r.status).not.toBe(0); + expect(r.stdout).toBe(''); + expect(r.stderr).toContain('developer-profile.json is not valid JSON'); + expect(r.stderr).toContain('git conflict markers'); + }); + + test('a valid profile still prints as before', () => { + fs.writeFileSync(path.join(tmpHome, 'developer-profile.json'), '{"sessions":[]}\n'); + const r = runDev('--profile'); + expect(r.status).toBe(0); + expect(r.stdout).toBe('{"sessions":[]}\n'); + }); +}); + describe('gstack-developer-profile errors', () => { test('unknown subcommand exits non-zero', () => { const r = runDev('--not-a-real-subcommand'); diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 61a680b40..367034c42 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -136,6 +136,25 @@ describe('gstack-doctor', () => { expect(r.out).not.toContain('.brain-sync-status.json'); }); + test('artifacts sync: a synced JSON file with conflict markers warns by name (#3763)', () => { + const f = makeFixture(); + syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); + write(path.join(f.state, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "v": 1\n=======\n "v": 2\n>>>>>>> origin/main\n}\n'); + const r = doctor(f); + expect(r.row('artifacts sync').state).toBe('warn'); + expect(r.row('artifacts sync').detail).toBe('developer-profile.json holds git conflict markers and does not parse'); + write(path.join(f.state, 'developer-profile.json'), '{"v": 2}\n'); + expect(doctor(f).row('artifacts sync').state).toBe('ok'); + }); + + test('artifacts sync: a JSON hold is not attributed to the secret scanner (#3763)', () => { + const f = makeFixture(); + syncOn(f, { status: 'held', message: 'held', held_count: 1, held: [{ path: 'developer-profile.json', rule: 'invalid-json', dependents: [], fixes: ['repair', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); + const r = doctor(f); + expect(r.row('artifacts sync').state).toBe('warn'); + expect(r.row('artifacts sync').detail).toBe('status held (1 synced file(s) are not valid JSON; 1 held in all)'); + }); + test('artifacts sync: held, then ok', () => { const f = makeFixture(); syncOn(f, { status: 'held', message: 'held 2 file(s): sk-ant-secret', held_count: 2, held: [{ path: 'projects/p/notes.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }, { path: 'projects/p/b.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 5dca2124f..1fd895132 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -816,7 +816,7 @@ describe('ARTIFACTS_SYNC attention lines (sync status table)', () => { const held = runStart([], env); expect(held).toMatch(/^ARTIFACTS_SYNC: mode=full \| last_push=\S+ \| queue=\d+$/m); expect(attention(held)).toEqual([ - `ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`, + `ARTIFACTS_SYNC: attention: status=held held=1. Sync is holding back 1 file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`, ]); expect(held).not.toContain('ghp_'); expect(remoteFiles()).toContain('projects/p/ceo-plans/clean.md'); From 35a676ff68b88619308591727ace7ac733650841 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 15:36:31 -0800 Subject: [PATCH 08/11] v1.92.47.0 fix(ship): /ship Step 5 runs test lanes one at a time on 4 cores or fewer, with --lanes serial|parallel (GST-851) (#3821) * fix(ship-preflight): run Step 5 lanes one at a time on 4 cores or fewer, with --lanes serial|parallel (GST-851) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-851 fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 5 says lanes run one at a time on 4 or fewer cores and names --lanes (GST-851 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/ship-bin-common.ts | 34 +++++++++++---- lib/ship-preflight.ts | 16 ++++--- package.json | 2 +- ship/sections/tests.md | 5 ++- ship/sections/tests.md.tmpl | 5 ++- test/ship-preflight.test.ts | 42 +++++++++++++++++++ .../ship/gst-851-serial-lanes.json | 4 ++ 10 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 test/size-ledger/ship/gst-851-serial-lanes.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2619a01d1..dab48b0c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.92.47.0] - 2026-11-01 + +- **`/ship` Step 5 runs its test lanes one at a time on machines with 4 or fewer cores, and `--lanes serial|parallel` picks the mode by hand** (`gstack-ship-preflight`, GST-851). Preflight used to start every `--lane` at once. On garrytan/gstack-evals#115 six lanes (a 1,451-test bun suite, tsc, a Docker tier and an arm tarball trial) started within 60 ms of each other on a 4-core, 16 GB machine, and running lanes together has taken machines down before (gstack-evals #101, #106, #107). Now, with no flag, preflight runs one lane at a time when `os.availableParallelism()` is 4 or less and all of them at once above that. `--lanes serial` or `--lanes parallel` overrides the default, and any other value, or `--lanes` with no value, is a usage error (exit 2). A new `LANES: serial|parallel (--lanes| cores)` line before the `LANE` lines says which mode ran and why. Each lane still prints its own `LANE` line with its own `tests_ran` count, in the order the lanes were given. Serial runs take the sum of the lane times instead of the longest one. There is no ship-policy key for this: a new key needs key versioning and a `min_gstack_version` bump, and the core-count default already covers the 4-core case. + +### Tests +- `test/ship-preflight.test.ts`: `--lanes serial` runs lanes one after another and keeps each lane's count; `--lanes parallel` keeps lane order when a later lane finishes first; an unflagged run names the core count; a bad or missing `--lanes` value is a usage error; and the core-count default is serial at 4 cores or fewer. + ## [1.92.46.0] - 2026-11-01 **brain-sync no longer publishes a synced JSON file that doesn't parse, and the profile and doctor now say when one is broken.** When two machines edit `developer-profile.json` before syncing, git's merge can leave conflict markers in it. brain-sync committed and pushed that file, so every machine got a profile that no longer parsed, while `gstack-developer-profile --profile` and `gstack-doctor` both reported everything ok (#3763). diff --git a/VERSION b/VERSION index cbd266e1d..f0f525626 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.46.0 +1.92.47.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 7d20d84f4..f4e17a48a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.46.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.47.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 2702f251f..0bb6e6b05 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -7,6 +7,7 @@ */ import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; import { parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp'; @@ -150,13 +151,32 @@ export class Report { } } -/** Run each lane through gstack-evidence run in parallel and read its recorded line. */ -export async function runLanes(cwd: string, list: { label: string; command: string }[]): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> { - return Promise.all(list.map(async l => { - const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]); - const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr); - return { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' }; - })); +/** + * How many lanes run at once (GST-851): `--lanes serial` one, `--lanes parallel` all; unset, one on a machine with 4 or fewer + * cores (six lanes at once took 4-core machines down on gstack-evals) and all above that. Any other value is a usage error. + */ +export function laneConcurrency(mode: string | undefined, cpus = os.availableParallelism()): number | string { + if (mode === 'serial') return 1; + if (mode === 'parallel') return Infinity; + if (mode !== undefined) return `--lanes ${mode} is not serial or parallel`; + return cpus <= 4 ? 1 : Infinity; +} + +/** Run each lane through gstack-evidence run, at most `concurrency` at a time, and read its recorded line; results keep the lanes' order. */ +export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> { + const out: { label: string; exit: number | null; testsRan: string; log: string }[] = []; + let next = 0; + const worker = async () => { + while (next < list.length) { + const i = next++; + const l = list[i]!; + const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]); + const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr); + out[i] = { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' }; + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length)) }, worker)); + return out; } /** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone. */ diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index ed1f48f50..519ee5475 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -6,22 +6,24 @@ * agent (a merge conflict, a failing or zero-run lane, the carry route, the Apple target). */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { classifyDiff } from './review-depth'; import { audit } from './ship-audit'; -import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; +import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md) gstack-ship-preflight --base [--lane