diff --git a/CHANGELOG.md b/CHANGELOG.md index 488849c80..63410dc5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## [1.91.350.0] - 2026-10-31 + +**`/ship` review costs less and catches more: one batched specialist, a blocking floor in both prose voices, and data files that no code reads review as docs (GSTA-167 variant G).** The no-loss replay certified the stack before merge. Round 5 replayed 14 PRs with 29 known blockers, and the new reviewers found 24 against the control's 21, non-inferior at p=0.035. Round 6 pooled 41 blockers (the 29 plus 12 confirmed units from GSTA-2): 33 against 27, non-inferior at p=0.0055. The free re-score for the classifier and state-matrix change lost 0 blockers. + +### Changed +- **One batched specialist reads every checklist at each depth.** It always reads Testing and Security on code and gate diffs, and adds State-machines, Data-migration, API-contract and Design when scope asks for them. Maintainability, Performance and Simplification run only when forced, and `gstack-review-plan` names them on a `SKIP:` line. The Red Team reads the batched reader's merged findings. +- **Specialists run at medium reasoning effort.** The Red Team, the native adversarial pass and the fix-check stay at full effort. `--no-tiers` (or `review_effort_tiers off`) runs specialists at the host default. +- **Both prose voices carry the blocking floor.** The native adversarial and outside-voice prompts say a finding blocks only if it would fail a test, break production, or lose data or secrets, and end each finding with `Severity:` and `Blocks: yes|no`. A finding is CRITICAL when its old label rule says so or it says `Blocks: yes`, and `Blocks: no` never lowers one. `gstack-review-dispatch collect` records a `Blocks: yes` finding as CRITICAL, so `judge` refuses a lead judgment that keeps it below the floor. +- **A JSON, CSV, TSV or raster data file that no code names counts as docs, not runtime lines.** `classifyDiff` finds code consumers with `git grep`; YAML, TOML, SVG and config files stay code, and path-only callers (the receipt's gate scan, the release route) keep data files code. `gstack-ship-preflight`'s `RUNTIME_LINES` now comes from the same function. +- **An untested state-matrix cell is a test gap below the floor.** A wrong row or collapsed kinds stays CRITICAL, and the state-machines checklist itself is unchanged. + +### Fixed +- **`gstack-review-dispatch render --checklists` takes the plan's checklist names** as well as paths, and refuses an unknown one. The outside-voice brief no longer adds a separate severity line, since the prompt now asks for Severity and Blocks itself. +- **`collect` keeps one finding per id in the layout the prompts ask for.** A `Severity:` or `Blocks:` line below a finding joins that finding, an inline `Blocks: yes` with no label still collects as CRITICAL, and the native voice's FIXABLE collects as CRITICAL even beside a lower `Severity:`. When the layout is unclear, `collect` merges findings rather than dropping one. +- **The data-file consumer scan reads `git grep` the same way under any git config.** `grep.lineNumber`, `grep.column` and `color.grep` no longer hide a consumer, and a failed scan keeps every data file code with `consumer_scan_failed`. + +### Tests +- `test/prose-voice-floor.test.ts` renders both prose prompts for the claude and codex hosts and checks the floor sentence and the Blocks line. `test/review-depth.test.ts` covers the batched reader on the captured fixtures, effort per role and the data-file rule. `test/review-dispatch.test.ts` covers `Blocks: yes` collection and batched checklist names. `test/review-packets-removed.test.ts` repins the changed briefs to their no-loss replays (PR-a, G1, L). + +## [1.91.349.0] - 2026-10-31 + +**Nine flaky tests from the 2026-10-30 flaky pass stop failing correct runs.** Two paid detectors missed correct replies, one paid case lost the evidence of its own failure, five free tests raced their own fixtures or paid a cold start inside a timed call, and two Windows failures came from real defects: a lost IPC receipt in the QA deadline worker and a Chromium feature that fails loopback connections. + +### Fixed +- **`lib/qa-deadline.ts`**: the receipt worker waits for the parent to acknowledge each receipt before it exits. Bun can drop an IPC message the parent has not read yet when the child exits, so on Windows `gstack-qa-deadline run` and `gstack-qa-evidence` could return the exit code without the `finished` receipt (Windows Free Tests 40991662945, `test/qa-deadline.test.ts:431` received one of two receipts). A receipt still unacknowledged after the existing 5 s write wait makes the worker exit 2, as an unwritten receipt did before. +- **`patches/playwright-core@1.62.1.patch`**: backports microsoft/playwright#43605, which disables Chromium's `TcpPortRandomizationWin`. On Windows it fails loopback page loads with `net::ERR_NO_BUFFER_SPACE` even when ports are free (`browse/test/cookie-picker-ui.test.ts`, Windows Free Tests 40656299153 and 41056150287). gstack's own browser launches on Windows had the same exposure. +- **`test/helpers/qa-only-cleanup.ts`**: the cleanup check reads the daemon state file once and treats a file deleted during shutdown as absent, instead of checking that it exists and then reading it (Free Tests 40964422549: ENOENT on `browse.json`). +- **`test/claude-code-runner.test.ts`**: the liveness helper counts a process in the `X` (being reaped) state as dead, like `Z`, and the cleanup no longer kills a pid that is already gone (Free Tests 41034844588: ESRCH). +- **`test/helpers/free-owned-browser-settlement.ts`**: in the `cancel-vanishing-record` case the fake daemon waits for the observer to remove its record before it exits, so the race the case injects always happens (Free Tests 40924799742). +- **`test/investigate-freeze-path.test.ts`**: the E1 cases pay the first bash and JSON interpreter start once, in an unasserted warm-up, before the timed hook calls. In four Windows runs (40824965988, 40917695070, 40985163846, 40996480693) only the file's first hook call hit the 15 s kill, the second took 1.9 to 7.9 s and the rest 0.3 to 1 s. Each timed call keeps its 15 s kill. + +### Gate integrity +- **ship-unmanaged-hook-consent** (`test/helpers/ship-hook-actor.ts`): the decline check also credits "no hook/change/file was installed, changed, modified, overwritten or replaced". Gate 41005113772 said "No hook was installed or changed" and "nothing was overwritten" after a correct decline; its installs, hook bytes and question checks all passed. The four negative controls still fail. +- **tpa-absent-darwin** (`test/helpers/third-party-actions.ts`): the Aside download pitch also counts in the passive voice at a clause start ("Aside ... can be downloaded at aside.com", gate 40987391676). Reported and mid-clause forms still count zero, and so does a clause with "not", "no", "never" or "n't" before the phrase. + +### Changed +- **autoplan-guard-pty** (`test/helpers/autoplan-guard-pty.ts`, `test/skill-e2e-autoplan-guard-pty.test.ts`): a `dispatch_prompt` deny prints each Agent dispatch's length, first differing offset and context next to the prompt the actor had to copy, read before cleanup deletes it. Gate 40992022983 lost those bytes because the journal summary cuts inputs at 160 characters. + +### Tests +- New captures: `test/fixtures/ship-hook-consent-2026-10-30.json` and a passive-voice capture in `test/fixtures/third-party-actions-pitch-captures.json`; both tests fail with their detector change reverted. `browse/test/playwright-core-patch.test.ts` checks the backport in the patch and the installed tree, and fails without it. `test/autoplan-guard-pty-fixture.test.ts` checks the dispatch delta on a synthetic journal. `test/qa-deadline.test.ts` plays the parent, so it now acknowledges each receipt; its assertions are unchanged, and a new case shows a worker whose parent never acknowledges exits 2 after sending both receipts. + +## [1.91.348.0] - 2026-10-31 + +- **Pregate now maps tests to dotfiles they name.** A test that reads `../.gitleaks.toml`, `.gitleaks.toml` or `join('.github', 'workflows', 'ci.yml')` used to get no edge to that file, so a branch touching only the dotfile ended in `PREGATE_NO_LANE`. Quoted literals may now start with a dot, and a dot-led literal that isn't `./` or `../` resolves from both the repository root and the folder of the file that names it, so a fixture's own `.github/` keeps its edge. A literal still forms an edge only when it names a file in the repo, so strings like `'.ts'` add nothing. On gstack this adds 179 edges (`buildGraph` over this repository: 16,934 on main 2127729, 17,113 with this change, none removed), all to real dotfiles such as `.github/workflows/*.yml` and `.gstack/*.json`. (GST-246, reported from garrytan/truther#956.) + +## [1.91.347.0] - 2026-10-31 + +**A Captain archives only its own crew's threads (R39, amended). A thread outside every crew belongs to the owner and stays as it is, even when the Captain's own automation created it.** + +### Changed +- **docs/captain.md and the Capy factory context (`factory.md` point 12)** now say a Captain archives only the threads in its own crew, each when its plan for that thread says the work is done. No Captain archives, flags or lists a thread outside every crew for archiving unless the owner asks. The earlier wording also let a Captain archive threads its automations started outside the crew, and that emptied the owner's unattached threads. + +## [1.91.346.0] - 2026-10-31 + +**`sealed.local_path` now flags a home path inside a shell default, `${UBI_RUNNER:-/home/…}`, as it already flagged the same path after `?? "/home/…"` in TypeScript.** gstack-evals `quick/laneb/runner/launch.sh:9` passed the scan because the `-` before the path counted as part of a word (#3568). + +### Fixed +- **`sealed.local_path`** (`lib/sealed-guard.ts`): a home path directly after a `${…:-` or `${…-` default now matches, for a name, an array element (`${arr[0]:-`, a subscript of up to 64 characters, so a long line of unclosed `${a[` cannot make the scan quadratic), an indirect name (`${!ref:-`), a positional number or a special parameter (`${@:-`). `:=`, `:+` and `:?` already matched, because `=`, `+` and `?` were never word characters. A hyphen anywhere else still excludes the match, so a path glued to a word (`docs-…`, `$VAR-…`, `${VAR}-…`) stays unflagged; docs/pregate.md lists that as a known limit. + +### Gate integrity +- This is a gate edit that only tightens. The guard now refuses lines it passed before. No existing test, threshold or allowlist changed. A new case in `test/sealed-guard.test.ts` covers all five operators, a positional parameter, `${@`, an array element, an indirect name, a Windows path and the TypeScript `??` form. It also checks six negative controls, which are two hyphenated paths, a hyphenated sentence, `$VAR-`, `${VAR}-` and a relative default. Without the fix it misses every `-` form. + ## [1.91.345.0] - 2026-10-31 **A capped `/ship` on Capy now spends fewer agent turns and counts only its own tokens, while every reviewer still gets exactly the brief 1.91.331.0 gives it.** Two new commands do `/ship`'s fixed shell work, a short driver file replaces reading the whole skill when a parent agent runs `/ship`, and the budget measures each reviewer batch on its own instead of across the parent's work. Replays of gstack-evals #34 and #36 (GSTA-167) spent 12.19M tokens on Steps 12-17 and 0.25-0.4M on each parent turn; these changes cut turns, not reviewer context. diff --git a/VERSION b/VERSION index bb998b909..a9eef217c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.345.0 +1.91.350.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 38ea47900..c2745c8e1 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.345.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.350.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-dispatch b/bin/gstack-review-dispatch index edaea1d9d..8cf7c9932 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -31,7 +31,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free roles: ${BRIEF_ROLES.join(', ')} slots: --diff-base (every role but fix-check) --exclude '' --stack --test-fw --learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team) - --checklists (specialist-batched) --findings-file --specialists (red-team) + --checklists (specialist-batched: the plan's checklists= names, or paths) --findings-file --specialists (red-team) --fix-list --hunks --tests [--callers ] (fix-check) writes /[-].prompt.md, records it in the ledger (default /review-ledger.jsonl) and prints BRIEF: reader= sha256= bytes= file=. Pass that file's content as the reader's prompt, unchanged. @@ -82,6 +82,8 @@ function render(): number { const diffBase = flag('--diff-base'); const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined); if (checklist && (role === 'specialist' || role === 'red-team') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`); + const checklists = flag('--checklists')?.split(',').map(s => s.trim()).filter(Boolean).map(c => (c.includes('/') ? c : path.join(ROOT, 'review/specialists', `${c}.md`))); + for (const c of checklists ?? []) if (!fs.existsSync(c)) throw new BriefError(`checklist not found: ${c}`); let diff: string | undefined; if (role === 'outside-voice' && diffBase) { const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase], { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); @@ -90,7 +92,7 @@ function render(): number { } const slots: Slots = { name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'), - learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: flag('--checklists'), + learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','), findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff, fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'), }; diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index a0fcf3228..5826f66f1 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -39,12 +39,14 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it) --gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable) --gated a,b specialists the hit-rate stats tag GATE_CANDIDATE - --force a,b|all specialists forced by prompt flags (--all-specialists is all) + --force a,b|all specialists forced by prompt flags (--all-specialists is all): at standard and full depth they join the + batched specialist's checklists, where maintainability, performance and simplification are read only when + forced; light runtime-0 and scoped passes dispatch them one reader each --reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed --depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run --range-base the fix range's base (this pass's PASS_HEAD): after a fixed blocker on a diff below full depth, a committed range of at most ${SCOPED_RANGE_LINES} runtime lines plans a scoped pass; anything unread plans the full pass - --no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) + --no-tiers specialists, audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude) --pass1-depth pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files @@ -130,14 +132,14 @@ function main(): number { } } const plan = planReview({ - ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, + ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines }, }); const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); + process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); return EXIT.ok; } const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`]; @@ -150,7 +152,7 @@ function main(): number { if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`); } for (const s of plan.skipped) out.push(`SKIP: ${s}`); - out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above stay full)`); + out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above run at their effort=)`); process.stdout.write(out.join('\n') + '\n'); return EXIT.ok; } diff --git a/bin/gstack-state-matrix b/bin/gstack-state-matrix index 6e7ac0204..f17ee2cd0 100755 --- a/bin/gstack-state-matrix +++ b/bin/gstack-state-matrix @@ -5,6 +5,7 @@ * gstack-state-matrix detect [--base ] [--diff-base ] * gstack-state-matrix check [--repo ] [--expect ] [--json] * gstack-state-matrix outfile + * gstack-state-matrix findings * * `detect` prints `SCOPE_STATE_MACHINE=true|false` (shell-safe for * `source <(...)`, with `# state-machine:` comment lines naming the hits) from @@ -14,7 +15,10 @@ * last; exit 0 ok, 1 incomplete (STATE_MATRIX_INCOMPLETE). `outfile` creates and * prints a new empty 0600 file in the repo's private `.gstack/tmp/` (0700, kept * out of git through info/exclude) for the specialist's output, so a later - * review never checks or commits an earlier run's file. Logic: lib/state-matrix.ts. + * review never checks or commits an earlier run's file. `findings` prints the + * output for Step 9.3's merge with each untested cell mapped below the floor + * (lib/review-scope.ts mapStateMatrixOutput) and `STATE_MATRIX_FINDINGS:` on + * stderr. Logic: lib/state-matrix.ts. */ import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -24,6 +28,7 @@ import { addedLines } from '../lib/banned-terms'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { resolveBaseBranch } from '../lib/ship-policy'; +import { mapStateMatrixOutput } from '../lib/review-scope'; import { checkStateMatrix, detectStateMachine, renderStateMatrixCheck, type Expectation } from '../lib/state-matrix'; const HELP = `gstack-state-matrix — kind × owner action × resulting row for proposal, hold and approval state machines @@ -32,6 +37,7 @@ const HELP = `gstack-state-matrix — kind × owner action × resulting row for check [--repo ] [--expect ] [--json] gaps in the state-machines specialist's output (review/specialists/state-machines.md) outfile print a new empty private file for that output + findings that output for the Step 9.3 merge, each untested cell mapped below the floor ${exitTableHelp()}`; @@ -71,6 +77,15 @@ function check(rest: string[]): number { return EXIT.ok; } +function findings(rest: string[]): number { + const input = rest.find(a => !a.startsWith('--')); + if (!input) { process.stderr.write(`gstack-state-matrix findings: a reviewer output file (or -) is required\n${HELP}\n`); return EXIT.usage; } + const r = mapStateMatrixOutput(input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8')); + process.stdout.write(r.text.endsWith('\n') ? r.text : `${r.text}\n`); + process.stderr.write(`STATE_MATRIX_FINDINGS: mapped=${r.mapped} (untested cells below the floor; docs/review-policy.md test gap)\n`); + return EXIT.ok; +} + function outfile(): number { const root = git(['rev-parse', '--show-toplevel']); const exclude = git(['rev-parse', '--git-path', 'info/exclude']); @@ -94,6 +109,7 @@ function main(argv: string[]): number { if (cmd === 'detect') return detect(rest); if (cmd === 'check') return check(rest); if (cmd === 'outfile') return outfile(); + if (cmd === 'findings') return findings(rest); process.stderr.write(`gstack-state-matrix: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; } diff --git a/browse/test/playwright-core-patch.test.ts b/browse/test/playwright-core-patch.test.ts index d09e136c9..f0974cbd3 100644 --- a/browse/test/playwright-core-patch.test.ts +++ b/browse/test/playwright-core-patch.test.ts @@ -60,3 +60,16 @@ describe('playwright-core windowsHide patch (#2160, #1989)', () => { expect(bundle).toContain('shell: true, windowsHide: true'); }); }); + +// Windows Free Tests 40656299153 and 41056150287 (cookie-picker-ui, serial retry passed): goto failed with +// net::ERR_NO_BUFFER_SPACE on 127.0.0.1. Chromium's TcpPortRandomizationWin raises WSAENOBUFS with ports free; +// microsoft/playwright#43605 disables it in 1.65, and the 1.62.1 patch backports it; drop this block at 1.65. +describe('playwright-core TcpPortRandomizationWin backport (microsoft/playwright#43605)', () => { + test('the patch and the installed tree disable the feature in the --disable-features list', () => { + expect(fs.readFileSync(path.join(ROOT, 'patches', 'playwright-core@1.62.1.patch'), 'utf-8')).toContain('+ "TcpPortRandomizationWin"'); + const bundle = fs.readFileSync(path.join(ROOT, 'node_modules', 'playwright-core', 'lib', 'coreBundle.js'), 'utf-8'); + const list = bundle.slice(bundle.indexOf('disabledFeatures = ['), bundle.indexOf('].filter(Boolean);', bundle.indexOf('disabledFeatures = ['))); + expect(list).toContain('"TcpPortRandomizationWin"'); + expect(bundle).toContain('"--disable-features=" + disabledFeatures.join(",")'); + }); +}); diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 5ca511a9f..7c90503ce 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). 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: "", base: "", out: "/p", pass: "-p", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (admission mode: `reservation` from `RESERVATION_ID:`), sends each brief after `sent` 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 listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, a JSON file of `{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 the operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` over your jam's transcript since /ship started. A small waiting child saves nothing. +/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: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (admission mode: `reservation` from `RESERVATION_ID:`), sends each brief after `sent` 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 listed under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT: judge`, `NEXT: poll` (rerun) or `STOP:`. `effort=` medium and light run `laneModel` at that reasoning; `models` maps a role to its model (`outside-voice`: another family); Step 11 adds `stage: "voices"`. Steps 7, 8 and 9.2.1 go in `extras`, a JSON file of `{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 the operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` over your jam's transcript since /ship started. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/capy/skill/gstack/factory.md b/capy/skill/gstack/factory.md index 37323489c..7e472397c 100644 --- a/capy/skill/gstack/factory.md +++ b/capy/skill/gstack/factory.md @@ -3,15 +3,15 @@ What seven Captain crews converged on (2026-10-29). Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person and never merge without their word. Story: capy/README.md; examples: your org Drive's software-factory/README.md, if any. 1. **Plan, then a yes.** New functionality starts with /autoplan sized to the work; the build waits for "do it". [SKILL.md: Plan, approve, ship] -2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY quoting gstack's verify output word for word, and stop. The merge owner merges on green and assigns versions. [docs/captain.md: The READY contract] -3. **Block only on the floor.** A finding blocks if it would fail a test, break production, or lose data or secrets; the rest goes in the PR body or the next wave. [docs/review-policy.md] -4. **Fix waves.** When main goes red, hold /ship while one owner finds the root cause; bring main in once, as your rules allow. Fold deferred findings and test follow-ups into one wave PR. [docs/triage.md] -5. **Severe first, same gates.** A severe user-facing bug jumps the queue; the plan can be short, but receipt, checks and merge owner stay. -6. **Trade findings, not small talk.** A finding that belongs to another crew goes to its Captain, through yours, with the evidence. +2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY quoting gstack's verify output verbatim, and stop. The merge owner merges on green and assigns versions. [docs/captain.md: The READY contract] +3. **Block only on the floor.** A finding blocks if it would fail a test, break production, or lose data or secrets; the rest goes in the PR body or next wave. [docs/review-policy.md] +4. **Fix waves.** When main goes red, hold /ship while one owner finds the root cause; bring main in once, as your rules allow. Fold deferred findings and test follow-ups into one PR. [docs/triage.md] +5. **Severe first, same gates.** A severe user-facing bug jumps the queue; the plan can be short; receipt, checks and merge owner stay. +6. **Trade findings, not small talk.** Another crew's finding goes to its Captain, through yours, with the evidence. 7. **Measure before you act.** Run the real system on the merged tree you ship; a real run outranks a reviewed plan. Each number names its run, commit or uncapped query; say what you did not verify. -8. **Evals decide, and losses get reported.** Preregister bars, cap and stop rule before paying [/eval-plan]. Cheap proof (a keyless fake, a smoke) before the paid run. Use controls (no-skill and placebo arms, clean decoys). Report use and pass separately; quote spend from the meter. +8. **Evals decide, and losses get reported.** Preregister bars, cap and stop rule before paying [/eval-plan]. Cheap proof (a keyless fake, a smoke) before the paid run. Use controls (no-skill and placebo arms, clean decoys). Report use and pass apart; quote spend from the meter. 9. **Flakes: one rerun, a ledger, a daily fix.** Only the rerun the policy allows, never a harness or infra red; a second red is real; no test is loosened. [docs/captain.md: The daily flaky pass] -10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast that needs no reply goes in the Captain's notes; never reply just to acknowledge. +10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast needing no reply goes in the Captain's notes; never reply just to acknowledge. 11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered simple steps, the reply word. Never re-ask a standing rule; check what you can yourself. [docs/captain.md: The ask format] -12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain); past the context threshold, hand off. Its Captain archives the threads it, its crew or automations start. Size reviews to the diff; read the token counter. [docs/ship-budget.md] +12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain); past the context limit, hand off. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the token counter. [docs/ship-budget.md] 13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference] diff --git a/changelog.d/gsta-167-calls-1.md b/changelog.d/gsta-167-calls-1.md index 3aa6cedb4..b3dd62a54 100644 --- a/changelog.d/gsta-167-calls-1.md +++ b/changelog.d/gsta-167-calls-1.md @@ -12,3 +12,5 @@ ### Fixed - **`gstack-ship-budget note --step 9.1`** records under step 9 with the sub-step as its label. Before, it was refused, and replays lost two notes to that error. - **`gstack-review-scope render`** accepts `{"deferred": [...]}` as well as the bare array. +- **`gstack-review-dispatch judge`** treats a judged line with no severity, or `"severity": null`, as invalid and prints `JUDGMENT_REJECTED`. Before, it crashed with a TypeError, and a replay spent three calls recovering. +- **The Capy skill passes a rendered brief as bash `cat` prints it.** A file read cut the long lines of a 231,589-byte outside-voice brief, so `sent` reported `BRIEF_MISMATCH` and the reader had to be sent again. diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 6c99766c2..45e932380 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -96,7 +96,7 @@ gstack/ ├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) ├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/) │ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads -├── patches/ # bun `patchedDependencies` patches (playwright-core windowsHide) +├── patches/ # bun `patchedDependencies` patches (playwright-core windowsHide, TcpPortRandomizationWin disabled on Windows) ├── docs/designs/ # Design documents (incl. IMPECCABLE_INTEROP.md = the design detector / catalog / open DESIGN.md record, and fork-port-residual-2026-09/ evaluation evidence) ├── setup-deploy/ # /setup-deploy skill (one-time deploy config) ├── .github/ # CI workflows + shared composite actions (.github/actions/) + Docker image (claude CLI pinned) diff --git a/docs/captain.md b/docs/captain.md index ed8c1003c..a57c1066a 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -35,9 +35,11 @@ by a daily pass; nothing is retried into green. rules file, the digest and the notes, validates READYs, builds and merges trains, calls slots, relays asks to the owner in the three shapes, runs the flaky pass, and decides everything that needs no access, credentials, spend over cap or a cited approval. It - archives its own child threads, including ones its crew or its automations started - outside the crew, when its plan for each thread says that thread's work is done; it - knows each thread's next step, so nobody else archives them. + archives only its own crew threads, each when its plan for that thread says the work is + done; it knows each thread's next step, so nobody else archives them. A thread outside + every crew belongs to the owner and stays as it is: no Captain archives it, flags it or + lists it for archiving unless the owner asks, even when the Captain's own automation + created it. - **A lane** (a crew thread) owns named files, builds on its own branch and machine, runs /ship, sends one READY block, stamps on call (merging the base into its pin only as part of that stamp commit, or when the controller names a real-code conflict), diff --git a/docs/model-policy.md b/docs/model-policy.md index e3a6612c3..60db5f375 100644 --- a/docs/model-policy.md +++ b/docs/model-policy.md @@ -206,18 +206,19 @@ set, because a higher effort would spend the time the gate is sized for. ### Review effort tiers -Every reviewer that can block a ship runs at `full` effort on the lane's -model: the core checklist, each specialist, the Red Team, the native -adversarial pass and the fix-check. `gstack-review-plan` names each -dispatch's effort, and `host` for the outside voice (its provider and +The core checklist, the Red Team, the native adversarial pass and the +fix-check run at `full` effort, the lane's reasoning. Specialists run at +`medium`, reasoning medium on the same model (GSTA-167 part 3: the no-loss +controls C and C2 ran them there); light's batched specialist carries the Red +Team prompt and stays `full`. `gstack-review-plan` names each dispatch's effort, and `host` for the outside voice (its provider and `codex_effort` above). Only work that cannot block runs `light`, the same model at a medium or low setting and never a cheaper model family: the coverage, plan-completion and docs audits, decision-trail reviewers and watchers -(`AUDIT_EFFORT:` in the plan's output). A paid replay showed why reviewers stay +(`AUDIT_EFFORT:` in the plan's output). A paid replay showed why the fix-check stays at full: a medium-effort fix-check missed a fail-open in gate code that the same check at full effort caught. A host that cannot set a subagent's effort runs it at its default. `gstack-config set review_effort_tiers off` (default `on`) -runs the audits and watchers at the host default too +runs the specialists, audits and watchers at the host default too ([review-policy.md](review-policy.md#review-depth)). ## Which workflows use the tiers? diff --git a/docs/pregate.md b/docs/pregate.md index a55d05e75..03de0f36a 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -805,8 +805,10 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js - **Rules.** `sealed.path` and `sealed.transcript` check every path a commit adds, renames or copies, whatever its content (binary, empty, deleted again in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path` - (`/home//`, `/Users//`, `C:\Users\\`) check each commit's added - lines. Findings print rule, `file[:line]` and commit, never the text. + (`/home//`, `/Users//`, `C:\Users\\`, including after a shell + default such as `${VAR:-…}` or `${VAR-…}`) check each commit's added lines. + Known limit: a path glued to the text before it, by a hyphen (`docs-…`, + `$VAR-…`, `${VAR}-…`) or a letter (`-I…`, `-L…`), is not matched. Findings print rule, `file[:line]` and commit, never the text. - **Globs.** `paths`, `transcripts` and `allow_paths` match the whole repo-relative path from the repository root. `*` and `?` stay inside one path segment; `**/` spans zero or more directories; a `**` not followed by diff --git a/docs/review-policy.md b/docs/review-policy.md index 19e0ab4f6..3894713bc 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -58,7 +58,7 @@ whatever class it resembles. | **bounded repeat work** | A re-dispatch on every call, a redundant screen after main moves, an orphaned scratch branch, a refusal that could come one step earlier in the sequence, a settle that could wait one more check. | The operation completes, nothing is lost, and the cost is one more bounded run; uncapped spend is the sideways sweep's CRITICAL, not this class. | | **conservative refusal** | Stale state scoped wider than its cause: a RED verdict that keeps refusing a head after main moved, a pending result that keeps refusing after it settled. | A refusal costs a rerun and never passes something wrong; a wrong pass would be the floor. | | **artifact presentation** | A string from an artifact printed verbatim into a parsed line (whitespace, control characters); a classifier that reads a non-zero exit code beside a non-RED verdict as RED. | Display and classification of the repository's own output; it forges no parsed field. | -| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. | +| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. Precedence for state machines: the specialist still reports each untested cell CRITICAL so `gstack-state-matrix check` proves the matrix complete, and `gstack-state-matrix findings` maps it to INFORMATIONAL per matrix cell before the merge; a wrong row or collapsed kinds stays CRITICAL, and a cell with a second finding keeps both. | A reviewer that wants a class promoted argues it against the floor with a reaching input, in the finding, and the owner rules; the table records the @@ -168,12 +168,38 @@ or test path, CI workflows, credential, state-root and the paths below). commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once. +**Data files no code names are docs.** A changed or deleted JSON, JSONL, NDJSON, CSV, +TSV or raster image (PNG, JPEG, GIF, WebP, BMP, ICO, AVIF) counts as docs when no +non-docs file names its basename at the head, at the merge base or, for live /ship, in +the working tree with untracked files; a data file named only by another data file +follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a test or +fixture path, a gate path, a config file (`.github/**`, anything under a `config/` +directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`, +`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named +consumer each keep the path code, and YAML, TOML and SVG are always code. More than +2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed` +and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts` +finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same +function); every path-only caller (the receipt's gate-scan decision, the Step 16 +post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim +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 (testing, maintainability and security checklists, the specialists scope selects and 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 | the code set with every threshold (specialists at 50, simplification at 100, Red Team at 200) read on runtime lines; specialists leave docs and registered outputs out of their diff, and every specialist but Testing leaves tests out too; adversarial and outside voice read everything | -| **full** | gate class, or any classifier error | the gate set, unchanged: every reviewer on the whole diff, the Red Team always, no hit-rate gating | +| **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 | + +**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist +subagent reads Testing and Security on every code or gate diff, plus +State-machines, Data-migration, API-contract and Design when scope asks (a +forced checklist always joins). Maintainability, Performance and Simplification +are read only when forced (`SKIP:` names them). The Red Team reads the batched +reader's merged findings. A free re-score of saved C and D findings lost 0 of +the 29 blockers (handoffs/GSTA-167/lean-ensemble-rescore.md); the live round 5 +replay passed (L∘G1 found 24 of 29 blockers against the control's 21, +handoffs/GSTA-167/noloss5-results.md). A fixed blocking finding, a CRITICAL or an open blocker raises a light diff to standard for the next step (`step=standard`): its specialists read at least 50 lines @@ -226,15 +252,20 @@ passes `--adversarial-ran` so a repeat lists them no more. Later full passes rer specialists on the range only. Step 11.5 binds a planned skip through the Step 9 record's `CURRENT` freshness instead of a native record. -**Effort.** Every reviewer that can block a ship (the core checklist, -specialists, the Red Team, the native adversarial pass, fix-checks) runs at -`full` effort. Work that cannot block (the Step 7 coverage audit, the Step 8 +**Effort.** The core checklist, the Red Team, the native adversarial pass and +fix-checks run at `full` effort (the lane's reasoning); specialists run at +`medium` (GSTA-167 part 3: the no-loss controls ran them there), except light's +batched specialist, which carries the Red Team prompt and stays `full`. The +outside voice and the native adversarial pass carry the floor sentence and end +each finding with `Severity:` and `Blocks: yes|no`; a finding is CRITICAL when +its label rule says so (native FIXABLE, outside Critical or High) or it says +`Blocks: yes`, and `Blocks: no` never lowers one (`gstack-review-dispatch collect`). Work that cannot block (the Step 7 coverage audit, the Step 8 plan-completion audit, the Step 14.5 docs audit, decision-trail reviewers, watchers) runs at `light`: the same model at a lower reasoning effort, never a cheaper model family. The outside voice keeps its own provider and effort (`codex_effort`). A host that cannot set a subagent's effort runs it at its default. `gstack-config set review_effort_tiers off` (or `--no-tiers`) runs -the audits and watchers at the host default too +the specialists, audits and watchers at the host default too ([model-policy.md](model-policy.md#reasoning-effort)). ### Behavior changes @@ -244,10 +275,15 @@ the audits and watchers at the host default too Team and the native adversarial pass on the range instead of the specialist army. - A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the core checklist and the gate scan review it. +- Every depth dispatches one batched specialist instead of one reader per + checklist; Maintainability, Performance and Simplification run only when forced. - Light no longer runs a separate Red Team: the batched specialist carries its 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. +- 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. ## What the receipt records @@ -375,10 +411,15 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: prompt and runs `sent` on what it passed: a hash that differs from the rendered one is `BRIEF_MISMATCH`, and that reader's review does not count. 3. `collect` hashes each reader's raw output and appends its findings to - `merged.jsonl` with the label exactly as written (a JSON line's `severity`, or a - prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE wherever it - stands, headings and table rows included; a top-level list item under a label-only - heading takes that label). A phantom finding costs a fold; a lost one costs the gate. + `merged.jsonl` with the label as written (a JSON line's `severity`, or the most + severe of a prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE + wherever they stand, headings and table rows included; a top-level list item under a + label-only heading takes the stronger of the two). A `Severity:` or `Blocks:` line + continues the finding above it until a list item, heading, bold paragraph opener or a + second line for the same field starts another: `Severity` raises its label, + `Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline + `Blocks: yes` is CRITICAL too, and with no finding above it is one. The native + adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167). A phantom finding costs a fold; a lost one costs the gate. 4. Lead judgment writes `judged.jsonl`; `judge` refuses a set that drops a merged finding, lowers its severity, folds it into a less severe duplicate, or adds or promotes one without a reason (`JUDGMENT_REJECTED`). A merged file missing any id diff --git a/docs/skills.md b/docs/skills.md index 405d690e8..8e760a90c 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -733,7 +733,7 @@ Every `/ship` run builds a code path map from your diff, searches for correspond ### Review gate -`/ship` displays historical review readiness in the [Review Readiness Dashboard](#review-readiness-dashboard) during preflight. A missing Eng Review is reported without an extra question; it does not replace or waive the current pre-landing review. Step 9 still runs the checklist, applicable specialists and bounded exploratory QA, with its existing approval and completion gates. +`/ship` displays historical review readiness in the [Review Readiness Dashboard](#review-readiness-dashboard) during preflight. A missing Eng Review is reported without an extra question; it does not replace or waive the current pre-landing review. Step 9 still runs the checklist, one batched specialist (Testing and Security, plus the checklists scope selects) and bounded exploratory QA, with its existing approval and completion gates. A lot of branches die when the interesting work is done and only the boring release work is left. Humans procrastinate that part. AI should not. diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index eabdb5394..89ba9a833 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -67,7 +67,9 @@ const briefText = async (r) => { return text; }; const roleArgs = (r) => ' --role ' + q(r.role) + (r.name ? ' --name ' + q(r.name) : ''); -const modelOf = (r) => r.model || (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role]; +const REASONING = { medium: 'medium', light: 'low' }; +const modelOf = (r) => r.model || (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role] + || (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined); const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_'); const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team']; @@ -134,6 +136,8 @@ try { m = await load(); } if (m.readers.some((x) => x.stage === stage && x.role === 'outside-voice' && x.state === 'rendered' && !modelOf(x))) return stop("the outside voice needs input.models['outside-voice'] (a model of another family); none was given"); + const unmapped = m.readers.find((x) => x.stage === stage && x.state === 'rendered' && REASONING[x.effort] && !modelOf(x)); + if (unmapped) return stop(unmapped.reader + ' runs at effort=' + unmapped.effort + ', which needs input.laneModel (your model id) or input.models; none was given'); for (const r of m.readers.filter((x) => x.stage === stage && x.state === 'rendered')) { const model = modelOf(r); const text = await briefText(r); diff --git a/lib/pregate/import-graph.ts b/lib/pregate/import-graph.ts index 6f6dc13b7..2445448c6 100644 --- a/lib/pregate/import-graph.ts +++ b/lib/pregate/import-graph.ts @@ -56,6 +56,9 @@ const scanner = new Bun.Transpiler({ loader: 'tsx' }); // names it, like a relative import; without it a test reading a template by // relative path had no edge to the template (GSTA-121's test/ship-publication-gates.test.ts). const QUOTED = /['"`]((?:\.\.?\/)*[A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g; +// A dot-led literal (`'.gitleaks.toml'`, `'../.github/x.yml'`) runs as its own pass and leaves its +// closing quote unconsumed, so a closing quote followed by `.method(` never eats the next literal's quote. +const DOTFILE = /['"`]((?:\.\.?\/)*\.[A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)(?=['"`])/g; const JOIN = /\bjoin\(([^()]*)\)/g; const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.mjs']; // A statement starts a line or follows `;` or a compound statement's `:`. The module spec is @@ -291,11 +294,11 @@ function resolveCandidate(files: Set, dirs: Set, c: string, lite */ function resolveRefs(files: Set, dirs: Set, fromFile: string, ref: string, literal = false, config: PathAliases = NO_ALIASES): string[] { if (!ref || ref.startsWith('node:') || ref.startsWith('bun:')) return []; - if (ref.startsWith('.')) { const t = resolveCandidate(files, dirs, normalize(path.posix.join(path.posix.dirname(fromFile), ref)), literal); return t ? [t] : []; } + if (/^\.\.?(?:\/|$)/.test(ref)) { const t = resolveCandidate(files, dirs, normalize(path.posix.join(path.posix.dirname(fromFile), ref)), literal); return t ? [t] : []; } const { chosen, others } = matchAliases(config.aliases, ref); for (const c of chosen ?? []) { const t = resolveCandidate(files, dirs, c, literal); if (t) return [t]; } if (!chosen && others.length === 0 && ref.startsWith('@') && !ref.includes('/')) return []; - const plausible = [...others, ...(config.baseUrl ? [normalize(path.posix.join(config.baseUrl, ref))] : []), normalize(ref)]; + const plausible = [...others, ...(config.baseUrl ? [normalize(path.posix.join(config.baseUrl, ref))] : []), normalize(ref), ...(literal && ref.startsWith('.') ? [normalize(path.posix.join(path.posix.dirname(fromFile), ref))] : [])]; return [...new Set(plausible.map(c => resolveCandidate(files, dirs, c, literal)).filter((t): t is string => t !== null))]; } @@ -429,6 +432,7 @@ export function referencesIn(files: Set, dirs: Set, rel: string, // sources (a test spawns a bin; a bash bin calls a sibling). Prose and // manifests name everything and would make every file a hub. if (!scansLiterals(rel)) return out; + for (const m of source.matchAll(DOTFILE)) for (const target of resolveRefs(files, dirs, rel, m[1]!, true, config)) out.push({ target, via: 'literal' }); for (const m of source.matchAll(QUOTED)) { const targets = resolveRefs(files, dirs, rel, m[1]!, true, config); for (const target of targets) out.push({ target, via: 'literal' }); diff --git a/lib/qa-deadline.ts b/lib/qa-deadline.ts index bbc3c4462..9e65bfb72 100644 --- a/lib/qa-deadline.ts +++ b/lib/qa-deadline.ts @@ -224,6 +224,9 @@ export async function runQaWindowsWorker(args: string[], emit: Emit, entrypoint: worker.on('message', (message: any) => { if (message?.type === messageType && ['stdout', 'stderr'].includes(message.stream) && message.receipt && typeof message.receipt === 'object') emit(message.stream, message.receipt, message.completion); + if (message?.type === messageType && Number.isSafeInteger(message.seq)) { + try { worker.send({ type: `${messageType}-ack`, seq: message.seq }, () => {}); } catch {} + } }); worker.once('error', () => { process.off('exit', kill); @@ -244,6 +247,10 @@ export async function withQaReceiptOutput(receiptWorker: boolean, messageType: s stderr: fs.createWriteStream('', { fd: 2, autoClose: false }), }; const writes: Promise[] = []; + const acks = new Map void>(); + if (receiptWorker) process.on('message', (message: any) => { + if (message?.type === `${messageType}-ack`) acks.get(message.seq)?.(); + }); let writeFailed = false; const failed = () => { writeFailed = true; }; output.stdout.on('error', failed); @@ -252,7 +259,11 @@ export async function withQaReceiptOutput(receiptWorker: boolean, messageType: s writes.push(new Promise(resolve => { const done = (error?: Error | null) => { if (error) writeFailed = true; resolve(); }; try { - if (receiptWorker) process.send!({ type: messageType, stream, receipt, ...(completion ? { completion } : {}) }, done); + if (receiptWorker) { + const seq = writes.length; + acks.set(seq, () => { acks.delete(seq); resolve(); }); + process.send!({ type: messageType, seq, stream, receipt, ...(completion ? { completion } : {}) }, (error?: Error | null) => { if (error) done(error); }); + } else output[stream].write(format(receipt), done); } catch { writeFailed = true; resolve(); } })); diff --git a/lib/review-brief.ts b/lib/review-brief.ts index b3e3063c2..65e207403 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -117,7 +117,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { return fill(t.native, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); case 'outside-voice': { const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); - return [body, '', 'Give each finding a severity: Critical, High, Medium or Low.', '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); + return [body, '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); } case 'fix-check': return renderFixCheckPrompt({ fixList: need(slots, 'fix_list'), touchedHunks: need(slots, 'hunks'), testResults: need(slots, 'tests'), callers: slots.callers ?? '' }); diff --git a/lib/review-collect.ts b/lib/review-collect.ts index 3846df0f3..1cfc02384 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -12,7 +12,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { GENERATED_PATH, parseRegistry } from './regen'; import { RESULT_CODES } from './result-codes'; -import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth'; +import { classifyPath, dataCandidate, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; export interface Collected extends ClassInput { perPath: PathLines[]; @@ -25,11 +25,12 @@ export interface Collected extends ClassInput { diffLines: number; mergeBase: string | null; untracked: string[]; + consumed: string[] | 'all'; } -function git(root: string, args: string[]): { ok: boolean; out: string; err: string } { +function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } { const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); - return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; + return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; } const scratch = (p: string) => p.startsWith('.gstack/tmp/'); const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200); @@ -88,7 +89,60 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick, extra: Pick): Collected { +/** More data candidates than this keep every data file code: the consumer scan is not run. */ +export const CONSUMER_SCAN_CAP = 2000; +/** How many data files deep a chain of data naming data is followed (a manifest naming a data file) before the rest count as consumed. */ +export const CONSUMER_SCAN_DEPTH = 4; + +/** + * The data candidates (lib/review-depth.ts dataCandidate) among the changed and deleted paths that a non-docs file names + * by basename, at each rev and, with `live`, in the working tree with untracked files. The merge base is one of the revs, + * so a consumer the change removes still counts. A data file named only by another data file is consumed when that one + * is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure is + * a collector error, which fails the depth closed. + */ +export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' { + const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot)).sort(); + if (candidates.length > CONSUMER_SCAN_CAP) return 'all'; + const base = (p: string) => p.slice(p.lastIndexOf('/') + 1); + const named = new Map>(); + const direct = new Set(); + const probed = new Set(); + let probe = candidates; + for (let depth = 0; probe.length && depth < CONSUMER_SCAN_DEPTH; depth++) { + const patterns = [...new Set(probe.map(base))].flatMap(b => ['-e', b]); + const runs = [...revs.map(rev => ({ at: rev, args: [rev, '--'] })), ...(live ? [{ at: '', args: ['--untracked', '--'] }] : [])]; + const next = new Set(); + for (const run of runs) { + const r = git(root, ['grep', '-I', '-F', '-o', '--null', '--no-line-number', '--no-column', '--no-color', ...patterns, ...run.args]); + if (r.status !== 0 && r.status !== 1) { errors.push({ code: 'consumer_scan_failed', detail: oneLine(r.err || `git grep exited ${r.status}`), input: `git grep -F ${run.at || '--untracked'}`, repair: 'run the git grep by hand and fix what git reports' }); return 'all'; } + for (const line of r.out.split('\n')) { + const parts = line.split('\0'); + const where = parts[0]; + const match = parts.length > 1 ? parts[parts.length - 1] : undefined; + if (!where || !match) continue; + const file = run.at ? where.slice(run.at.length + 1) : where; + if (scratch(file) || classifyPath(file, classRoot) === 'docs') continue; + for (const p of probe) { + if (file === p || !match.includes(base(p))) continue; + if (!dataCandidate(file, classRoot)) { direct.add(p); continue; } + (named.get(file) ?? named.set(file, new Set()).get(file)!).add(p); + if (!probed.has(file) && !probe.includes(file)) next.add(file); + } + } + } + for (const p of probe) probed.add(p); + probe = [...next]; + } + const consumed = new Set([...direct, ...probe]); + for (let grew = true; grew;) { + grew = false; + for (const [file, targets] of named) if (consumed.has(file)) for (const p of targets) if (!consumed.has(p)) { consumed.add(p); grew = true; } + } + return candidates.filter(p => consumed.has(p)); +} + +function result(diff: ReturnType, extra: Pick): Collected { const paths = diff.perPath.map(e => e.path).sort(); return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; } @@ -100,13 +154,14 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C const mb = git(root, ['merge-base', baseSha, headSha]); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase, headSha], errors); const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]); const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort(); - return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] }); + const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [headSha, mergeBase], false, skillDirs, errors); + return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed }); } /** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */ @@ -117,7 +172,7 @@ export function collectLive(root: string, base: string): Collected { const mb = git(root, ['merge-base', ref, 'HEAD']); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); @@ -133,5 +188,6 @@ export function collectLive(root: string, base: string): Collected { diff.perPath.push({ path: p, lines, binary }); if (!binary) diff.added[p] = text.split('\n'); } - return result(diff, { root, registry, errors, mergeBase, untracked }); + const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors); + return result(diff, { root, registry, errors, mergeBase, untracked, consumed }); } diff --git a/lib/review-depth.ts b/lib/review-depth.ts index bce1fffef..a7af650d3 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -17,7 +17,7 @@ import { matchesAny } from './ship-policy'; export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; export type Depth = 'light' | 'standard' | 'full'; -export type Effort = 'light' | 'full' | 'host'; +export type Effort = 'light' | 'medium' | 'full' | 'host'; export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check'; export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop'; @@ -29,8 +29,16 @@ export const RED_TEAM_LINES = 200; export const LARGE_DIFF_LINES = 100; /** The largest fix range, in runtime lines, a pass 2 after a blocking fix reviews as a scoped pass (GSTA-152 D6); a starting value. */ export const SCOPED_RANGE_LINES = 150; -/** The checklists light's batched specialist always reads, whatever the scope. */ -export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'maintainability', 'security']; +/** The checklists the batched specialist always reads on code and gate class, whatever the depth or scope (GSTA-167 part 2). */ +export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'security']; +/** The checklists the batched specialist adds when scope asks; maintainability, performance and simplification are read only when forced. */ +export const BATCH_SCOPED: readonly Specialist[] = ['data-migration', 'api-contract', 'design', 'state-machines']; +/** + * Each blocking role's effort (GSTA-167 part 3, CREW 30c): specialists at medium, which is how the no-loss controls ran + * them; the Red Team, the native adversarial pass and the fix-check at full (the lane's reasoning); the outside voice at + * its own provider's. Light's batched specialist carries the Red Team prompt, so it stays full. + */ +export const ROLE_EFFORT = { specialist: 'medium', 'red-team': 'full', 'adversarial-native': 'full', 'fix-check': 'full', 'outside-voice': 'host' } as const satisfies Record; /** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */ export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)'; const CLASS_RANK: Record = { docs: 0, tests: 1, code: 2, gate: 3 }; @@ -48,10 +56,18 @@ const GATE_PATH = [ /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, ]; +/** 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. */ +export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i; +/** Data-shaped files tools read by convention, with no code naming them: always code. */ +const DATA_CONFIG = [ + /(^|\/)\.github\//, /(^|\/)config\//, /(^|\/)\.[^/]+$/, /(^|\/)[^/]+\.config\.[^/]+$/, + /(^|\/)(package|package-lock|npm-shrinkwrap|composer|deno|jsr|tsconfig[^/]*|jsconfig[^/]*|manifest|app|vercel|netlify|firebase|renovate|turbo|nx|lerna|biome|angular|nest-cli|lint-staged|components|pyrightconfig|cdk|serverless|appsettings[^/]*)\.json$/i, +]; + /** 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, SCOPED_RANGE_LINES, - patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), + 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); /** @@ -70,7 +86,21 @@ export function classifyPath(p: string, root?: string | readonly string[]): Path return root.includes(top) ? 'code' : 'docs'; } -export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path'; +/** + * A code-class path the data rule may move to docs: a data extension, and not agent prose, a gate path or a config file. + * Only classifyDiff applies the rule, and only to a candidate its collector found no consumer for (ClassInput.consumed); + * classifyPath and every path-only caller keep data files code. + */ +export function dataCandidate(p: string, root?: string | readonly string[]): boolean { + return DATA_PATH.test(p) && classifyPath(p, root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p)); +} + +/** Whether a range needs the gate scan: any path that is not docs by the path-only rule. The data rule never skips it. */ +export function gateScanNeeded(paths: readonly string[], root?: string | readonly string[]): boolean { + return paths.some(p => classifyPath(p, root) !== 'docs'); +} + +export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path' | 'consumer_scan_failed'; /** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */ export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName } export interface PathLines { path: string; lines: number; binary: boolean } @@ -94,12 +124,18 @@ export interface ClassInput { registry?: RegistryInfo; /** Collector failures (a diff git could not list): each fails the depth closed. */ errors?: readonly DepthError[]; + /** + * The data candidates (dataCandidate) a non-docs file names at the head, the merge base or in the working tree + * (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code. + */ + consumed?: readonly string[] | 'all'; } export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null } 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); -const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root)); +const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root); +const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root)); /** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { @@ -109,6 +145,8 @@ export function classifyDiff(i: ClassInput): ClassResult { const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); }; if (!i.paths.length) raise('docs', 'no changed paths'); for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } + const data = i.paths.filter(p => unconsumedData(i, p)); + if (data.length) reasons.push(`docs: ${data.length} data path(s) no code names`); const s = i.scope ?? {}; if (s.SCOPE_ERROR) { raise('code', `scope error: ${s.SCOPE_ERROR}`); @@ -251,8 +289,10 @@ export interface PlanInput extends ClassInput { reviewedNumbers?: string; /** --depth-floor: raises the depth, never lowers it. */ floor?: Depth; + /** false under --no-tiers or review_effort_tiers off: specialists run at the host's default effort. */ + tiers?: boolean; } -/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what light's batched specialist reads. */ +/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */ export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string } export interface ReviewPlan extends DepthResult { /** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */ @@ -294,6 +334,29 @@ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = }); } +/** + * The checklists the one batched specialist reads (the lean ensemble, GSTA-167 part 2): the class sets the base (none for docs, + * testing for tests, LIGHT_FIXED for code and gate), BATCH_SCOPED joins when scope asks and hit-rate gating allows (gate class + * ignores gating), and a forced checklist always joins. + */ +export function batchChecklists(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] { + const forced = forcedBy(i); + const codeLike = cls === 'code' || cls === 'gate'; + const base: readonly Specialist[] = codeLike ? LIGHT_FIXED : cls === 'tests' ? ['testing'] : []; + if (cls === 'docs') skipped.push('specialists (docs class)'); + if (cls === 'tests') skipped.push('specialists except testing (tests class)'); + const extra = SPECIALISTS.filter(n => { + if (base.includes(n)) return false; + if (forced(n)) return true; + if (!codeLike || !BATCH_SCOPED.includes(n) || !scopeWants[n]!(i.scope ?? {})) return false; + if (cls !== 'gate' && (i.gated ?? []).includes(n)) { skipped.push(`${n} (gated by hit rate)`); return false; } + return true; + }); + const dropped = SPECIALISTS.filter(n => codeLike && !BATCH_SCOPED.includes(n) && !base.includes(n) && !extra.includes(n)); + if (dropped.length) skipped.push(`${dropped.join(', ')} (lean ensemble: read only when forced)`); + return [...base, ...extra]; +} + /** A path as one git exclude pathspec; a glob character or a leading colon makes it literal. */ const excludeSpec = (p: string) => `:(exclude${/[*?[\]\\]/.test(p) || p.startsWith(':') ? ',literal' : ''})${p}`; const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; @@ -317,18 +380,17 @@ export function planReview(i: PlanInput): ReviewPlan { const dispatches: Dispatch[] = []; const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) => dispatches.push({ role, name, effort, exclude, ...extra }); const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec); - const tests = i.paths.filter(p => pathClass(i, p) === 'tests' && !isOutput(i, p)).map(excludeSpec); const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint; const firstVoices = h.fullPasses === 0 || !i.adversarialRan; + const specialist: Effort = i.tiers === false ? 'host' : ROLE_EFFORT.specialist; if (next.step === 'full' && stepDepth === 'light') { const forced = forcedBy(i); if (runtimeLines > 0) { - const scoped = SPECIALISTS.filter(n => !LIGHT_FIXED.includes(n) && (forced(n) || (scopeWants[n]?.(i.scope ?? {}) && !(i.gated ?? []).includes(n)))); - add('specialist', 'batched', 'full', docsAndOutputs, { checklists: [...LIGHT_FIXED, ...scoped, 'red-team'] }); + add('specialist', 'batched', ROLE_EFFORT['red-team'], docsAndOutputs, { checklists: [...batchChecklists('code', i, skipped), 'red-team'] }); skipped.push('red-team (light depth: the batched specialist carries the Red Team prompt)'); if (firstVoices) add('adversarial-native', 'adversarial', 'full', [], { reason: LIGHT_ADVERSARIAL_REASON }); } else { - for (const n of SPECIALISTS.filter(forced)) add('specialist', n, 'full'); + for (const n of SPECIALISTS.filter(forced)) add('specialist', n, specialist); skipped.push('specialists, red-team and adversarial-native (light depth, runtime 0)'); } if (h.fullPasses === 0 && i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); @@ -339,7 +401,8 @@ export function planReview(i: PlanInput): ReviewPlan { const dcls: DiffClass = full ? 'gate' : cls; const raised = !full && runtimeLines <= SMALL_DIFF_LINES; const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0); - for (const n of specialistsFor(dcls, { ...i, diffLines: lines }, skipped)) add('specialist', n, 'full', full ? [] : n === 'testing' ? docsAndOutputs : [...docsAndOutputs, ...tests]); + const checklists = batchChecklists(dcls, i, skipped); + if (checklists.length) add('specialist', 'batched', specialist, full ? [] : docsAndOutputs, { checklists }); const redTeam = dcls === 'gate' || (dcls === 'code' && (lines > RED_TEAM_LINES || !!i.criticalFound || raised)); if (redTeam) add('red-team', 'red-team', 'full'); else skipped.push(`red-team (${dcls} class${dcls === 'code' ? `, ${lines} runtime lines, no critical` : ''})`); @@ -356,7 +419,7 @@ export function planReview(i: PlanInput): ReviewPlan { } else if (next.step === 'scoped') { add('fix-check', 'fix-check', 'full'); const forced = SPECIALISTS.filter(forcedBy(i)); - for (const n of forced) add('specialist', n, 'full'); + for (const n of forced) add('specialist', n, specialist); skipped.push(forced.length ? 'specialists (scoped pass; forced ones dispatched)' : 'specialists (scoped pass)'); add('red-team', 'red-team', 'full'); const stepElevenPending = !i.adversarialRan && cls === 'code'; diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index 185474689..0c9a96e9f 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -8,7 +8,9 @@ * judgment each lead-judgment change: an added or promoted finding, with its reason * judge the verdict on the parent's judged set (ok, or the dropped and downgraded ids) * - * Findings reach merged.jsonl only through collect, with each voice's label exactly as written. + * Findings reach merged.jsonl only through collect, with each voice's label as written (a prose line's most severe one, + * raised by the Severity line below it), except that a prose voice's `Blocks: yes` and the native voice's FIXABLE collect + * as CRITICAL (GSTA-167 normalizer v2). * The parent's judged set may add or promote a finding and may fold a duplicate into one at least * as severe; it never drops or downgrades one. verify replays every check from the ledger alone. */ @@ -41,7 +43,8 @@ export function recordSent(ledger: string, reader: string, sentText: string): { /** Severity ranks across the voices' vocabularies; a label with no severity (FIXABLE, INVESTIGATE) ranks 0. */ const RANKS: Record = { critical: 4, p0: 4, high: 3, p1: 3, medium: 2, p2: 2, informational: 2, low: 1, p3: 1, fixable: 0, investigate: 0 }; -export function rank(label: string): number | null { +export function rank(label: unknown): number | null { + if (typeof label !== 'string') return null; const r = RANKS[label.trim().replace(/^\[|\]$/g, '').toLowerCase()]; return r === undefined ? null : r; } @@ -53,31 +56,81 @@ const LABEL_HEADING = new RegExp(`^(?:#{1,6}\\s*|\\*\\*)(${LABELS})(?:\\s+(?:fin const HEADING = /^(?:#{1,6}\s|\*\*[^*]+\*\*:?$)/; const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+\S/; const stronger = (a: string, b: string) => ((rank(a) ?? 0) >= (rank(b) ?? 0) ? a : b); +/** A prose voice's `Blocks: yes` (GSTA-167 normalizer v2): the finding is CRITICAL whatever its label; `Blocks: no` never lowers one. */ +const BLOCKS_YES = /(? { - const out: Array<{ label: string; raw: string }> = []; + type Finding = { label: string; raw: string; fields: Set }; + const out: Finding[] = []; let section: string | null = null; + let current: Finding | null = null; + let head = ''; + let gap = false; + const role = reader.split('/')[0]; + const named = (l: string) => (role === 'adversarial-native' && l.toUpperCase() === 'FIXABLE' ? 'CRITICAL' : l); + const strongest = (t: string) => [...t.matchAll(ALL_LABELS)].map(m => named(m[1]!)).reduce((a, b) => (a === undefined ? b : stronger(a, b)), undefined); + const push = (label: string, raw: string, fields: string[] = []): Finding => { const f = { label, raw, fields: new Set(fields) }; out.push(f); return f; }; for (const line of output.replace(/\r\n/g, '\n').split('\n')) { const t = line.trim(); - if (!t || /^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) continue; + if (!t) { gap = true; continue; } + const afterGap = gap; + gap = false; + if (/^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) { current = null; continue; } + const indented = line !== line.trimStart(); + const label = strongest(t); + const blocks = BLOCKS_YES.test(t); + const listItem = !indented && LIST_ITEM.test(t); + const locus = LOCUS.test(t); + const meta = listItem && locus ? null : META.exec(t); + if (meta) { + const field = meta[1]!.toLowerCase(); + if (current && !current.fields.has(field)) { + current.fields.add(field); + if (blocks) current.label = 'CRITICAL'; + else if (label) current.label = stronger(current.label, label); + current.raw += ` | ${t}`; + } else if (blocks || label) current = push(blocks ? 'CRITICAL' : label!, head ? `${head} | ${t}` : t, [field]); + else current = null; + continue; + } const heading = LABEL_HEADING.exec(t); - if (heading) { section = heading[1]!; continue; } - if (line === line.trimStart() && HEADING.test(t)) section = null; + if (heading) { section = named(heading[1]!); current = null; head = ''; continue; } + if (!indented && HEADING.test(t)) { section = null; current = null; } if (t.startsWith('{')) { try { const o = JSON.parse(t) as Record; - if (typeof o.severity === 'string') { out.push({ label: o.severity, raw: t }); continue; } + if (typeof o.severity === 'string') { current = push(o.severity, t); continue; } } catch { /* prose that starts with a brace */ } } - const inline = ANY_LABEL.exec(t)?.[1]; - const inherited = section && line === line.trimStart() && LIST_ITEM.test(t) ? section : undefined; - const label = inline && inherited ? stronger(inherited, inline) : inline ?? inherited; - if (label) out.push({ label, raw: t }); + const fresh = listItem || (!indented && (t.startsWith('**') || afterGap)) || (!!current?.fields.size && locus); + if (fresh) { current = null; head = t; } + const inherited = section && listItem ? section : undefined; + const found = label && inherited ? stronger(inherited, label) : label ?? inherited; + if (found) { current = push(blocks ? 'CRITICAL' : found, t); if (!indented) head = t; continue; } + if (blocks && !t.startsWith('{')) { + if (current) { current.label = 'CRITICAL'; current.raw += ` | ${t}`; continue; } + current = push('CRITICAL', head && head !== t ? `${head} | ${t}` : t); + continue; + } + if (!fresh && !current && (!LIST_ITEM.test(head) || locus)) head = t; } - return out; + return out.map(({ label, raw }) => ({ label, raw })); } /** collect: hash the raw output, append its findings to merged.jsonl and record both. */ diff --git a/lib/review-pass.ts b/lib/review-pass.ts index 03fa06431..7153982a3 100644 --- a/lib/review-pass.ts +++ b/lib/review-pass.ts @@ -138,7 +138,7 @@ export function renderStage(m: Manifest, stage: Stage, t: SectionTexts, diffFor: const findings = specialistFindings(m.merged); fs.writeFileSync(path.join(briefs, 'specialist-findings.jsonl'), findings, { mode: 0o600 }); findingsSha = promptHash(findings); - const specialists = m.readers.filter(x => x.role === 'specialist' || x.role === 'specialist-batched').length; + const specialists = m.readers.reduce((n, x) => n + (x.role === 'specialist' ? 1 : x.role === 'specialist-batched' ? (x.checklists ?? []).filter(c => c !== 'red-team').length : 0), 0); Object.assign(slots, { checklist: path.join(m.checklist_root, 'review/specialists/red-team.md'), findings, specialists: String(specialists) }); } if (role === 'outside-voice') slots.diff = diffFor(m.diff_base); diff --git a/lib/review-scope.ts b/lib/review-scope.ts index 97e0e3e19..b514dd557 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -14,6 +14,7 @@ * Pure functions here; bin/gstack-review-scope runs git and gh. */ import { SPECIALISTS } from './review-depth'; +import { parseSpecialistOutput } from './state-matrix'; export type Locus = 'diff' | 'fix-range' | 'outside'; export type Kind = 'defect' | 'test-gap' | 'advisory'; @@ -130,13 +131,47 @@ function fingerprintLine(fp: unknown): number | undefined { return Number.isInteger(n) && n > 0 ? n : undefined; } +/** The blocking floor in Garry's words (docs/review-policy.md, CREW rule 23); every reviewer prompt carries it verbatim. */ +export const BLOCKING_FLOOR = 'would fail a test, break production, or lose data or secrets'; + /** The categories a reviewer uses for a test finding; `testing` is what the Testing specialist's schema emits. */ export const TEST_GAP_CATEGORIES: readonly string[] = ['testing', 'tests', 'test-gap', 'test-coverage', 'coverage', 'missing-test', 'untested']; -/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test), or a product defect. */ +/** What mapStateMatrixOutput stamps on an untested state-matrix cell: a test gap, below the floor (docs/review-policy.md). */ +export const UNTESTED_CELL = 'untested-cell'; + +const cellKey = (f: Record): string | null => (f.cell && typeof f.cell === 'object' ? `${String(f.cell.kind ?? '').trim().toLowerCase()}\u0000${String(f.cell.action ?? '').trim().toLowerCase()}` : null); + +/** + * A reviewer output (JSON lines) with each untested state-matrix cell mapped below the floor, per matrix cell and before + * Step 9.3's fingerprint merge: a `state-matrix` finding the checklist's schema writes as "untested cell: …", whose `cell` is a + * cell with `"test": null` in a matrix record of the same output, and the only finding on that cell, becomes INFORMATIONAL with `state_matrix: "untested-cell"` and its + * `raw_severity`. A second finding on the cell (a wrong row, collapsed kinds) keeps both as they are, so a merge can never + * fold a defect into a demoted record; a cell no matrix lists as untested changes nothing. Every other line, matrix records + * included, is returned byte for byte; `gstack-state-matrix check` reads the raw output, never this one. + */ +export function mapStateMatrixOutput(text: string): { text: string; mapped: number } { + const { matrices, findings } = parseSpecialistOutput(text); + const untested = new Set(matrices.flatMap(m => m.cells.filter(c => c.test === null).map(c => cellKey({ cell: c })!))); + const perCell = new Map(); + for (const f of findings) { const k = cellKey(f); if (k) perCell.set(k, (perCell.get(k) ?? 0) + 1); } + let mapped = 0; + const lines = text.split('\n').map(line => { + let f: Record; + try { f = JSON.parse(line.trim()); } catch { return line; } + const k = f && typeof f === 'object' && !f.matrix && f.category === 'state-matrix' && /^untested cell\b/i.test(String(f.summary ?? '')) ? cellKey(f) : null; + if (!k || !untested.has(k) || perCell.get(k) !== 1 || f.state_matrix === UNTESTED_CELL) return line; + mapped += 1; + return JSON.stringify({ ...f, severity: 'INFORMATIONAL', raw_severity: f.severity ?? null, state_matrix: UNTESTED_CELL }); + }); + return { text: lines.join('\n'), mapped }; +} + +/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test, an untested state-matrix cell still below CRITICAL), or a product defect. */ export function kindOf(finding: ScopeFinding): Kind { if (finding.advisory === true) return 'advisory'; if (TEST_GAP_CATEGORIES.includes(String(finding.category ?? '').toLowerCase())) return 'test-gap'; + if (finding.state_matrix === UNTESTED_CELL && String(finding.severity ?? '').toUpperCase() !== 'CRITICAL') return 'test-gap'; return 'defect'; } @@ -153,6 +188,8 @@ export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks /** * The scope decision per finding. `all`: fix everything (today's loop). + * An untested state-matrix cell mapped below the floor is a note from pass 1 on + * (docs/review-policy.md, the test-gap ruling) under the other policies. * `frozen` pass 1: fix the diff, defer blocking findings in unchanged code, * note the rest. `frozen` pass 2+: fix blocking findings in the range since * the previous pass or in the diff; defer blocking findings outside; note @@ -170,6 +207,7 @@ export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInp const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined; let scope: ScopeAction; if (input.policy === 'all') scope = 'fix'; + else if (kind === 'test-gap' && finding.state_matrix === UNTESTED_CELL) scope = 'note'; else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer'; else if (input.pass <= 1 && !AFTER_PASS_RAISERS.includes(raised)) scope = 'fix'; else scope = blocking ? 'fix' : 'note'; diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index 976a44860..7ba8b261d 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -47,7 +47,7 @@ export type Visibility = 'public' | 'private' | 'unknown'; const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility']; const MAX_ENTRIES = 64; const MAX_REGEX = 256; -const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; +const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-]|\$\{!?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\s]{0,64}\])?|[0-9]+|[@*#?$!-]):?-)(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; const PUBLIC_TTL_MS = 24 * 3600_000; const PRIVATE_TTL_MS = 3600_000; diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index e66d8dbbd..cca22b3ca 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -7,14 +7,10 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { classifyPath } from './review-depth'; +import { classifyDiff } from './review-depth'; import { audit } from './ship-audit'; -import { matchesAny } from './ship-policy'; import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; -/** Data files: code-class paths with no runtime behavior, left out of RUNTIME_LINES. */ -const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i; - 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