diff --git a/CHANGELOG.md b/CHANGELOG.md index 297daf775..fabef13e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ # Changelog -## [Unreleased] +## [1.93.10.0] - 2026-11-02 + +### Changed + +- Evals test frontier models (R54). /eval-plan and docs/TESTING_INTERNALS.md#frontier-models-under-test now say that models under test are Opus 5.5 and GPT-6.1 Sol, plus at most one named low-cost primary. Haiku and Sonnet aren't stand-ins for a frontier model, and a Haiku- or Sonnet-only result can't carry a KEEP, FIX-METHOD or CUT-CANDIDATE verdict. Judges, review voices and housekeeping models keep their models. `gstack-eval-plan dry-run` refuses a plan whose every arm is Haiku or Sonnet unless `--tier-question` names what it asks about that tier, warns on a mixed plan, and prices the whole plan on frontier models. `pilot start` refuses a Haiku- or Sonnet-only pilot unless its dry run recorded that tier question. + +### Fixed + +- Agent SDK evals can no longer run on the SDK's bare stub prompt by accident. Every `runAgentSdkTest` and direct SDK `query()` caller now passes Claude Code's preset or names why it doesn't. The runner refuses a missing, empty or bare-string prompt, and a free tripwire test scans test/ and scripts/ for the same mistake. docs/TESTING_INTERNALS.md#agent-sdk-system-prompt says to record which prompt each arm used, and to treat a null result from an arm on the stub as FIX-METHOD. + +**A test that keeps flaking while someone fixes it can be quarantined instead of skipped, and it still can't turn a red run green.** `gstack-flaky quarantine add` puts the test in `.gstack/quarantine.json` with an owner, a ledger link and an expiry. The test keeps running on every run and is never counted as a pass. When every test an attempt failed is quarantined, the whole command may run again, up to 3 attempts, and the run is green only when an attempt gets the runner's own full green verdict, so a coverage threshold or another failure outside the report stays red. Before this, a test that flaked every few runs either went red on every PR or got skipped and forgotten. + +### Added +- **Quarantine registry, `gstack-flaky quarantine add|renew|release|check|report`** (`.gstack/quarantine.json`, GSTA-158 P2): a quarantined test that fails all 3 attempts is a `hard-break` (red), a quarantined id missing from a run that included its file is red, and quarantined tests are listed (`FLAKY_RESULT … quarantined=`) and ledgered (`quarantined-pass`, `quarantined-fail`, `hard-break`) on every run. Tests that are not quarantined keep their one rerun. +- **Entries are owned and expire**: owner, ledger link, class (`detector`, `harness`, `timing`, `model-latency`; `product` and `infra` refused), evidence, 7 days by default and 14 at most, one renewal of up to 7 days with new evidence and the owner named again. An expired entry stops applying with no edit and prints the renew and release commands. Live entries are capped at 10% of the suite (at least 1, and 5 under 50 tests). +- **`run --base `** reads the registry from the base branch, so a change cannot quarantine the test it breaks. **`check --base`** refuses an entry added in the change that edits its test's file, an entry removed without a release record, a removed release record and rewritten history. **`report --ledger `** lists entries soonest-expiring first and marks those with 10 consecutive clean passes ready to release. gstack's own `CASE_QUARANTINE` / `EVAL_POLICY` is unchanged and shares no schema with it. + +### Docs +- `docs/flaky.md#quarantine`: commands, rules, expiry, cap, classes, the `CASE_QUARANTINE` separation and the daily-pass `gh run download` + `report` loop. `captain/templates/automation-flaky-pass.md` and `docs/captain.md` run `report` and fix expiring entries first. + +### Tests +- `test/quarantine.test.ts`: add refusals (cap, more than 14 days, no evidence, product or infra class, unknown id), one renewal and future-dated renewals, release, `check --base` against git fixtures (non-ASCII paths included), expiry, a hard break across 3 attempts, the false-green regression (exit 1 from outside the report while only a quarantined test fails stays red), missing ids with their file present or absent, and the registry read from the base branch. + +### Gate integrity +- `test/flaky-run.test.ts`: the exact `FLAKY_RESULT` line it expects gains the new `quarantined=0` field. No other assertion, threshold or skip changed. + +- `docs/support-tiers.md` describes Windows CI as it works now. `windows-smoke` is the required Windows check on pull requests, and the six-shard `windows-free-tests` suite runs nightly on main or by hand and never blocks a pull request. The page was written before #3806 landed, so it still said these changes were coming. **The train gate's paid report now prices the trials it used to call "cost unknown".** 32 of 139 gate trials had no cost on every gate since Oct 26, so the report's total understated a gate by about $7.50 (GSTA-150 audit). All four causes are fixed. @@ -14,6 +40,86 @@ ### Tests - `test/paid-report-cost.test.ts`: pricing arithmetic, streamed dedupe, `caseCost`, `recordE2E`'s `cost_case_id`, a two-case shard whose label-named records bind by cost without changing the failed record, class or model. `test/paid-report-census.test.ts`: a record-less trial's streamed cost reaches the trial and the verdict total once. `test/paid-report-cost.test.ts` also prices a PTY session, its inline sidechains and subagent files once per message id, leaves a foreign session, a missing or unreadable transcript or an unpriced model unknown, and claims only the one new transcript for a launch's cwd. `test/helpers/claude-pty-runner.launch.unit.test.ts`: a real launch prices the transcript it created without adding an argument, and an opted-out launch prices nothing. +## [1.93.9.0] - 2026-11-02 + +**`gstack-ship-receipt read --check-prose` no longer reads the required Review-loop exception line as counter claims (GST-977).** `write --review-loop-exception` requires the PR body to carry the whole `Review-loop exception: …` line word for word, but the prose check read that line as prose. On gstack-evals #139 the owner's reason "outside 1 and 2 skip detector" was read as a `2 skip` claim against the receipt's 70 skips, so no body could pass both checks. + +### Fixed +- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own exception exactly (the same test `write` applies) is skipped, like the rendered `## Approvals` block. A copy of that exact line inside a code fence is skipped too, as `write` accepts one. The same words inside a longer line, or a line for a different exception, are still checked. + +### Tests +- `test/ship-receipt-prose.test.ts`: the #139 exception text passes `--check-prose` beside a matching `70 skip` claim, and a wrong number elsewhere still fails. On main the same body failed with `gate.unit.skip body=2 receipt=70`. + +## [1.93.8.0] - 2026-11-02 + +**/ship now runs Step 11's voices before any gate-class stop, and a recorded owner grant runs its fix-check.** On a gate-class change, a blocker fixed in review round 2 used to stop the run for the owner before Step 11 ran. If the owner then chose "ship with open notes", gate code shipped with no adversarial, outside or targeted review (C6-36 after-opus). `gstack-review-plan` now answers `NEXT: voices` for any gate-class stop while Step 11 has not run. /ship runs Step 11 as it does on `done`, asks the plan again with `--adversarial-ran`, and the stop then stands. The round cap still limits fix rounds; it no longer skips the required voices. Second, the owner's grant of one scoped fix-check after round 2 had no way in: the plan took no grant input, and the pass program printed `PASS: none next=stop`. Now `gstack-review-plan --grant ` checks that the branch's `gstack-approvals` record holds that id as an approved grant naming `review_loop_grant` and this run's stop head (exit 3 for any other row, a grant recorded for an earlier /ship on the branch included), and plans the one fix-check in place of the stop after a round-2 full pass. The pass program then dispatches that fix-check like any other. A fix-check never earns another, so the grant cannot repeat. After `NEXT: voices`, a blocking finding the voices queue goes back to the plan as `--blocking-open` instead of starting a new review pass. + +### Tests +- `test/review-depth.test.ts`: a gate-class round-2 blocker fix plans `voices` (adversarial, outside and targeted) before every stop and stops once they ran. A grant plans one fix-check after round 2, after the voices on gate class, and never a second. The transitions table and rule 3 in `docs/review-policy.md` say both. All four failed at main 1022f4f. +- `test/review-plan-cli.test.ts`: `--grant` with a recorded grant prints `NEXT: fix-check`; an unrelated approval, an unrecorded id, a `review_loop_exception` acceptance row or a grant for an earlier head exits 3; after a fix-check the grant never plans another. Failed at main 1022f4f (`NEXT: stop`). +- `test/review-depth.test.ts`: an invariant over every transitions-table state (gate class or not, Step 11 run or not, grant or not) and a dispatch-superset no-loss check against main's plans. +- `test/review-pass.test.ts`: the pass program runs the granted fix-check reader instead of `PASS: none next=stop`. Failed at main 1022f4f. + +### Docs +- `docs/review-policy.md`: rule 3 and two new rows in the transitions table. `ship/DRIVER.md`, `ship/sections/review-army.md` and `ship/sections/adversarial.md` route `voices` and `--grant`. + +## [1.93.7.0] - 2026-11-02 + +**A version stamp the branch added at its fork point's version now counts as stale after the base moves (GST-963).** On gbrain #8425, a branch added a row that named the fork point's version. After main moved past it, `gstack-restamp` skipped the row ("no tentative mention") and `gstack-ship-release finish` routed the line as a behavior change, so the stamp kept the old version and the docs stage flagged a product edit. + +### Fixed +- **`lib/restamp.ts`:** a stamp_patterns match on a branch-added line that names the fork point's version is rewritten to the new version, like a mention of a newer tentative version. Lines the branch did not add, and versions below the fork point, are left alone. +- **`lib/ship-bin-common.ts`:** finish routes such a line as metadata (a stamp file), not behavior. +- **One stamp bound for the writer and the stamp checks** (`lib/stamp-only.ts` `stampOnlyLine` with the branch's side): the tree receipt behind `gstack-ship-queue stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (read against the slot's predecessor), and finish's route now accept a moved version on a line the branch added at or above the fork-point version. Before, the tree receipt and the carry still floored every line at the gated head's VERSION, so the stamp commit the writer now makes would have read `TREE: changed` and stopped the train with `QUEUE_STAMP_NOT_STAMP_ONLY`. The same move on a base line is still refused, unless that base line's text duplicates a line the branch added (GST-979). The tree receipt and the train checks also accept the move when the branch's own VERSION already equals the new version, as it does in a train slot that kept its claim. Two gaps remain, tracked in GST-980: the /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior. + +### Tests +- `test/restamp-mentions.test.ts`: a branch-added row at the fork version after the base moved is restamped and its file is routed as metadata. On main, the stage reports `skipped` and ROUTE counts the file as behavior. +- `test/ship-rereview-triggers.test.ts`: the GST-895 fork-version case now routes as metadata. +- `test/restamp-mentions.test.ts`: `gstack-tree-receipt` reads the GST-963 stamp commit as `same-modulo-stamps` (it read `TREE: changed` before the shared bound), refuses the same move on a base row, and still accepts the GST-895 lost-slot stamp. `test/gstack-ship-receipt-docs.test.ts`: the carry takes the fork-version bound only on the branch's own line. +- `test/ship-queue-train-stamp.test.ts`: a pin claiming 1.2.4.0 with a row left at the base's 1.2.3.0 stamps, and the row moves to 1.2.4.0. Without the train checks' bound, `stampChain` refused it with `QUEUE_TRAIN_STAMP_MISMATCH`. On main it stamped, but the row stayed at 1.2.3.0. + +## [1.93.6.0] - 2026-11-02 + +**/plan-tune now learns from answers to questions that aren't in its registry, when the skill says which preference the question is about (#3442).** Before, an answer to an ad-hoc question id such as `plan-ceo-review-add-caching` moved no dimension, because only registered ids carry a signal. Most logged answers are on ad-hoc ids, so inferred values stayed near 0.5. + +### Added +- **Signal hints at ask time:** a skill can write `` in the question and `"signal_key":""` in the `gstack-question-log` row, choosing the key from the fixed list that `gstack-question-preference --signal-keys` prints. `--derive` uses the hint only when the id has no registry entry, the key is a known signal, and the answer matches one of that signal's options, by the exact key or the key's own words. Hinted answers fall under the same per-day cap as registered ones. +- **`hinted_events`** in the profile, and `(hinted)` on `--trace` rows, so you can see which answers came from hints. + +### Changed +- One shared marker parser (`scripts/qid-marker.ts`) now serves the log hook, the preference hook and the Codex session import. A hint never changes auto-decide or one-way-door handling. An unknown hint, or a hint on a registered id, is dropped with `QLOG_SIGNAL_KEY_DROPPED`, and the row is still logged. + +### Tests +- New tests that fail on main check that derive moves a dimension for a hinted ad-hoc answer and not for an unhinted one. Unknown keys, choices outside the signal, and registered ids ignore the hint. Hinted answers share the per-day cap. The hooks and the Codex import carry the hint into the log. + +## [1.93.5.0] - 2026-11-02 + +**The weekly OSV scan is clean again: qs and @hono/node-server are pinned past two Medium advisories (#3843).** The 2026-11-02 scan (run 41406416003) flagged qs 6.16.0 (GHSA-6pq8-jhm2-2vcw, CVSS 6.3, a `qs.stringify` comma-format crash) and @hono/node-server 2.1.1 (GHSA-rmxm-3fg6-px4f, CVSS 5.3, a `serveStatic` double decode that skips middleware). Both reach gstack only through the dev and test toolchain (`@anthropic-ai/claude-agent-sdk` pulls in `@modelcontextprotocol/sdk`), and nothing setup, the build or an installed skill runs loads either one. Installs set up since v1.91.231.0 don't contain them at all. Older installs and contributor checkouts that still carry the dev toolchain get the patched versions on their next `./setup`. + +### Fixed +- **`package.json` overrides**: `qs` is pinned to 6.16.2 and `@hono/node-server` to 2.1.6, the newest patch releases inside the ranges express, body-parser and the MCP SDK ask for. `bun.lock` now holds one copy of each, and the separate `body-parser/qs` entry is gone. + +### Tests +- `test/dependency-security.test.ts`: the lockfile floor check now covers `qs >= 6.16.1` and `@hono/node-server >= 2.1.3`. Both cases failed on main. + +## [1.93.4.0] - 2026-11-02 + +**/ship's finding collector reads severity from a finding's own Severity field, so a severity word in the finding's text no longer raises it.** + +### Fixed + +- When a reader's finding carries its own `Severity:` field, the collector uses that field. A word like "P0" in the finding's text (a plan phase name, for example) no longer makes it a blocker the reader marked `Blocks: no`. A label leading the line (`CRITICAL:`, `[P0]`, `- High:`, a label heading) still sets the floor, `Blocks: yes` still makes a finding CRITICAL, and the native voice's FIXABLE still collects as CRITICAL. A field that names more than one label ("High/Critical") counts as no field, and a lowercase `severity:` keeps its label. Lines with no Severity field collect as before. (GST-976) + +## [1.93.3.0] - 2026-11-02 + +**The queue now refuses a head whose required targeted review is missing or failed (GST-966).** On gstack-evals#140, the receipt showed `REVIEW_TARGETED: required=auth receipt=legacy verdict=missing` and `gstack-ship-queue ready` still exited 0, so a skipped required reader looked the same as a clean one. + +### Fixed +- **`lib/ship-receipt-depth.ts`:** `ready`, `train build` and `land` refuse `QUEUE_RECEIPT_TARGETED_MISSING` when the head needs a targeted class (money, gate, auth or secret) that no done targeted pass in the receipt covers: no pass recorded, a failed or unrun pass, or a pass on other classes. The line says `verdict=failed` when the recorded pass failed. A head that needs no targeted class passes as before. `train build` and `land` print the `REVIEW_TARGETED:` line with the refusal. + +### Tests +- `test/gstack-ship-queue.test.ts`: `ready` on an auth head with #140's receipt shape (missing) and with a failed pass refuses with the new code; a done pass and a head that needs no class pass. On main the missing case exits 0. + ## [1.93.2.0] - 2026-11-02 **Native cookie import now waits for its browser to actually exit before it reports cleanup, and three more flaky tests from the 2026-11-01 flaky pass are repaired.** Windows drops a Job's process count to zero while terminated Edge processes are still shutting down and still hold the profile's files, so cleanup that trusted the count returned early and the next profile reset failed with a sharing violation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18845d8bf..f394d2e3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -432,7 +432,7 @@ eval files, and misses the strict classifier. No API keys needed. - **Catalog budget** (`test/catalog-budget.test.ts`) — Caps the aggregate discovery surface: the sum of every skill's frontmatter `name` + `description` (what every host loads at discovery, every session) must stay under 1,423 token-equivalents, with a 260-byte per-skill cap. Counting goes through the shared census in `test/helpers/skill-census.ts` (physical files vs authored skills vs registry entries — three deliberately different counts). Adding a skill? The failure message carries the re-measure + ratchet protocol. - **Context-budget ratchet** (`test/context-budget-ratchet.test.ts`) — CI ceilings on the two token ledgers the catalog budget doesn't cover: the always-on full-frontmatter aggregate and each skill's per-invocation eager tokens (SKILL.md + forced-read references), graded against `test/fixtures/context-budget.json` via `lib/context-bill.ts`. New skills fail until they have a ceiling; ceilings for removed skills must be pruned. Legitimate growth or a landed reduction: re-run `bun test/helpers/capture-context-budget.ts` and commit the refreshed fixture in the same commit, so the change is a visible decision in the diff. - **Skill-to-bin drift ratchet** (`test/skill-bin-drift.test.ts`) — `scripts/skill-bin-drift.ts` statically reads every gstack bin call in skill templates, carved sections, references and the Claude-host renders (it never executes a bin) and fails with `DRIFT_UNKNOWN_BIN` or `DRIFT_UNKNOWN_FLAG` at file:line when a skill names a bin missing from `bin/` or a flag that bin's parser does not accept. Known misses live in `test/fixtures/skill-bin-drift-allowlist.json`, which only shrinks: a new finding fails, and so does an entry that no longer reproduces. Check one skill with `bun run scripts/skill-bin-drift.ts --skill `; bins with permissive parsers or no usage text are reported, never failed. -- **Dependency security regressions** (`test/dependency-security.test.ts`) — Run `bun test test/dependency-security.test.ts` to check the resolved `sharp` and `adm-zip` version floors, load Sharp, verify ordinary ZIP extraction, and reject extraction through destination-file and destination-directory symlinks. The symlink cases skip Windows. These checks complement the OSV scan; they do not change its existing exceptions. +- **Dependency security regressions** (`test/dependency-security.test.ts`) — Run `bun test test/dependency-security.test.ts` to check the resolved `sharp`, `adm-zip`, `qs` and `@hono/node-server` version floors, load Sharp, verify ordinary ZIP extraction, and reject extraction through destination-file and destination-directory symlinks. The symlink cases skip Windows. These checks complement the OSV scan; they do not change its existing exceptions. ### Tier 2: E2E via `claude -p` diff --git a/TODOS.md b/TODOS.md index 83709f2a4..9cb7eb0e1 100644 --- a/TODOS.md +++ b/TODOS.md @@ -85,7 +85,7 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up. - **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2. -- **Plan-tune `{skill}-{slug}` ids never reach a signal (#3442, split from #2623)** — the label-to-key capture (#3394), `contributing_events` and the per-day bound (#3397) closed the rest of #2623. Remaining: `scripts/resolvers/question-tuning.ts` lets skills use `{skill}-{slug}` ids, which have no registry entry and so no `signal_key`; those answers never move a dimension. Acceptance: `{skill}-{slug}` ids get a hinted fallback to a registered signal key. Needs a design call on the key source. Credit @v639dragoon. **Effort:** M. **Priority:** P2. +- **Plan-tune qid marker follow-ups (#3442 review notes, filed 2026-11-02)**: `QID_MARKER_RE` in `scripts/qid-marker.ts` drops the whole marker when the `;signal=` tail is malformed (an underscore, a space, an empty key). Match the id first and drop only a bad hint. `question-preference-hook.ts` still looks up the raw marker id, while the log hook lowercases it. Use `parseQidMarker(...).id` in both hooks. Add hinted-marker cases to `test/question-preference-hook.test.ts`. **Effort:** S. **Priority:** P3. - **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3. - **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills//SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3. - **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3. diff --git a/VERSION b/VERSION index 07854d86f..40f3543d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.2.0 +1.93.10.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index fe4bb1956..7a37f602b 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.2.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.10.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/autoplan/SKILL.md b/autoplan/SKILL.md index 9dcdf7751..5413fe13a 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -67,9 +67,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/bin/gstack-codex-session-import b/bin/gstack-codex-session-import index 73543e799..9014502db 100755 --- a/bin/gstack-codex-session-import +++ b/bin/gstack-codex-session-import @@ -138,7 +138,7 @@ for SESSION_FILE in "${SESSION_FILES[@]}"; do const text = String(e.payload?.message || ""); if (!text) continue; // Detect D-numbered brief or marker. Markers are sufficient on their own. - const markerMatch = text.match(//i); + const markerMatch = text.match(//i); const dMatch = text.match(/^D\d+[\.\d]*\s*[—\-]\s*(.+?)$/m); if (!markerMatch && !dMatch) continue; @@ -199,6 +199,7 @@ for SESSION_FILE in "${SESSION_FILES[@]}"; do options_count: options.length || 1, user_choice: userChoice.slice(0, 64), ...(recommended ? { recommended: recommended.slice(0, 64) } : {}), + ...(markerMatch?.[2] ? { signal_key: markerMatch[2].toLowerCase() } : {}), source, session_id: sessionId, // Use ts_nanos+ts shape from the event itself if available; else null. diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index 409c4f56e..624dc723c 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -564,6 +564,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) { const days = new Set(); let count = 0; let signalEvents = 0; + let hintedEvents = 0; for (const line of lines) { let e; try { e = JSON.parse(line); } catch { continue; } @@ -576,16 +577,18 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) { const day = when && !Number.isNaN(when.getTime()) ? when.toISOString().slice(0, 10) : null; if (day) days.add(day); const def = Object.hasOwn(QUESTIONS, e.question_id) ? QUESTIONS[e.question_id] : undefined; - const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : {}; + const hinted = !def && typeof e.signal_key === "string" && Object.hasOwn(SIGNAL_MAP, e.signal_key); + const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : hinted ? SIGNAL_MAP[e.signal_key] : {}; const deltas = Object.hasOwn(byChoice, e.user_choice) ? byChoice[e.user_choice] : []; if (!Array.isArray(deltas) || deltas.length === 0) continue; signalEvents++; + if (hinted) hintedEvents++; const key = day || "undated"; const sums = buckets.get(key) || {}; buckets.set(key, sums); for (const d of deltas) { sums[d.dim] = (sums[d.dim] || 0) + d.delta; - rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta }); + rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta, hinted }); } for (const dim of new Set(deltas.map((d) => d.dim))) contributing[dim] = (contributing[dim] ?? 0) + 1; } @@ -598,7 +601,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) { totals[dim] = (totals[dim] ?? 0) + bounded; } } - return { cap, totals, contributing, rows, capped, count, signalEvents, skills, qids, days }; + return { cap, totals, contributing, rows, capped, count, signalEvents, hintedEvents, skills, qids, days }; } ' @@ -635,7 +638,7 @@ do_derive() { lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean); } - const { totals, contributing, count, signalEvents, skills, qids, days } = + const { totals, contributing, count, signalEvents, hintedEvents, skills, qids, days } = accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS); const values = {}; @@ -653,6 +656,8 @@ do_derive() { // evidence' from 'balanced evidence': both print 0.5, which is how // five 0.5s sat beside sample_size 1286 (#2623). contributing_events: contributing, + // Signal events that came from an unregistered id's signal hint (#3442). + hinted_events: hintedEvents, diversity: { skills_covered: skills.size, question_ids_covered: qids.size, @@ -712,7 +717,7 @@ do_trace() { } else { console.log('TRACE: ' + rows.length + ' events for ' + target); for (const r of rows) { - console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')'); + console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')' + (r.hinted ? ' (hinted)' : '')); } for (const c of acc.capped.filter((c) => c.dim === target)) { console.log(' day ' + clean(c.day) + ': ' + signed(c.sum, 3) + ' capped to ' + signed(c.bounded, 3)); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index c5f6396db..6fe734001 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -307,6 +307,7 @@ gstack_result_anchor() { BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;; QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;; QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;; + QUEUE_RECEIPT_TARGETED_MISSING) printf '%s' 'queue-receipt-targeted-missing' ;; QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;; QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;; QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;; diff --git a/bin/gstack-question-log b/bin/gstack-question-log index 6661ba8a8..f935a0266 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -23,6 +23,12 @@ # question_id, the clicked UI label is mapped to its # registered option key (#2623) and kept in choice_label. # Unmatched labels and other sources stay as given. +# signal_key — optional SIGNAL_MAP key an unregistered question_id's +# answer speaks to (#3442; `gstack-question-preference +# --signal-keys` lists them). Kept only when it is a +# signal key and the id is unregistered; the choice is +# then mapped to that signal's option keys. A dropped +# hint logs the row anyway (QLOG_SIGNAL_KEY_DROPPED). # recommended — option key the agent recommended (optional; mapped # the same way on hook-captured events) # followed_recommendation — bool (optional — computed if both present) @@ -197,6 +203,32 @@ if (j.source === 'hook') { } } +// A signal hint on an unregistered id (#3442) is kept only when it names a +// SIGNAL_MAP key; the choice is then mapped to that signal's option keys +// (exact key or its own words). A dropped hint never fails the log. +if (j.signal_key !== undefined) { + const { SIGNAL_MAP, resolveHintedOptionKey } = await import(process.env.GSTACK_SCRIPTS_DIR + '/psychographic-signals.ts'); + const { getQuestion } = await import(process.env.GSTACK_SCRIPTS_DIR + '/question-registry.ts'); + const hint = typeof j.signal_key === 'string' ? j.signal_key.toLowerCase() : ''; + let reason = ''; + if (!Object.hasOwn(SIGNAL_MAP, hint)) reason = 'it is not a signal key; list them with gstack-question-preference --signal-keys'; + else if (getQuestion(j.question_id)) reason = 'a registered id keeps its own signal'; + if (reason) { + process.stderr.write('gstack-question-log: logged the answer without its signal_key: ' + reason + ' (QLOG_SIGNAL_KEY_DROPPED)\n'); + delete j.signal_key; + } else { + j.signal_key = hint; + const key = resolveHintedOptionKey(hint, j.user_choice); + if (key !== undefined && key !== j.user_choice) { + j.choice_label = j.user_choice; + j.user_choice = key; + } + if (j.recommended !== undefined) { + j.recommended = resolveHintedOptionKey(hint, j.recommended) ?? j.recommended; + } + } +} + // followed_recommendation — compute if both sides present. An __unknown__ // choice means extraction failed, not that the user rejected the // recommendation — leave the field absent so metrics can't be poisoned. @@ -233,6 +265,7 @@ if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then fi exit 1 fi +grep -h 'QLOG_SIGNAL_KEY_DROPPED' "$TMPERR" >&2 || true LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl" diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index 5d0d1af40..a4e55fa95 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -19,6 +19,8 @@ # --read → dump preferences JSON # --clear [] → clear one or all preferences # --stats → short summary (inert one-way prefs counted separately) +# --signal-keys → one line per signal key with its option keys, the +# menu for an unregistered id's ;signal= hint (#3442) # # User-origin gate # ---------------- @@ -365,8 +367,22 @@ do_stats() { ") } +do_signal_keys() { + cd "$ROOT_DIR" + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + import('./scripts/psychographic-signals.ts').then((m) => { + console.log('Optional signal hint for an unregistered question_id whose answer shows one of these preferences:'); + console.log('write , start each option label with one of the'); + console.log('option keys listed after that signal key, and add a signal_key field with that key to the gstack-question-log call.'); + console.log('Omit the hint when no line fits.'); + for (const line of m.signalKeyLines()) console.log(line); + }).catch(err => { console.error('signal-keys:', err.message); process.exit(1); }); + " +} + case "$CMD" in --check) do_check "$@" ;; + --signal-keys) do_signal_keys ;; --write) do_write "$@" ;; --read|"") do_read ;; --clear) do_clear "$@" ;; diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index 40610335b..793a4d1f3 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -5,7 +5,7 @@ * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] * [--reviewed-numbers ] [--depth-floor standard|full] [--range-base ] [--pass1-depth light|standard|full] [--no-tiers] - * [--targeted-ran ] [--json] + * [--targeted-ran ] [--grant ] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * * Reads the diff against the merge base with (committed, tracked uncommitted @@ -21,6 +21,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { locate, readRecord } from '../lib/approval-record'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; import { collectCommit, collectLive } from '../lib/review-collect'; @@ -54,12 +55,17 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --pass1-depth pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full --targeted-ran the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again + --grant the owner's grant of one scoped fix-check after round 2, recorded with gstack-approvals add (approved, + its ask or scope naming review_loop_grant and the stop head's sha, at or after --range-base and at or + before HEAD; needs --range-base): the stop after a round-2 full pass with a fixed blocking finding or a + gate-class edit plans that fix-check instead; a fix-check never earns another; any other row is refused (exit 3) --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR; standard only by --depth-floor or a raise; otherwise light. TARGETED: the money, gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped -(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan +(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, voices (gate class: Step 11 before any stop, +then ask again with --adversarial-ran), stop or done. A full plan from an error exits 0; exit 1 means no plan could be built (git could not list the diff). ${exitTableHelp()}`; @@ -92,11 +98,22 @@ function main(): number { if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; } const floor = flag('--depth-floor') as Depth | undefined; if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; } + const grant = flag('--grant'); + if (has('--grant') && (!grant || grant.startsWith('-'))) { process.stderr.write('gstack-review-plan: --grant needs the approval id gstack-approvals add printed\n'); return EXIT.usage; } const rangeBase = flag('--range-base'); if (has('--range-base') && (!rangeBase || rangeBase.startsWith('-'))) { process.stderr.write('gstack-review-plan: --range-base needs the fix range\'s base commit (this pass\'s PASS_HEAD)\n'); return EXIT.usage; } + if (grant && !rangeBase) { process.stderr.write('gstack-review-plan: --grant needs --range-base (round 2\'s PASS_HEAD), which binds the grant to the stop it answers\n'); return EXIT.usage; } const root = git(['rev-parse', '--show-toplevel'])?.trim(); if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; } process.chdir(root); + if (grant) { + const loc = locate({ cwd: root, env: process.env }); + const row = ('file' in loc ? readRecord(loc).rows : []).find(r => r.id === grant && r.outcome === 'approved' && `${r.ask} ${r.scope}`.includes('review_loop_grant')); + const passHead = git(['rev-parse', '--verify', '--quiet', `${rangeBase}^{commit}`])?.trim(); + const ancestor = (a: string, d: string) => git(['merge-base', '--is-ancestor', a, d]) !== null; + const stopHead = row && passHead ? `${row.ask} ${row.scope}`.match(/\b[0-9a-f]{7,40}\b/g)?.map(s => git(['rev-parse', '--verify', '--quiet', `${s}^{commit}`])?.trim()).find(s => s && ancestor(passHead, s) && ancestor(s, 'HEAD')) : undefined; + if (!stopHead) { process.stderr.write(`gstack-review-plan: --grant ${grant}: no approved row ${grant} naming review_loop_grant and this invocation's stop head (a commit at or after --range-base ${rangeBase}, at or before HEAD) in this branch's approval record (${'file' in loc ? 'gstack-approvals list' : loc.message}); record the owner's grant with gstack-approvals add --ask '... (review_loop_grant )' first\n`); return EXIT.refused; } + } const collected = collectLive(root, base); if (collected.errors.length) { for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`); @@ -145,7 +162,7 @@ function main(): number { const plan = planReview({ ...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), - history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines }, + 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, granted: grant }, }); const gateScan = { tagged: gateTagged, head }; if (has('--json')) { diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 3de38d032..773fb19c1 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -37,6 +37,7 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts'; import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; +import { branchSides, type BranchSide } from '../lib/stamp-only'; import { runScriptSync } from '../lib/script-invocation'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; @@ -495,7 +496,7 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[] * change, such as a CHANGELOG fragment next to the base's changelog.d/README.md * (GST-343, #3606), into the product patch-id at one head only. */ -function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet; sides: ReadonlyArray }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet; sides: ReadonlyArray; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { const changedRaw = git(['diff', '--name-only', from, to], repoRoot); if (changedRaw === undefined) return undefined; const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; @@ -507,7 +508,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const versions = new Map(); const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; }; const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p); - const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates } : undefined); + const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined); const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) })); const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) @@ -620,7 +621,7 @@ function cmdCarry(rest: string[], json: boolean): number { const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)]; if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); const touched = new Set([...listed[0], ...listed[1]]); - const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] }); + const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') }); if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); const { changed } = spec; const productFrom = patchId(repoRoot, fromBase, from, spec.product); diff --git a/bin/gstack-tree-receipt b/bin/gstack-tree-receipt index e718d3738..9212a2e2c 100755 --- a/bin/gstack-tree-receipt +++ b/bin/gstack-tree-receipt @@ -64,10 +64,10 @@ function main(argv: string[]): number { if (predSha || predPr || predRef) predecessor = { pr: predPr ?? predecessor?.pr ?? null, recordedHead: predSha ?? predecessor?.recordedHead ?? null, ref: predRef ?? null }; const policyFrom = flag(argv, '--policy-from') ?? 'base'; + const base = resolveBaseBranch(repoRoot, flag(argv, '--base')); let loaded: LoadedPolicy; if (policyFrom === 'head') loaded = loadPolicyFromHead(repoRoot); else { - const base = resolveBaseBranch(repoRoot, flag(argv, '--base')); const l = loadPolicyFromBase(repoRoot, base); if (isUnavailable(l)) { process.stderr.write(policyUnavailableLine('gstack-tree-receipt', base, l.detail) + '\n'); return EXIT.refused; } loaded = l; @@ -76,7 +76,7 @@ function main(argv: string[]): number { const receipt = buildReceipt({ repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label, - predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), + predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), baseRef: `origin/${base}`, }); if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; } process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt)); diff --git a/bun.lock b/bun.lock index 30f33b2fb..a32b356bc 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "playwright-core@1.62.1": "patches/playwright-core@1.62.1.patch", }, "overrides": { + "@hono/node-server": "2.1.6", "@modelcontextprotocol/sdk": "1.32.0", "adm-zip": "^0.6.1", "fast-uri": "3.1.9", @@ -38,6 +39,7 @@ "image-size": "2.0.4", "ip-address": "10.7.4", "proxy-addr": "2.0.8", + "qs": "6.16.2", "sharp": "0.35.5", }, "packages": { @@ -65,7 +67,7 @@ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + "@hono/node-server": ["@hono/node-server@2.1.6", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ=="], "@huggingface/jinja": ["@huggingface/jinja@0.5.10", "", {}, "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw=="], @@ -483,7 +485,7 @@ "punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + "qs": ["qs@6.16.2", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA=="], "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], @@ -597,8 +599,6 @@ "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "body-parser/qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="], - "dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], diff --git a/canary/SKILL.md b/canary/SKILL.md index 42b8aff9f..0ef61ffd7 100644 --- a/canary/SKILL.md +++ b/canary/SKILL.md @@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/captain/SKILL.md b/captain/SKILL.md index 3299ad4d7..776349ddb 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -59,9 +59,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/changelog.d/garrytan-frontier-models-under-test.md b/changelog.d/garrytan-frontier-models-under-test.md deleted file mode 100644 index e631d1ac8..000000000 --- a/changelog.d/garrytan-frontier-models-under-test.md +++ /dev/null @@ -1,3 +0,0 @@ -### Changed - -- Evals test frontier models (R54). /eval-plan and docs/TESTING_INTERNALS.md#frontier-models-under-test now say that models under test are Opus 5.5 and GPT-6.1 Sol, plus at most one named low-cost primary. Haiku and Sonnet aren't stand-ins for a frontier model, and a Haiku- or Sonnet-only result can't carry a KEEP, FIX-METHOD or CUT-CANDIDATE verdict. Judges, review voices and housekeeping models keep their models. `gstack-eval-plan dry-run` refuses a plan whose every arm is Haiku or Sonnet unless `--tier-question` names what it asks about that tier, warns on a mixed plan, and prices the whole plan on frontier models. `pilot start` refuses a Haiku- or Sonnet-only pilot unless its dry run recorded that tier question. diff --git a/changelog.d/garrytan-sdk-preset-tripwire.md b/changelog.d/garrytan-sdk-preset-tripwire.md deleted file mode 100644 index 9daf5e5fb..000000000 --- a/changelog.d/garrytan-sdk-preset-tripwire.md +++ /dev/null @@ -1,3 +0,0 @@ -### Fixed - -- Agent SDK evals can no longer run on the SDK's bare stub prompt by accident. Every `runAgentSdkTest` and direct SDK `query()` caller now passes Claude Code's preset or names why it doesn't. The runner refuses a missing, empty or bare-string prompt, and a free tripwire test scans test/ and scripts/ for the same mistake. docs/TESTING_INTERNALS.md#agent-sdk-system-prompt says to record which prompt each arm used, and to treat a null result from an arm on the stub as FIX-METHOD. diff --git a/changelog.d/gsta158-quarantine.md b/changelog.d/gsta158-quarantine.md deleted file mode 100644 index 844dfea3a..000000000 --- a/changelog.d/gsta158-quarantine.md +++ /dev/null @@ -1,15 +0,0 @@ -**A test that keeps flaking while someone fixes it can be quarantined instead of skipped, and it still can't turn a red run green.** `gstack-flaky quarantine add` puts the test in `.gstack/quarantine.json` with an owner, a ledger link and an expiry. The test keeps running on every run and is never counted as a pass. When every test an attempt failed is quarantined, the whole command may run again, up to 3 attempts, and the run is green only when an attempt gets the runner's own full green verdict, so a coverage threshold or another failure outside the report stays red. Before this, a test that flaked every few runs either went red on every PR or got skipped and forgotten. - -### Added -- **Quarantine registry, `gstack-flaky quarantine add|renew|release|check|report`** (`.gstack/quarantine.json`, GSTA-158 P2): a quarantined test that fails all 3 attempts is a `hard-break` (red), a quarantined id missing from a run that included its file is red, and quarantined tests are listed (`FLAKY_RESULT … quarantined=`) and ledgered (`quarantined-pass`, `quarantined-fail`, `hard-break`) on every run. Tests that are not quarantined keep their one rerun. -- **Entries are owned and expire**: owner, ledger link, class (`detector`, `harness`, `timing`, `model-latency`; `product` and `infra` refused), evidence, 7 days by default and 14 at most, one renewal of up to 7 days with new evidence and the owner named again. An expired entry stops applying with no edit and prints the renew and release commands. Live entries are capped at 10% of the suite (at least 1, and 5 under 50 tests). -- **`run --base `** reads the registry from the base branch, so a change cannot quarantine the test it breaks. **`check --base`** refuses an entry added in the change that edits its test's file, an entry removed without a release record, a removed release record and rewritten history. **`report --ledger `** lists entries soonest-expiring first and marks those with 10 consecutive clean passes ready to release. gstack's own `CASE_QUARANTINE` / `EVAL_POLICY` is unchanged and shares no schema with it. - -### Docs -- `docs/flaky.md#quarantine`: commands, rules, expiry, cap, classes, the `CASE_QUARANTINE` separation and the daily-pass `gh run download` + `report` loop. `captain/templates/automation-flaky-pass.md` and `docs/captain.md` run `report` and fix expiring entries first. - -### Tests -- `test/quarantine.test.ts`: add refusals (cap, more than 14 days, no evidence, product or infra class, unknown id), one renewal and future-dated renewals, release, `check --base` against git fixtures (non-ASCII paths included), expiry, a hard break across 3 attempts, the false-green regression (exit 1 from outside the report while only a quarantined test fails stays red), missing ids with their file present or absent, and the registry read from the base branch. - -### Gate integrity -- `test/flaky-run.test.ts`: the exact `FLAKY_RESULT` line it expects gains the new `quarantined=0` field. No other assertion, threshold or skip changed. diff --git a/changelog.d/gsta203-support-tiers-present.md b/changelog.d/gsta203-support-tiers-present.md deleted file mode 100644 index 4b82c7ca0..000000000 --- a/changelog.d/gsta203-support-tiers-present.md +++ /dev/null @@ -1 +0,0 @@ -- `docs/support-tiers.md` describes Windows CI as it works now. `windows-smoke` is the required Windows check on pull requests, and the six-shard `windows-free-tests` suite runs nightly on main or by hand and never blocks a pull request. The page was written before #3806 landed, so it still said these changes were coming. diff --git a/codex/SKILL.md b/codex/SKILL.md index ab76ed9e4..e6130253f 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -53,9 +53,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 40d14265c..ee68c8752 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/context-save/SKILL.md b/context-save/SKILL.md index 0c5acc09f..05c13659a 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 6e0bbfd2e..71ecbe7df 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -78,9 +78,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 25c8785be..0eaaa3e97 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/design-motion/SKILL.md b/design-motion/SKILL.md index f40d78c4d..01d2938cd 100644 --- a/design-motion/SKILL.md +++ b/design-motion/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 47100ca31..42e47d251 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index 59f595100..e086e9bfa 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -72,9 +72,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/devex-review/SKILL.md b/devex-review/SKILL.md index f105da25a..1dc425e3c 100644 --- a/devex-review/SKILL.md +++ b/devex-review/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/docs/designs/PLAN_TUNING_V0.md b/docs/designs/PLAN_TUNING_V0.md index 77bf95e8d..523c31044 100644 --- a/docs/designs/PLAN_TUNING_V0.md +++ b/docs/designs/PLAN_TUNING_V0.md @@ -89,6 +89,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an "sample_size": 47, "signal_events": 19, "contributing_events": {"scope_appetite": 9, "risk_tolerance": 6, "...": "..."}, + "hinted_events": 3, "diversity": { "skills_covered": 5, "question_ids_covered": 14, @@ -105,7 +106,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an } ``` -`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace ` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436). +`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace ` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436). An unregistered `{skill}-{slug}` id moves a dimension only through a signal hint (#3442): the asked question carries `` and the log row a matching `signal_key`, chosen from `gstack-question-preference --signal-keys`. Derive uses the hint only when the id has no registry entry, the key is in the signal map and the answer matches one of that signal's option keys; `hinted_events` counts those signal events, and `--trace` marks their rows `(hinted)`. **Diversity check** (Codex #13): `inferred` is considered "enough data" only when `sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`. Below this, `/plan-tune profile` shows "not enough observed data yet" instead of a potentially-misleading inferred value. diff --git a/docs/review-policy.md b/docs/review-policy.md index f7b872281..1a8aa6fc3 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -98,9 +98,19 @@ incomplete coverage, which blocks until the reviewer is rerun. finishes with its open notes listed in the PR body and receipt, or stops for the owner when a blocking finding is open, or was fixed in round 2 and is unverified, or a round-2 fix edited a gate-class path, naming it (path, - severity, problem). A third round is the owner's + severity, problem). The cap limits fix rounds, not Step 11's voices: on gate + class a stop waits for Step 11's adversarial, outside and targeted voices + (`NEXT: voices`), so gate code never reaches the owner's "ship with open + notes" without them (GST-967). A third round is the owner's grant of one scoped fix-check (recorded in `review_loop_exception`), never the - loop's. The cap approves nothing. + loop's: once `gstack-approvals add` records the grant (its ask or scope + naming `review_loop_grant` and the stop head's sha), + `gstack-review-plan --grant --range-base ` + plans that fix-check in place of the stop after a round-2 full pass with a + fixed blocking finding or a gate-class edit, and refuses any other row, + including a grant for a stop head before `--range-base` (an earlier /ship). + A fix-check never earns another, so the grant cannot repeat, and a stop after + a round-2 fix-check still goes to the owner. The cap approves nothing. ### Transitions @@ -122,12 +132,19 @@ incomplete coverage, which blocks until the reviewer is rerun. | a full pass that leaves a blocking finding open (fixed or not) | stop: name it for the owner | | a fix-check that found nothing blocking | done | | a fix-check that found a blocking finding | stop: name it for the owner | +| a stop (any row above) on gate class before Step 11's voices ran | Step 11's voices (adversarial, outside, targeted), then ask again with `--adversarial-ran` | +| a round-2 full pass that stops for a fixed blocking finding or a gate-class edit, with the owner's recorded grant (`--grant `) | one scoped fix-check (round 3), after Step 11 on gate class | A **fix-check** is one reviewer reading the touched hunks, the fix list, the test results and the callers of every changed symbol, at full effort. It is logged as a `review-fix-check` row, so it never counts as a pass. No army, no Red Team and no outside voice run on it. +`NEXT: voices` runs Step 11 as `done` does, from the last full pass's +manifest; /ship then asks the plan again with `--adversarial-ran` (a blocking +finding the voices queued counts as `--blocking-open`, never a new Step 9), and +the stop or the granted fix-check it names stands. + A **scoped pass** is pass 2 after a blocking fix when pass 1 and the diff both ran below full depth (no gate class, no depth error, no `--depth-floor full`). @@ -384,10 +401,11 @@ block without new floor words. The reader is read-only, and `NO FINDINGS` is a result. `gstack-review-dispatch render --role targeted --classes ` renders its brief; `verify --targeted-out ` writes the record that `gstack-ship-receipt write --review-targeted @` puts in the receipt. -`gstack-ship-queue ready` and `train build` print +`gstack-ship-queue ready`, `train build` and `land` print `REVIEW_TARGETED: required= receipt= -verdict=ok|missing` and refuse nothing yet; enforcement comes in a later -release. +verdict=ok|missing|failed` and refuse +[`QUEUE_RECEIPT_TARGETED_MISSING`](troubleshooting.md#queue-receipt-targeted-missing) +when a required class has no done pass covering it. Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit 21, gate 18, auth 3 and secret 8. The gate class first used the gate paths, @@ -539,10 +557,11 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: 3. `collect` hashes each reader's raw output and appends its findings to `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 + wherever they stand, headings and table rows included, except that a line with its own `Severity` field takes that + field's label (any letter case; a field stating more than one label, "High/Critical", counts as none), so a severity word in the finding's text does not raise it, and a label leading the line stays its floor (GST-976); 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, + second line for the same field starts another: `Severity` sets its label (a label leading the finding stays its floor), `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). An unnumbered, unlabeled bullet with no file:line diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 1e08417be..fb1aefa2f 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -246,8 +246,10 @@ result is not a current pass, and (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that head: /ship runs before every READY ([details](#ship-before-ready)). It also refuses `QUEUE_RECEIPT_DEPTH_RAISED` when the receipt's review depth is below -what the head needs and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner -exception the receipt records does not stand ([the ship receipt](#ship-receipt)). +what the head needs, `QUEUE_RECEIPT_TARGETED_MISSING` when a targeted class +the head needs has no done targeted pass in the receipt, and +`QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner exception the receipt records +does not stand ([the ship receipt](#ship-receipt)). Send the block as printed. **2. Lane behind another PR: gate ahead.** Gate the tree the queue will have @@ -683,8 +685,10 @@ slot. The queue enforces this at every step from the PR body alone: head being READY'd (or its `pr` is another PR); the informational `SHIP_RECEIPT:` line still prints. It refuses `QUEUE_RECEIPT_DEPTH_RAISED` (with a `REVIEW_DEPTH:` line) when the receipt records a review depth below - what the head needs, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner - exception it records does not stand ([the ship receipt](#ship-receipt)). + what the head needs, `QUEUE_RECEIPT_TARGETED_MISSING` (with a + `REVIEW_TARGETED:` line) when a targeted class the head needs has no done + targeted pass in the receipt, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an + owner exception it records does not stand ([the ship receipt](#ship-receipt)). There is no flag around it. - `train build` refuses a pin whose PR has no current receipt with the same codes, naming the PR, before anything is written, pushed or dispatched, @@ -803,9 +807,10 @@ its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship receipt for the pin; see [/ship before READY](#ship-before-ready)), -`QUEUE_RECEIPT_DEPTH_RAISED` and `QUEUE_RECEIPT_EXCEPTION_INVALID` (the -receipt's review depth is below what the pin needs, or an owner exception it -records does not stand; see [the ship receipt](#ship-receipt)), +`QUEUE_RECEIPT_DEPTH_RAISED`, `QUEUE_RECEIPT_TARGETED_MISSING` and +`QUEUE_RECEIPT_EXCEPTION_INVALID` (the receipt's review depth is below what +the pin needs, a targeted class the pin needs has no done targeted pass, or an +owner exception it records does not stand; see [the ship receipt](#ship-receipt)), `QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed `QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after merging #,#; nothing dispatched, no branch pushed; fix: …` line names @@ -1222,7 +1227,12 @@ block `gstack-approvals` renders (its heading, the Date | Asked | Exact words table and the `Source: gstack-approvals record` line) quotes the owner's words, not this PR's numbers, so it is skipped: "one Step 11 pass" in that table is not a `pass` claim. Text you write under the heading after that block, and -a `## Approvals` example inside a code fence, are still checked. Data PRs (evals, +a `## Approvals` example inside a code fence, are still checked. The +`Review-loop exception: …` line the receipt's own exception requires is +skipped the same way (GST-977): it quotes the owner's reason, so "outside 1 and +2 skip detector" is not a `2 skip` claim. Only a line that matches the rendered +line exactly is skipped, inside a code fence too, as the receipt check accepts +a fenced copy; the same words inside a longer line are still checked. Data PRs (evals, measurements, sweeps) run it before READY so the body's numbers are the run's. @@ -1524,7 +1534,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so. 2. **release_tool**: the policy command, under the contract below. 3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under ``, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once. -4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. +4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A base line whose text duplicates a line the branch added gets the branch's bound too, because the checks match branch lines by text, not position (GST-979). The /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior (GST-980). 5. **mirror**: tier 2; declared and validated, reported as not run. 6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8739ca306..e7db68e52 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5501,6 +5501,23 @@ body no longer carries the exception's line word for word /ship again and record the owner's acceptance with `--docs` or `--review-loop-exception` ([ship-policy.md](ship-policy.md#ship-receipt)). + +### `QUEUE_RECEIPT_TARGETED_MISSING` + +**Meaning.** `ready`, `train build` or `land` recomputed the targeted +classes the head needs (money, gate, auth, secret) from git, and the receipt's +`review_targeted` does not cover every one of them with a `done` pass. The +`REVIEW_TARGETED:` line prints the required classes, the classes a done pass +covered (`legacy` when the receipt records no targeted pass) and +`verdict=missing`, or `verdict=failed` when the recorded pass failed. A +head that needs no class passes whatever the receipt records. The receipt is +unchanged. + +**Fix.** Run /ship on the head the READY names so the targeted reader runs on +every required class and `gstack-ship-receipt write --review-targeted` records +a done pass ([review-policy.md](review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code)). +Never edit `review_targeted` by hand. + ### `QUEUE_SCREEN_STALE_REF` @@ -5898,7 +5915,8 @@ body that names a counter the receipt records (`passes=3` against against `gate.passed: 41`) with a different value. The receipt is written from the run; the prose was typed, so a reader of the body would learn a number the run did not produce. The `PROSE:` line names each field with both values. The -`## Approvals` table `gstack-approvals` renders is never read as a claim. +`## Approvals` table `gstack-approvals` renders, and the `Review-loop exception: …` +line the receipt's exception requires, are never read as claims. **Fix.** Correct the body to the receipt's number. When the receipt is the stale one (the run was repeated), rerun `/ship` so the receipt is rewritten diff --git a/document-generate/SKILL.md b/document-generate/SKILL.md index 0a4dd5f6b..1c0dba743 100644 --- a/document-generate/SKILL.md +++ b/document-generate/SKILL.md @@ -58,9 +58,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/document-release/SKILL.md b/document-release/SKILL.md index 0cebece51..437e8ffe5 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/eval-plan/SKILL.md b/eval-plan/SKILL.md index 131788eeb..fa6a0f479 100644 --- a/eval-plan/SKILL.md +++ b/eval-plan/SKILL.md @@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/health/SKILL.md b/health/SKILL.md index 3b1ee34ca..ecde9b5f2 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/hosts/claude/hooks/question-log-hook.ts b/hosts/claude/hooks/question-log-hook.ts index c484d9141..3ed739ca7 100644 --- a/hosts/claude/hooks/question-log-hook.ts +++ b/hosts/claude/hooks/question-log-hook.ts @@ -37,6 +37,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { runBin } from './spawn-bin'; import { logHookError as sharedLogHookError } from './hook-log'; +import { parseQidMarker } from '../../../scripts/qid-marker'; interface HookStdin { session_id?: string; @@ -65,7 +66,6 @@ interface ExtractedQuestion { door_type?: string; } -const MARKER_RE = //i; const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i; function logHookError(msg: string): void { @@ -103,13 +103,14 @@ function extractQuestionId( skill: string, questionText: string, options: string[], -): { id: string; marker_present: boolean; stripped_question: string } { - const match = questionText.match(MARKER_RE); - if (match) { +): { id: string; signal_key?: string; marker_present: boolean; stripped_question: string } { + const marker = parseQidMarker(questionText); + if (marker) { return { - id: match[1], + id: marker.id, + ...(marker.signal_key ? { signal_key: marker.signal_key } : {}), marker_present: true, - stripped_question: questionText.replace(MARKER_RE, '').trim(), + stripped_question: marker.stripped, }; } return { @@ -289,7 +290,7 @@ async function main(): Promise { if (!qText) continue; const opts = optionLabels(q.options || []); - const { id, stripped_question } = extractQuestionId(skill, qText, opts); + const { id, signal_key, stripped_question } = extractQuestionId(skill, qText, opts); const recommended = extractRecommended(stripped_question, opts); const summary = stripped_question.slice(0, 200); const choice = choices[i] || { choice: '__unknown__' }; @@ -305,6 +306,7 @@ async function main(): Promise { tool_use_id: stdin.tool_use_id?.slice(0, 128), }; if (recommended) payload.recommended = recommended.slice(0, 64); + if (signal_key) payload.signal_key = signal_key; if (choice.free_text) payload.free_text = String(choice.free_text); spawnLog(payload, stdin.cwd); diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts index b88f4830e..0629e0e6d 100644 --- a/hosts/claude/hooks/question-preference-hook.ts +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -51,6 +51,7 @@ import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv } import { resolveStateRoot } from '../../../lib/state-root'; import { readVersionedSlugCache } from '../../../lib/bin-context'; import { logHookError as sharedLogHookError } from './hook-log'; +import { QID_MARKER_RE as MARKER_RE } from '../../../scripts/qid-marker'; interface HookStdin { session_id?: string; @@ -67,7 +68,6 @@ interface HookStdin { cwd?: string; } -const MARKER_RE = //i; const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i; const stateRoot = (): string => resolveStateRoot(); diff --git a/investigate/SKILL.md b/investigate/SKILL.md index fbb91557d..cb6a50df2 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -96,9 +96,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 2989e0c0a..099596af3 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -53,9 +53,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ios-design-review/SKILL.md b/ios-design-review/SKILL.md index cd0faf7a5..6000c79a5 100644 --- a/ios-design-review/SKILL.md +++ b/ios-design-review/SKILL.md @@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ios-fix/SKILL.md b/ios-fix/SKILL.md index 7ebda8e0f..12c3e4c43 100644 --- a/ios-fix/SKILL.md +++ b/ios-fix/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 937db4a30..2fb38d735 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md index 7b7dd647e..0597d91cb 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 491ba0521..57e64a21a 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/landing-report/SKILL.md b/landing-report/SKILL.md index bc7362d60..449d24b80 100644 --- a/landing-report/SKILL.md +++ b/landing-report/SKILL.md @@ -53,9 +53,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/learn/SKILL.md b/learn/SKILL.md index bd368ee9d..0d9f456c0 100644 --- a/learn/SKILL.md +++ b/learn/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/lib/restamp.ts b/lib/restamp.ts index 06b4a8828..45dd82ff7 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -362,36 +362,9 @@ export function stampMentions(text: string, templates: readonly string[] = []): return found; } -/** - * A removed/added line pair that a restamp alone explains: every version the - * removed line mentions in a stamp form and the added line no longer does - * moves to `newV`, and the rewritten line is the added line byte for byte. - * The moved versions are the ones the stamp_paths stage moves, not only the - * old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = ''` never equals the base version at the slot and is a stamp all the - * same (#3512). The stage's own bound applies: a tentative mention is newer - * than the version the branch was written against, so a moved version older - * than `oldV` (a released key moved by hand) is not a stamp, and neither is - * one of another width (`tentativeMentions`: a 3-digit dependency pin in a - * 4-digit repo is never a candidate, so moving it is a hand edit). A line that - * moves nothing, or changes anything beyond those mentions, is not one either. - */ -export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean { - if (removed === added) return false; - const floor = parseVersion(oldV); - const width = versionWidth(oldV); - const after = stampMentions(added, templates); - const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v)); - if (!moved.length || !floor) return false; - if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false; - let text = removed; - for (const v of moved) text = rewriteStamps(text, v, newV, templates).text; - return text === added; -} - /** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */ -export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string { - const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim(); +export function forkVersion(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): string { + const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..${head}`]).out.split('\n')[0]?.trim(); if (!first) return fallback; const v = versionAtRef(repoRoot, `${first}^`); return 'version' in v ? v.version : fallback; @@ -525,7 +498,9 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string /** * The stamp_paths stage. An explicit old version (the branch's own stamp, or * `--was`) is rewritten on every line of every stamp file; a tentative one - * (a mention newer than the fork version) only on lines the branch added. + * (a mention newer than the fork version, or of the fork version itself: a + * row written at the version the branch forked from instead of a next one, + * GST-963) only on lines the branch added. * With no explicit version the branch's VERSION equals the base's: either it * never stamped, or it stamped X and the base reached X on its own (a PR that * lost its slot and merged the base). Then a stamp-form mention of the base @@ -548,6 +523,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v))); const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null; if (equalBase) tentative.add(equalBase); + if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion); const moved = new Map(); const baseSide: string[] = []; let touched = 0; @@ -590,7 +566,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const baseOwnNote = baseOwn.length ? `; ${baseOwn.reduce((n, [, own]) => n + own.notOwn, 0)} line(s) the base added since the fork point are the base's, not this branch's, and stay (${baseOwn.map(([rel, own]) => `${rel}: ${own.notOwn}`).join('; ')})` : ''; sp.detail = (olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}` - : `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; + : `${searched}no tentative mention at or newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; } function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] { diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index f85c80bc0..3d6385ee7 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -264,6 +264,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the PR body’s /ship receipt records an owner exception (docs or review loop) that is malformed, lacks the fix-check it follows, or whose line the body no longer carries verbatim', fix: 'restore the exception line in the PR body word for word, or run /ship again and record the owner’s acceptance with --docs or --review-loop-exception', }, + QUEUE_RECEIPT_TARGETED_MISSING: { + anchor: 'queue-receipt-targeted-missing', + summary: 'the head touches money, gate, auth or secret code that needs the targeted reader, and the PR body’s /ship receipt records no done targeted pass covering every required class (none recorded, failed, not run, or other classes)', + fix: 'run /ship on the head the READY names so the targeted reader covers every class the REVIEW_TARGETED line lists as required; the receipt is left unchanged', + }, QUEUE_SCREEN_STALE_REF: { anchor: 'queue-screen-stale-ref', summary: 'GitHub’s refs/pull//merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success', diff --git a/lib/review-depth.ts b/lib/review-depth.ts index 59340ac8e..37b1febe3 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -20,7 +20,7 @@ export type PathClass = 'docs' | 'tests' | 'code'; export type Depth = 'light' | 'standard' | 'full'; export type Effort = 'light' | 'medium' | 'full' | 'host'; export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted'; -export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop'; +export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'done' | 'stop'; export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const; export type Specialist = (typeof SPECIALISTS)[number]; @@ -338,6 +338,12 @@ export interface History { /** Runtime lines of the fix range (RANGE_BASE..HEAD) as classifyDiff counts them, 0 when it touches none; undefined or null when the range was not read. */ rangeRuntimeLines?: number | null; pass1Depth?: Depth; + /** The diff is gate class (planReview sets it): Step 11's voices run before any stop. */ + gate?: boolean; + /** Step 11's native adversarial pass already ran (planReview sets it from PlanInput.adversarialRan). */ + adversarialRan?: boolean; + /** The approval id of the owner's recorded grant of one scoped fix-check after round 2 (review_loop_exception). */ + granted?: string; } export interface NextStep { step: Step; reason: string } @@ -349,8 +355,19 @@ export interface NextStep { step: Step; reason: string } * changes no runtime lines; two rounds is the cap (R51: a scoped pass or a fix-check is * one): after round 2, below-floor fixes finish with notes, and a blocker open or fixed * there, or a gate-class edit there, stops for the owner, whose grant may add one scoped fix-check. + * The cap limits fix rounds, not Step 11's voices: on gate class any stop waits for them (GST-967), + * and the owner's recorded grant (`granted`) turns the stop after a round-2 full pass with a fixed finding into its + * one fix-check; a fix-check never earns another, so the grant cannot repeat. */ export function nextStep(h: History, depth?: Depth): NextStep { + const n = loopStep(h, depth); + if (n.step !== 'stop') return n; + if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's voices (adversarial, outside, targeted) run before the owner's exit, then ask again with --adversarial-ran (${n.reason})` }; + if (h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` }; + return n; +} + +function loopStep(h: History, depth?: Depth): NextStep { if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' }; const fixed = !!(h.fixedBlocking || h.fixedBelow); if (h.last === 'fix-check') return h.blockingOpen || h.fixedBlocking || h.touchedGate ? { step: 'stop', reason: `the fix-check ${h.touchedGate && !h.blockingOpen && !h.fixedBlocking ? 'edited a gate-class path' : 'found a blocking finding'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` } : { step: 'done', reason: fixed ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' }; @@ -475,7 +492,7 @@ export function planReview(i: PlanInput): ReviewPlan { const c = classifyDiff(i); const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor); const { class: cls, runtimeLines } = depth; - const next = nextStep(i.history, depth.depth); + const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan }, depth.depth); const h = i.history; const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound); const stepDepth: Depth = raise ? 'standard' : depth.depth; @@ -535,8 +552,11 @@ export function planReview(i: PlanInput): ReviewPlan { } else if (next.step === 'fix-check') { add('fix-check', 'fix-check', 'full'); if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); + } else if (next.step === 'voices') { + add('adversarial-native', 'adversarial', 'full'); + add('outside-voice', 'outside-voice', 'host'); } - if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check'].includes(next.step)) { + if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check', 'voices'].includes(next.step)) { if (i.targeted.ran) skipped.push('targeted (already read this product patch)'); else add('targeted', 'targeted', ROLE_EFFORT.targeted); } diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index dd3266dee..4406d59c5 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -8,8 +8,8 @@ * 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 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 + * Findings reach merged.jsonl only through collect, with each voice's label as written (a prose finding's own Severity field when + * it has one, else its line's most severe label), 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. @@ -67,6 +67,20 @@ const META = new RegExp(`^(?:[-*+]\\s+)?\\**\\s*(Severity|Blocks)\\**\\s*(?::|\\ /** The severity and Blocks a prose finding's own fields state (GST-766): recorded beside the collected label, never in its place. */ export interface Stated { severity?: string; blocks?: string } const STATED_SEVERITY = new RegExp(`(?]\\s+|\\d+[.)]\\s+)?\\**\\s*Severity\\**\\s*:?\\s*\\**\\s*((?:${LABELS})(?:\\s*[/→]\\s*\\**\\s*(?:${LABELS}))*)(?![A-Za-z0-9])|(?:[.;,|()]\\s*|\\s[—–-]\\s*)\\**\\s*Severity\\**\\s*:\\s*\\**\\s*((?:${LABELS})(?:\\s*[/→]\\s*\\**\\s*(?:${LABELS}))*)(?![A-Za-z0-9])(?=\\**\\s*(?:$|[.;,|()*—–]|-\\s|Blocks)))`, 'gi'); +/** + * A label leading its line past list, quote, heading, emphasis and emoji marks (one mark per step, so a long divider stays linear) + * and an optional file:line, closed by a mark or a separator, or by a space, comma or parenthesis after an all-caps word ("[P0] …", + * "1. **High**: …", "### CRITICAL: …", "1. api.ts:10 CRITICAL: …", "1. CRITICAL missing auth"): the reader's own label, which a + * Severity field never lowers. "P0 pilot …" is not one. + */ +const FIELD_LABELS = new RegExp(ANY_LABEL.source, 'gi'); +const LEAD_LABEL = new RegExp(`^(?:[^\\p{L}\\p{N}\\s]\\s*|\\d+[.)]\\s*)*(?:\\w[\\w./-]*:\\d+(?:[-–:,]\\s*\\d+)*[^\\p{L}\\p{N}\\s]*\\s+(?:[^\\p{L}\\p{N}\\s]\\s*)*)?(${LABELS})(?=[*_\`\\])]|\\s*[:|—–]|\\s+-\\s|\\s*$|(?<=[A-Z]{3})[\\s,(.])`, 'u'); const STATED_BLOCKS = /(? { - type Finding = { label: string; raw: string; fields: Set; list: boolean }; + type Finding = { label: string; raw: string; fields: Set; list: boolean; lead?: string }; const out: Finding[] = []; let section: string | null = null; let current: Finding | null = null; @@ -97,8 +113,17 @@ export function parseFindings(reader: string, output: string): Array<{ label: st let treeSeen = 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), list: LIST_ITEM.test(raw) }; out.push(f); return f; }; + const strongestOf = (labels: Array) => labels.reduce((a, b) => (a === undefined ? b : b === undefined ? a : stronger(a, b)), undefined); + const strongest = (t: string) => strongestOf([...t.matchAll(ALL_LABELS)].map(m => named(m[1]!))); + const fieldOf = (t: string) => { const ls = [...t.matchAll(SEVERITY_FIELD)].flatMap(m => (m[1] ?? m[2]!).match(FIELD_LABELS)!); return ls.length === 1 ? ls[0] : undefined; }; + const critical = (t: string) => BLOCKS_YES.test(t) || (role === 'adversarial-native' && /(? { const l = LEAD_LABEL.exec(t)?.[1]; return l === undefined ? undefined : named(l); }; + const labelOf = (t: string) => { + const field = fieldOf(t); + if (field === undefined) return strongest(t); + return critical(t) ? 'CRITICAL' : strongestOf([leadOf(t), field])!; + }; + const push = (label: string, raw: string, fields: string[] = [], lead?: string): Finding => { const f = { label, raw, fields: new Set(fields), list: LIST_ITEM.test(raw), lead }; out.push(f); return f; }; for (const line of output.replace(/\r\n/g, '\n').split('\n')) { const t = line.trim(); if (!t) { gap = true; continue; } @@ -107,7 +132,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st gap = false; if (/^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) { current = null; continue; } const indented = line !== line.trimStart(); - const label = strongest(t); + const label = labelOf(t); const blocks = BLOCKS_YES.test(t); const listItem = !indented && LIST_ITEM.test(t); const locus = LOCUS.test(t); @@ -117,6 +142,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st if (current && !current.fields.has(field)) { current.fields.add(field); if (blocks) current.label = 'CRITICAL'; + else if (field === 'severity' && fieldOf(t) !== undefined && !critical(current.raw)) current.label = strongestOf([current.lead, label])!; 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]); @@ -129,7 +155,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st if (t.startsWith('{')) { try { const o = JSON.parse(t) as Record; - if (typeof o.severity === 'string') { current = push(o.severity, t); continue; } + if (typeof o.severity === 'string') { current = push(o.severity, t, [], o.severity); continue; } } catch { /* prose that starts with a brace */ } } const detail = listItem && /^[-*+]\s/.test(t) && !locus && !label && !section && !!current && !current.list && !current.fields.size; @@ -138,7 +164,11 @@ export function parseFindings(reader: string, output: string): Array<{ label: st 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 (found) { + current = push(blocks ? 'CRITICAL' : found, t, [], strongestOf([leadOf(t), inherited, fieldOf(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); diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 630a33ea1..6bdd582dc 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -10,7 +10,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; -import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp'; +import { parseUnifiedDiff, versionAtRef } from './restamp'; +import { branchSides, stampOnlyLine } from './stamp-only'; import { matchesAny } from './ship-policy'; import { extractVersion } from './version-source'; import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation'; @@ -109,8 +110,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile). * A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule: * added since the merge base with origin/, and not added by the base since): after a lost slot and a merge of the - * base its stale stamp is older than `ref`'s VERSION, so it must be newer than the fork point's version instead, the - * bound gstack-restamp's stamp_paths stage uses (GST-895). + * base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead, + * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). */ export function stampedFiles(policy: Record | null, cwd: string, ref: string, base: string): string[] { const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; @@ -123,25 +124,8 @@ export function stampedFiles(policy: Record | null, cwd: string, re const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : []; const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]); if (diff.status !== 0) return []; - let fork: string | undefined; - let since: string | undefined; - const own = new Map>(); - const ownLines = (rel: string): Set => { - if (own.has(rel)) return own.get(rel)!; - since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim(); - let lines = new Set(); - if (since) { - try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {} - } - own.set(rel, lines); - return lines; - }; - const stamp = (rel: string, removed: string, added: string): boolean => { - if (stampOnlyLine(removed, added, before.version, after, templates)) return true; - if (!ownLines(rel).has(removed)) return false; - fork ??= forkVersion(cwd, `origin/${base}`, before.version); - return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork)); - }; + const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref); + const stamp = (rel: string, removed: string, added: string): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel)); return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 && f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path); } diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index dd8d22d31..7047579e3 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -339,7 +339,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`]; git(root, ['fetch', '-q', 'origin', ctx.base]); const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]); - lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : [])); + lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : [])); if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run); if (!policy.changelog_fragments) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'the base policy has changelog_fragments off; land merges only under the key, where ready records the READY receipt it checks' }, run); const keys = repoIdentity(root).keys; diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 19549fe83..340a98568 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -42,7 +42,7 @@ export function isReleasePath(root: string, policy: ShipPolicy, versionRel: stri * byte for byte, and equals T_i outside release paths. Null when all hold, else why not. */ export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null { - const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel }); + const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel, baseRef: `${merged}^1` }); if ('error' in r) return r.error; if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`; const cl = o.policy.changelog; diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 5c7981e00..53119465f 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -34,7 +34,7 @@ import { genericBackstop } from './ship-queue-train-gate'; const Q = 'gstack-ship-queue'; function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same: boolean; detail: string } { - const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${gated}^1` }); if ('error' in r) return { same: false, detail: r.error }; return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` }; } @@ -79,7 +79,7 @@ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: const after = t.prefixes[i + 1]!; const root = env.ctx.repoRoot; if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` }; - const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` }); if ('error' in r) return { same: false, detail: r.error }; const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`; if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` }; @@ -105,7 +105,7 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa const post = sameModuloStamps(env, after.commit, main); if (post.same) return post; const cl = env.policy.policy.changelog; - const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label }); + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label, baseRef: `${after.commit}^1` }); if ('error' in r || !cl || r.residual.some(f => f !== cl)) return post; const parent = git(env.ctx.repoRoot, ['rev-parse', '--verify', '-q', `${main}^`]).out; if (!parent || !sameModuloStamps(env, t.prefixes[i]!.commit, parent).same) return { same: false, detail: `${post.detail}; main's parent ${parent.slice(0, 12) || '(none)'} is not T${t.prefixes[i]!.index} modulo stamps` }; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index b589ddfa9..2cc453be2 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -367,7 +367,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry } const ready = loadReady(env.repo.keys, p.pr, info.headRefOid); const shipped = bindTrainReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid], !!ready?.base_red); - if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); + if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : []), ...(shipped.targetedLine ? [`${shipped.targetedLine} pr=${p.pr}`] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); lines.push(`${shipped.targetedLine} pr=${p.pr}`); if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); let screen = ready.screen; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index dea5df303..7d5dec193 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -66,7 +66,7 @@ export function receiptDigest(r: TreeReceipt): string { } function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure { - const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label }); + const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label, baseRef: `${ctx.remote ?? 'origin'}/${ctx.base}` }); return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r; } diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index 4452321e9..66adef3d7 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -13,8 +13,8 @@ * The queue recomputes the depth the head needs from git objects * (lib/review-collect.ts) and refuses a receipt below it. * `review_targeted`: the targeted pass on money, gate, auth and secret code - * (lib/review-targeted.ts), which the queue reports beside the depth and does - * not refuse on yet (GST-257 PR 1). Kept beside + * (lib/review-targeted.ts); the queue reports it beside the depth and refuses + * a head whose required classes the pass did not cover (GST-966). Kept beside * lib/ship-receipt.ts so that module stays under the size ratchet. */ import { spawnSync } from 'node:child_process'; @@ -146,16 +146,25 @@ export function parseReviewTargeted(value: unknown, productPatchId: string): { o return { ok: true, targeted: { classes, rules: v.rules as string, brief_sha: v.brief_sha, product_patch_id: productPatchId, status: v.status as ReviewTargeted['status'], findings: v.findings, raised_by: (v.raised_by as string).trim() } }; } -/** The queue's report-only line: the classes the head needs against the classes a done targeted pass covered; never a refusal (GST-257 PR 1). */ -export function receiptTargetedLine(required: readonly TargetedClass[], r: ShipReceipt): string { +export interface DepthVerdict { ok: boolean; line: string; message?: string } + +/** + * The classes the head needs against the classes a done targeted pass covered; a required class left uncovered (no pass, + * a failed or unrun pass, or a pass on other classes) is refused (GST-966). A receipt with neither gstack_version nor + * review_depth predates targeted receipts, as receiptDepthVerdict treats it, and is accepted as legacy. + */ +export function receiptTargetedVerdict(required: readonly TargetedClass[], r: ShipReceipt): DepthVerdict { const rt = r.review_targeted; const covered: readonly string[] = rt?.status === 'done' ? rt.classes : []; - const ok = required.every(c => covered.includes(c)); - return `REVIEW_TARGETED: required=${classList(required)} receipt=${rt ? classList(covered) : 'legacy'} verdict=${ok ? 'ok' : 'missing'}`; + const missing = required.filter(c => !covered.includes(c)); + const verdict = !missing.length ? 'ok' : rt?.status === 'failed' ? 'failed' : 'missing'; + const line = `REVIEW_TARGETED: required=${classList(required)} receipt=${rt ? classList(covered) : 'legacy'} verdict=${verdict}`; + if (!missing.length) return { ok: true, line }; + if (!rt && !r.review_depth && !r.gstack_version) return { ok: true, line: `${line} (legacy receipt, written before receipts recorded review_targeted; accepted)` }; + const why = !rt ? 'records no targeted pass' : rt.status === 'failed' ? 'records that its targeted pass failed' : rt.status === 'not-run' ? 'records that its targeted pass did not run' : `covers ${classList(covered)} only`; + return { ok: false, line, message: `the head requires the targeted reader on ${missing.join(', ')}, but the receipt ${why}` }; } -export interface DepthVerdict { ok: boolean; line: string; message?: string } - /** The head's committed range against the base, read once for the depth verdict and the targeted line. */ interface HeadInputs { baseSha: { ok: boolean; out: string }; c: ReturnType; scope: Record } function headInputs(root: string, baseRef: string, head: string): HeadInputs { @@ -192,10 +201,10 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` }; } -/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. */ +/** bindReceipt plus the owner exceptions, the review depth and the targeted pass: what READY, train build and land accept. */ export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]): | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string } - | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } { + | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID' | 'QUEUE_RECEIPT_TARGETED_MISSING'; message: string; line: string; depthLine?: string; targetedLine?: string } { const b = bindReceipt(body, pr, accepted); if (!b.ok) return b; const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok; @@ -204,8 +213,9 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string, const at = headInputs(root, baseRef, b.head); const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head, at); const gateTagged = boundScan(b.receipt, b.head) ?? (gateScanNeeded(at.c.paths, at.c.root) ? null : 0); - const targetedLine = receiptTargetedLine(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt); - if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line, targetedLine }; - return { ...b, depthLine: d.line, targetedLine }; + const t = receiptTargetedVerdict(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt); + if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line, targetedLine: t.line }; + if (!t.ok) return { ok: false, code: 'QUEUE_RECEIPT_TARGETED_MISSING', message: `${t.message}; the receipt is unchanged; run /ship on ${b.head.slice(0, 12)}`, line: b.line, depthLine: d.line, targetedLine: t.line }; + return { ...b, depthLine: d.line, targetedLine: t.line }; } diff --git a/lib/ship-receipt-prose.ts b/lib/ship-receipt-prose.ts index 18e76a8e2..a99ee1784 100644 --- a/lib/ship-receipt-prose.ts +++ b/lib/ship-receipt-prose.ts @@ -7,6 +7,7 @@ */ import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record'; import { RECEIPT_FENCE } from './ship-receipt'; +import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth'; export interface ProseClaim { field: string; prose: number; receipt: number } export interface ProseCheck { checked: number; mismatches: ProseClaim[] } @@ -65,9 +66,24 @@ function withoutApprovalsBlocks(body: string): string { return out.join(''); } +/** + * The body without the `Review-loop exception: …` line the receipt's own exception requires (GST-977): write and the + * receipt check want it word for word, and it quotes the owner's reason (`outside 1 and 2 skip detector`), not this + * PR's counters. Only a line whose trimmed text is that rendered line, the test write applies, is left out (a fenced + * copy too, as write accepts one); the same words inside a longer line stay prose. A marker line takes its place so + * the text on either side never joins into a claim. + */ +function withoutExceptionLine(body: string, exception: unknown): string { + if (exception === undefined) return body; + const parsed = parseReviewLoopException(exception); + if (!parsed.ok) return body; + const line = reviewLoopExceptionLine(parsed.exception); + return body.split(/(?<=\n)/).map(l => (l.trim() === line ? '(review-loop exception line)\n' : l)).join(''); +} + /** * Every `=`, `: ` and ` ` in the prose (the body - * without its receipt block and its rendered Approvals block), where `` is a counter's leaf key with `_` + * without its receipt block, its rendered Approvals block and its review-loop exception line), where `` is a counter's leaf key with `_` * matched as `_`, space or `-` and not part of a longer word (`3 pass-through` * is not a `pass` claim), compared with the receipt. A leaf key shared by * several counters (`pass` under every gate lane, `passed` under gate and ci) @@ -77,7 +93,7 @@ function withoutApprovalsBlocks(body: string): string { * ` ` claim about the next key (#3513). */ export function checkReceiptProse(receipt: Record, body: string): ProseCheck { - const prose = withoutApprovalsBlocks(body.replace(BLOCK_RE, '')); + const prose = withoutExceptionLine(withoutApprovalsBlocks(body.replace(BLOCK_RE, '')), receipt.review_loop_exception); const byLeaf = new Map>(); for (const [field, value] of receiptCounters(receipt)) { const leaf = field.split('.').at(-1)!; diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index b628a0aba..1b1c5ed83 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -12,7 +12,7 @@ import { FRAGMENT_README } from './changelog-fragments'; import { renderIssueLines } from './issue-links'; import { matchesAny } from './ship-policy'; import { stampFileKind, versionStampOnly } from './pregate/stamp-only'; -import { stampOnlyLine } from './restamp'; +import { stampOnlyLine, type BranchSide } from './stamp-only'; export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ @@ -188,13 +188,14 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an * The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else * `package.json`) and the base policy's `stamp_patterns` templates. */ -export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] } +export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide } /** * A policy-named stamp path's change is a stamp when the new text reads as the * old text once every new version in it is the old one, or when every changed * line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old - * version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule + * version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512, and + * on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule * the tree receipt applies to the same `stamp_paths`, `stamp_files` and * `release_outputs` before the train accepts a stamp commit. No version * change, or a file missing on either side, is not a stamp. @@ -209,7 +210,7 @@ export function stampOnlyText(change: CarryChange): boolean { const now = after.split('\n'); if (was.length !== now.length) return false; const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]); - return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [])); + return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side)); } /** diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts new file mode 100644 index 000000000..3b0d2d1e7 --- /dev/null +++ b/lib/stamp-only.ts @@ -0,0 +1,70 @@ +/** + * Stamp-only checks: whether a removed/added line pair is a restamp alone, and + * the branch side (its own lines and fork version) that bounds that check for + * lines the branch wrote itself. Split from restamp.ts (module-size ratchet). + */ +import { spawnSync } from 'node:child_process'; +import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp'; +import { cmpVersion, parseVersion, versionWidth } from './version-source'; + +/** + * A removed/added line pair that a restamp alone explains: every version the + * removed line mentions in a stamp form and the added line no longer does + * moves to `newV`, and the rewritten line is the added line byte for byte. + * The moved versions are the ones the stamp_paths stage moves, not only the + * old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = ''` never equals the base version at the slot and is a stamp all the + * same (#3512). The stage's own bound applies: a tentative mention is newer + * than the version the branch was written against, so a moved version older + * than `oldV` (a released key moved by hand) is not a stamp, and neither is + * one of another width (`tentativeMentions`: a 3-digit dependency pin in a + * 4-digit repo is never a candidate, so moving it is a hand edit). A line that + * moves nothing, or changes anything beyond those mentions, is not one either. + * With `side`, a removed line the branch added itself is bounded by its fork + * point's version instead, the stage's bound for its own lines (GST-895, + * GST-963): finish's stampedFiles, the tree receipt and the receipt carry all + * pass the same side. + */ +export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean { + if (removed === added) return false; + return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates)); +} + +/** Lines the branch added to one file (since its merge base with the base, less the lines the base added since) and its fork point's version. */ +export interface BranchSide { fork: string; added: ReadonlySet } + +/** + * Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to` + * (`head` unless a reviewed tree stands in for it), the fork version from `head`'s first-parent history + * (`fallback` when it has no own commit). A file git cannot diff has no side, so its lines keep the `oldV` bound. + */ +export function branchSides(repoRoot: string, baseRef: string, head: string, fallback: string, to = head): (rel: string) => BranchSide | undefined { + const sides = new Map(); + let since: string | undefined; + let fork: string | undefined; + return rel => { + if (sides.has(rel)) return sides.get(rel); + since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim(); + let side: BranchSide | undefined; + if (since) { + try { + const added = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel)?.text; + if (added?.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added }; + } catch {} + } + sides.set(rel, side); + return side; + }; +} + +function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean { + const floor = parseVersion(oldV); + const width = versionWidth(oldV); + const after = stampMentions(added, templates); + const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v)); + if (!moved.length || !floor) return false; + if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false; + let text = removed; + for (const v of moved) text = rewriteStamps(text, v, newV, templates).text; + return text === added; +} diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index c946ffa71..7ba350205 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -16,7 +16,8 @@ import * as path from 'node:path'; import { normalizeChangelog } from './changelog-check'; import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments'; import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle'; -import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; +import { parseUnifiedDiff, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; +import { branchSides, stampOnlyLine, type BranchSide } from './stamp-only'; import type { ResultCodeName } from './result-codes'; import { matchesAny, type ShipPolicy } from './ship-policy'; @@ -55,7 +56,7 @@ function blob(cwd: string, treeish: string, rel: string): string | null { * between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new * heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree. */ -export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] { +export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, side?: (rel: string) => BranchSide | undefined): FileVerdict[] { const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; const folded = new Map(); for (const f of fileDiffs) { @@ -65,11 +66,11 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st if (parsed && !('code' in parsed)) folded.set(f.path, parsed); } const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); - const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments); + const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments, side); const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog); const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; return fileDiffs.map((f): FileVerdict => { - if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV); + if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, [], side); return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` }; }); } @@ -77,15 +78,15 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st /** * Every removed/added pair differs only by stamp rewrites to the new version: * the old version, or a tentative one (newer than it) the stamp_paths stage - * moved (#3512), each in a stamp form (`stampOnlyLine`). + * moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines). */ -function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null { +function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null { for (const h of hunks) { if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at }; for (let i = 0; i < h.removed.length; i++) { const r = h.removed[i]!; const a = h.added[i]!; - if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; + if (!stampOnlyLine(r, a, oldV, newV, templates, side)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; } } return null; @@ -96,7 +97,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string return normalizeLockfile(name, a) === normalizeLockfile(name, b); } -export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict { +export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = [], side?: (rel: string) => BranchSide | undefined): FileVerdict { const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line }); const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' }); if (f.binary) return changed('binary', 'binary content differs'); @@ -127,8 +128,9 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)'); } const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null; - if (oldV && newV && oldV !== newV && rule) { - const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns); + const own = rule ? side?.(f.path) : undefined; + if (oldV && newV && (oldV !== newV || own) && rule) { + const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own); return bad ? changed(rule, bad.why, bad.line) : stamp(rule); } return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`); @@ -136,6 +138,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre export interface ReceiptOptions { repoRoot: string; gated: string; current?: string; policy: ShipPolicy; policyLabel: string; versionRel?: string; + /** The base ref the gated head's own lines are read against (the fork-version bound, GST-963); without it every line keeps the old version's bound. */ + baseRef?: string; predecessor?: { pr: string | null; recordedHead: string | null; ref?: string | null; currentHead?: string | null }; bundlePath?: string; gh?: (args: string[]) => { ok: boolean; out: string }; env?: NodeJS.ProcessEnv; rerunCommand?: string; } @@ -176,7 +180,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' }; } const diffText = d.stdout.replace(/\n$/, ''); - const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV); + const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV, o.baseRef && oldV ? branchSides(o.repoRoot, o.baseRef, gatedHead, oldV) : undefined); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : ''; diff --git a/modernize/SKILL.md b/modernize/SKILL.md index 8896a70a1..2b50f82ac 100644 --- a/modernize/SKILL.md +++ b/modernize/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 9bc0af744..571f62648 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -99,9 +99,9 @@ Q-numbering starts at `Q1` per invocation, independent of D-numbering. Questions ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/package.json b/package.json index aa9e39e0d..312e37851 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.2", + "version": "1.93.10", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", @@ -103,6 +103,7 @@ "typescript": "7.0.2" }, "overrides": { + "@hono/node-server": "2.1.6", "@modelcontextprotocol/sdk": "1.32.0", "adm-zip": "^0.6.1", "fast-uri": "3.1.9", @@ -110,6 +111,7 @@ "image-size": "2.0.4", "ip-address": "10.7.4", "proxy-addr": "2.0.8", + "qs": "6.16.2", "sharp": "0.35.5" }, "patchedDependencies": { diff --git a/pair-agent/SKILL.md b/pair-agent/SKILL.md index 6774c17c9..912a3e476 100644 --- a/pair-agent/SKILL.md +++ b/pair-agent/SKILL.md @@ -51,9 +51,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index c804a37a5..e64c876ec 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -98,9 +98,9 @@ Before emitting a tool or prose decision brief, verify: ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index 7314fce32..213b5a34a 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -104,9 +104,9 @@ Before emitting a tool or prose decision brief, verify: ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/plan-devex-review/SKILL.md b/plan-devex-review/SKILL.md index f5da53a6f..b9e9f11ab 100644 --- a/plan-devex-review/SKILL.md +++ b/plan-devex-review/SKILL.md @@ -68,9 +68,9 @@ Before emitting a tool or prose decision brief, verify: ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index 46dc6e36b..4061ac83b 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -189,9 +189,9 @@ Before emitting a tool or prose decision brief, verify: ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/plan-tune/SKILL.md b/plan-tune/SKILL.md index 481d7764e..d4e77e818 100644 --- a/plan-tune/SKILL.md +++ b/plan-tune/SKILL.md @@ -58,9 +58,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index b000b93c1..34dfe43d6 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/qa/SKILL.md b/qa/SKILL.md index 3ac1de3f7..5a348f9c7 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/retro/SKILL.md b/retro/SKILL.md index 85b3ba8df..0e85fb8a1 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -72,9 +72,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/review/SKILL.md b/review/SKILL.md index 8ae904514..f98b7ca7a 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/scripts/harness-version.json b/scripts/harness-version.json index e53c17097..a4bbf78c8 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -34,7 +34,7 @@ "test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6}, "test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6}, "test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":6}, - "test/helpers/pty/boundaries.ts": {"blob":"c5520118da36f96f60a3c9720751bb57a669865c","since":6,"nonBehavioral":"Widens the plan-eng-review first-review severity annotation from [P0-3] to [P0-3|CRITICAL|INFORMATIONAL] (#3349). Only a question carrying the new labels can change verdict, and only the changed plan-eng-review prompt asks for them; every case that runs it owns that generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."}, + "test/helpers/pty/boundaries.ts": {"blob":"c09b69611fb22ddf093adb2baa83434494e5f8fa","since":6,"nonBehavioral":"Widens the two plan-eng-review boundary predicates' qid regex to also accept the optional ;signal= hint suffix (#3442). Only a question carrying that suffix can change verdict, and only the changed question-tuning resolver asks for it; every case that runs a skill with that resolver owns its generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."}, "test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":6}, "test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":6}, "test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":6}, diff --git a/scripts/psychographic-signals.ts b/scripts/psychographic-signals.ts index d6a479497..11d51ef42 100644 --- a/scripts/psychographic-signals.ts +++ b/scripts/psychographic-signals.ts @@ -306,6 +306,42 @@ export function resolveOptionKey(question_id: string, choice: string): string | return tie ? undefined : best; } +/** + * Map a choice on an unregistered question to an option key of its hinted + * signal (#3442). Only the exact key or the key's own words count, never + * OPTION_LABEL_CUES: a signal's keys mix several questions' options, so a + * cue such as "accept" could map a "yes" to the opposite delta. The longest + * matching key wins; a tie or no match returns undefined. + */ +export function resolveHintedOptionKey(signal_key: string, choice: string): string | undefined { + if (!Object.hasOwn(SIGNAL_MAP, signal_key)) return undefined; + const keys = Object.keys(SIGNAL_MAP[signal_key]!); + if (keys.includes(choice)) return choice; + const label = normalizeOptionLabel(choice); + if (!label) return undefined; + let best: string | undefined; + let bestWords = 0; + let tie = false; + for (const key of keys) { + const words = key.replace(/-/g, ' '); + if (label !== words && !label.startsWith(words + ' ')) continue; + const n = words.split(' ').length; + if (n > bestWords) { + best = key; + bestWords = n; + tie = false; + } else if (n === bestWords) { + tie = true; + } + } + return tie ? undefined : best; +} + +/** One line per signal key with its option keys, as `--signal-keys` prints it. */ +export function signalKeyLines(): string[] { + return Object.entries(SIGNAL_MAP).map(([key, byChoice]) => `${key}: ${Object.keys(byChoice).join(' | ')}`); +} + /** * Validate that every signal_key referenced in the registry has a matching * entry in SIGNAL_MAP. Called by tests to catch drift. diff --git a/scripts/qid-marker.ts b/scripts/qid-marker.ts new file mode 100644 index 000000000..bb213d00d --- /dev/null +++ b/scripts/qid-marker.ts @@ -0,0 +1,26 @@ +/** + * The marker a skill puts in an asked question (#3442). + * + * `` names the question; an unregistered id may add a + * signal hint, ``, naming the + * SIGNAL_MAP key its answer speaks to. Ids and keys are read lowercase. + * Whether a hint is a real SIGNAL_MAP key is checked where the row is + * logged (bin/gstack-question-log), not here. + */ +export const QID_MARKER_RE = //i; + +export interface QidMarker { + id: string; + signal_key?: string; + stripped: string; +} + +export function parseQidMarker(text: string): QidMarker | undefined { + const m = QID_MARKER_RE.exec(text); + if (!m) return undefined; + return { + id: m[1]!.toLowerCase(), + ...(m[2] ? { signal_key: m[2].toLowerCase() } : {}), + stripped: text.replace(QID_MARKER_RE, '').trim(), + }; +} diff --git a/scripts/resolvers/question-tuning.ts b/scripts/resolvers/question-tuning.ts index 2e35c2b89..e034d6735 100644 --- a/scripts/resolvers/question-tuning.ts +++ b/scripts/resolvers/question-tuning.ts @@ -27,9 +27,9 @@ export function generateQuestionTuning(ctx: TemplateContext): string { const registry = `${ctx.paths.skillRoot}/scripts/question-registry.ts`; return `## Question Tuning (skip entirely if \`QUESTION_TUNING: false\`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose \`question_id\` from \`${registry}\` or \`{skill}-{slug}\`, then run \`${bin}/gstack-question-preference --check ""\`; for an unregistered id, write the question summary to \`.gstack/tmp/qt.txt\` (file-write tool) and append \`--summary-file .gstack/tmp/qt.txt\` (one-way keyword check). \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose \`question_id\` from \`${registry}\` or \`{skill}-{slug}\`, then run \`${bin}/gstack-question-preference --check ""\`; for an unregistered id, write the question summary to \`.gstack/tmp/qt.txt\` (file-write tool) and append \`--summary-file .gstack/tmp/qt.txt\` (one-way keyword check); its \`--signal-keys\` prints an optional signal-key hint. \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include \`\` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include \`\` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the \`(recommended)\` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/scripts/review-dispatch-compare.ts b/scripts/review-dispatch-compare.ts index ad8790dad..efe817232 100755 --- a/scripts/review-dispatch-compare.ts +++ b/scripts/review-dispatch-compare.ts @@ -85,6 +85,7 @@ function newShip(f: Fixture, sc: Scenario): { calls: Call[]; ending: string } { const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION; for (const d of plan.dispatches) calls.push({ role: d.role, name: d.name, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes }); adversarialRan ||= plan.dispatches.some(d => d.role === 'adversarial-native'); + if (plan.next.step === 'voices') continue; const o = outcomes[sc][Math.min(i, outcomes[sc].length - 1)] ?? {}; const last = plan.next.step === 'scoped' ? 'full' : plan.next.step; h = { fullPasses: h.fullPasses + (last === 'full' ? 1 : 0), last, ...o }; diff --git a/setup-deploy/SKILL.md b/setup-deploy/SKILL.md index bcdc45db2..1ad580a9f 100644 --- a/setup-deploy/SKILL.md +++ b/setup-deploy/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 201c54af9..aaeb4348e 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -56,9 +56,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ship/DRIVER.md b/ship/DRIVER.md index a7da6798b..ca721a499 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -84,11 +84,10 @@ reader ran. ## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11 Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps -9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included; -`JUDGMENT: ok` before any fix), fixes and persistence in +9.3, 9.4 and the repeat decision as written (`JUDGMENT: ok` before any fix), fixes and persistence in one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or `scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the -pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from +pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done` or `voices`: that program runs Step 11 from `sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither prints `ADVERSARIAL: skipped (review plan)`). diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index f7b98d4c8..a0f0d8ac1 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -82,11 +82,10 @@ reader ran. ## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11 Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps -9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included; -`JUDGMENT: ok` before any fix), fixes and persistence in +9.3, 9.4 and the repeat decision as written (`JUDGMENT: ok` before any fix), fixes and persistence in one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or `scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the -pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from +pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done` or `voices`: that program runs Step 11 from `sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither prints `ADVERSARIAL: skipped (review plan)`). diff --git a/ship/SKILL.md b/ship/SKILL.md index de5eaa671..43ec62eab 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 0bc8600b3..97f66a3d8 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -8,6 +8,9 @@ with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH (`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap, reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) and add `--reservation ` to the `--start adversarial-review` call below. +After `NEXT: voices` (gate class: Step 11 before any stop), Finish the adversarial phase takes neither +decision 2 nor 3: rerun Step 9's repeat decision with `--adversarial-ran` (a queued blocking finding as +`--blocking-open`); its `NEXT:` (the stop, or the owner's granted fix-check) stands. ## Step 11: Adversarial review diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl index ea99ddfdd..53bd90695 100644 --- a/ship/sections/adversarial.md.tmpl +++ b/ship/sections/adversarial.md.tmpl @@ -6,6 +6,9 @@ with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH (`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap, reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) and add `--reservation ` to the `--start adversarial-review` call below. +After `NEXT: voices` (gate class: Step 11 before any stop), Finish the adversarial phase takes neither +decision 2 nor 3: rerun Step 9's repeat decision with `--adversarial-ran` (a queued blocking finding as +`--blocking-open`); its `NEXT:` (the stop, or the owner's granted fix-check) stands. {{ADVERSARIAL_STEP}} diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index 450f3adae..f78dda7d1 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -820,8 +820,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan - with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, -`--depth-floor ` when given), `--range-base $PASS_HEAD` and `--pass1-depth `; -its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. +`--depth-floor ` when given, `--grant ` once the owner's grant of one scoped fix-check is +in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth `; +its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop. `scoped` is 3 too. A full pass 1 always gets a full pass 2. Other scopes apply decisions 2–4 in order. diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl index 8b349c4e9..9ce103804 100644 --- a/ship/sections/review-army.md.tmpl +++ b/ship/sections/review-army.md.tmpl @@ -229,8 +229,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan - with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, -`--depth-floor ` when given), `--range-base $PASS_HEAD` and `--pass1-depth `; -its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. +`--depth-floor ` when given, `--grant ` once the owner's grant of one scoped fix-check is +in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth `; +its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop. `scoped` is 3 too. A full pass 1 always gets a full pass 2. Other scopes apply decisions 2–4 in order. diff --git a/skillify/SKILL.md b/skillify/SKILL.md index 14cf07b93..fbf9b99ab 100644 --- a/skillify/SKILL.md +++ b/skillify/SKILL.md @@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/spec/SKILL.md b/spec/SKILL.md index 8d94a32c8..722483a88 100644 --- a/spec/SKILL.md +++ b/spec/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index da280e4ff..7b9c4ff20 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/test-audit/SKILL.md b/test-audit/SKILL.md index 732a31cf6..3072ea8cf 100644 --- a/test-audit/SKILL.md +++ b/test-audit/SKILL.md @@ -54,9 +54,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/test/dependency-security.test.ts b/test/dependency-security.test.ts index 25ecb982a..d6a5e16dd 100644 --- a/test/dependency-security.test.ts +++ b/test/dependency-security.test.ts @@ -6,7 +6,7 @@ import AdmZip from "adm-zip"; import sharp from "sharp"; describe("security dependency overrides", () => { - for (const [name, minimum] of [["sharp", "0.35.4"], ["adm-zip", "0.6.1"]]) { + for (const [name, minimum] of [["sharp", "0.35.4"], ["adm-zip", "0.6.1"], ["qs", "6.16.1"], ["@hono/node-server", "2.1.3"]]) { test(`${name} resolves only versions at or above ${minimum}`, async () => { const lock = await Bun.file(join(import.meta.dir, "../bun.lock")).text(); const versions = [...lock.matchAll(new RegExp(`"${name}@([^"]+)"`, "g"))]; diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 832c00e42..3b8907a44 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -50,9 +50,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `$GSTACK_ROOT/[ski ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index de5eaa671..43ec62eab 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -57,9 +57,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 3ae35dbb3..093381d4f 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -48,9 +48,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `$GSTACK_ROOT/[ski ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 1eabf0836..4e89c2bf5 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -50,9 +50,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `$GSTACK_ROOT/[ski ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `$GSTACK_BIN/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/test/fixtures/review-ledger/main-labels.json b/test/fixtures/review-ledger/main-labels.json new file mode 100644 index 000000000..c923a6ceb --- /dev/null +++ b/test/fixtures/review-ledger/main-labels.json @@ -0,0 +1 @@ +{"note":"parseFindings labels at main 82e9d7ec (GST-976): every input the review-dispatch and prose-voice-floor tests pass it, every reader fixture here, and generated Severity field shapes. Regenerate only from a frozen main.","main":"82e9d7ec7a2c222f20b04aaf5b965fa50d7b6bdb","entries":[{"source":"pinned","reader":"adversarial-native","output":"FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"INVESTIGATE: odd lock in b.ts:9\nSeverity: Low\nBlocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"INVESTIGATE: odd lock in b.ts:9\nBlocks: no","main":["INVESTIGATE"]},{"source":"pinned","reader":"adversarial-native","output":"FIXABLE: race in a.ts:3. Severity: Medium. Blocks: no","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"1. FIXABLE: race in a.ts:3 — Severity: Low, Blocks: no","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"- FIXABLE: race in a.ts:3\n- Severity: Medium\n- Blocks: no","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Drops the last row (q.ts:7). Severity: Critical. Blocks: no","main":["Critical"]},{"source":"pinned","reader":"outside-voice","output":"1. Cache evicts sessions (c.ts:4)\n Severity: High","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"1. Retry storm (r.ts:2)\n Severity: Medium\n Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Index drift (i.ts:5). Severity: Low. Blocks: Yes, it loses the row","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Naming (n.ts:1)\n Severity: Low\n Blocks: no","main":["Low"]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\nNO FINDINGS\n","main":[]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\n{\"severity\":\"CRITICAL\",\"path\":\"a.ts\",\"line\":3,\"category\":\"logic\",\"summary\":\"off by one\",\"specialist\":\"testing\"}\n{\"severity\":\"INFORMATIONAL\",\"path\":\"a.ts\",\"line\":9,\"category\":\"style\",\"summary\":\"name\",\"specialist\":\"testing\"}\n","main":["CRITICAL","INFORMATIONAL"]},{"source":"pinned","reader":"outside-voice","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\nFindings:\n1. **High**: retry loop never backs off (q.ts:78)\n2. Low - log line is noisy\nRecommendation: Fix the retry because it DoSes the pool\n","main":["High","Low"]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\n{\"severity\":\"CRITICAL\",\"path\":\"a.ts\",\"line\":3,\"category\":\"logic\",\"summary\":\"x\",\"specialist\":\"testing\"}\n","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\n{\"severity\":\"CRITICAL\",\"path\":\"b.ts\",\"line\":1,\"category\":\"x\",\"summary\":\"y\",\"specialist\":\"red-team\"}\n","main":["CRITICAL"]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\n{\"severity\":\"CRITICAL\",\"path\":\"a.ts\",\"line\":3,\"category\":\"logic\",\"summary\":\"x\",\"specialist\":\"testing\"}\n{\"severity\":\"INFORMATIONAL\",\"path\":\"b.ts\",\"line\":1,\"category\":\"n\",\"summary\":\"y\",\"specialist\":\"testing\"}\n","main":["CRITICAL","INFORMATIONAL"]},{"source":"pinned","reader":"r","output":"NO FINDINGS\n","main":[]},{"source":"pinned","reader":"r","output":"## High\n1. The auth check skips webhooks (a.ts:4)\n - Impact: nested evidence\n **Fix:**\n- second one\n## Notes\n- not a finding\n**Critical findings**\n- real one\n**Recommendations**\n- consider renaming foo\n","main":["High","High","Critical"]},{"source":"pinned","reader":"r","output":"1. **Unbounded retry in queue.ts:78** (FIXABLE): loops on 429\n2. `auth.ts:42` skips token expiry check — INVESTIGATE\n### CRITICAL: missing auth check in api.ts:10\n#### [P1] token not expired\n### High: authorization bypass (auth.ts:14)\n- **HIGH**: leak\n| High | race in queue.ts:4 |\nClassification: INVESTIGATE\n7. **Informational: reads every file**","main":["FIXABLE","INVESTIGATE","CRITICAL","P1","High","HIGH","High","INVESTIGATE","Informational"]},{"source":"pinned","reader":"r","output":"FIXABLE: race in a.ts:3\nINVESTIGATE: odd lock\nRecommendation: Fix the race because x\n","main":["FIXABLE","INVESTIGATE"]},{"source":"pinned","reader":"outside-voice","output":"No CRITICAL issues except the token leak in log.ts:3","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No CRITICAL issues except the token leak in log.ts:3","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"No CRITICAL issues except the token leak in log.ts:3","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"No CRITICAL issues except the token leak in log.ts:3","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"There is no CRITICAL issue other than the SQL injection at db.ts:12","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"There is no CRITICAL issue other than the SQL injection at db.ts:12","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"There is no CRITICAL issue other than the SQL injection at db.ts:12","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"There is no CRITICAL issue other than the SQL injection at db.ts:12","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing CRITICAL apart from the auth bypass (auth.ts:9).","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"Nothing CRITICAL apart from the auth bypass (auth.ts:9).","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"Nothing CRITICAL apart from the auth bypass (auth.ts:9).","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"Nothing CRITICAL apart from the auth bypass (auth.ts:9).","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No new CRITICAL findings, but the token leak in log.ts:3 still blocks.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No new CRITICAL findings, but the token leak in log.ts:3 still blocks.","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"No new CRITICAL findings, but the token leak in log.ts:3 still blocks.","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"No new CRITICAL findings, but the token leak in log.ts:3 still blocks.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"None CRITICAL besides the race in queue.ts:40, which loses writes.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"None CRITICAL besides the race in queue.ts:40, which loses writes.","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"None CRITICAL besides the race in queue.ts:40, which loses writes.","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"None CRITICAL besides the race in queue.ts:40, which loses writes.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"- **No CRITICAL** issues remain except F2 (store.ts:22 corrupts data).","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"- **No CRITICAL** issues remain except F2 (store.ts:22 corrupts data).","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"- **No CRITICAL** issues remain except F2 (store.ts:22 corrupts data).","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"- **No CRITICAL** issues remain except F2 (store.ts:22 corrupts data).","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"REVIEWED_TREE: bedd511efa0fa16c9277821073c68e438dc760d2 /workspace/gstack-evals\n{\"severity\":\"INFORMATIONAL\",\"path\":\"wiki/board/README.md\",\"line\":229,\"summary\":\"Section 9's opening still says every ruling is recorded in the approvals file.\"}\n{\"severity\":\"INFORMATIONAL\",\"path\":\"wiki/board/README.md\",\"line\":113,\"summary\":\"The tstack row still runs on full stacks-r1 and is not labelled raw.\"}\n\nI checked these against the post, the frozen texts and the pinned code, and found nothing CRITICAL. The arithmetic is consistent throughout:\n- The new secondary: 47/60, 15/60, 5/40, 7/40, 4/40 and 11.5/40.\n- The minimum p of 2^-15.","main":["INFORMATIONAL","INFORMATIONAL","CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"Found no CRITICAL issues in the fix range.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"Nothing CRITICAL or HIGH remains.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"This is not CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"I found zero Critical findings, and none High.","main":["Critical"]},{"source":"pinned","reader":"fix-check","output":"The retry path is safe; without CRITICAL impact.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No new CRITICAL issues introduced by the fix.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No remaining CRITICAL or HIGH findings.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"**No** CRITICAL issues found.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No **CRITICAL** issues found.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No `CRITICAL` issue.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No [P1] issues remain.","main":["P1"]},{"source":"pinned","reader":"fix-check","output":"Nothing at CRITICAL severity remains.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No CRITICAL regressions introduced by the fix.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"Nothing CRITICAL to report.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"There is nothing CRITICAL in the fix range.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No CRITICAL — the fix is sound.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"Found no CRITICAL or HIGH regressions.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No HIGH-risk changes.","main":["HIGH"]},{"source":"pinned","reader":"fix-check","output":"No CRITICAL/HIGH findings.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No P0/P1 issues.","main":["P0"]},{"source":"pinned","reader":"fix-check","output":"No CRITICAL, HIGH or MEDIUM issues.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"Checked collect() at lib/review-ledger.ts:189; no CRITICAL issues remain.","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"No CRITICAL issues; served on localhost:3000.","main":["CRITICAL"]},{"source":"pinned","reader":"r","output":"Not CRITICAL, but High: the cache drops a row (c.ts:4)","main":["CRITICAL"]},{"source":"pinned","reader":"r","output":"No CRITICAL, High: the cache drops a row (c.ts:4)","main":["CRITICAL"]},{"source":"pinned","reader":"r","output":"Nothing CRITICAL / HIGH: race in q.ts:7","main":["CRITICAL"]},{"source":"pinned","reader":"r","output":"No CRITICAL lock guards the write in Makefile:7\nSeverity: CRITICAL","main":["CRITICAL"]},{"source":"pinned","reader":"r","output":"1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL lock guards the write in Makefile:7\nSeverity: CRITICAL","main":["LOW","CRITICAL"]},{"source":"pinned","reader":"r","output":"1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL issues remain.","main":["LOW","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"- a.ts:3 race. Severity: Low. Blocks: no\n Separately, nothing CRITICAL about b.ts:9, but it leaks fds.\n Severity: High","main":["Low","CRITICAL"]},{"source":"pinned","reader":"r","output":"1. A CRITICAL race in queue.ts:78 that no lock covers","main":["CRITICAL"]},{"source":"pinned","reader":"r","output":"- A divide by zero CRITICAL bug in parse()","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"FIXABLE: no timeout on the fetch in a.ts:3","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Retry loop drops the last row (queue.ts:78). Severity: Medium · Blocks: yes\n2. INVESTIGATE: lock order in pool.ts:12\n Blocks: yes\n3. FIXABLE: stale cache key (cache.ts:4)\n Severity: High\n Blocks: no\n4. Token never expires (auth.ts:9)\n Fix: check exp\n **Blocks:** yes\nRecommendation: Fix the retry loop because it loses rows","main":["CRITICAL","CRITICAL","High","CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"1. INVESTIGATE: lock order in pool.ts:12\n Severity: Medium\n Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Low-level retry loop in queue.ts:78 drops writes on 429. Severity: Critical. Blocks: no\n- [P2] cache.ts:40 evicts live sessions. Severity: High. Blocks: no","main":["Critical","High"]},{"source":"pinned","reader":"outside-voice","output":"1. FIXABLE: race in a.ts:3\nWhy it matters: two writers\nSeverity: Low\nBlocks: yes\n2. Unlabeled item about b.ts:9\nSeverity: Medium","main":["CRITICAL","Medium"]},{"source":"pinned","reader":"outside-voice","output":"1. Token never expires (auth.ts:9). Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Token never expires in auth.ts:9 — Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Token never expires (auth.ts:9)\nFix: check exp **Blocks:** yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"## Critical findings\n- Severity: Medium — missing owner check at auth.ts:14\n- Severity: Low — token logged at log.ts:3","main":["Critical","Critical"]},{"source":"pinned","reader":"outside-voice","output":"**Finding 1.** a.ts:3 drops rows\nSeverity: Low\nBlocks: no\n**Finding 2.** b.ts:4 leaks\nSeverity: High\nBlocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"a.ts:3 drops rows\nSeverity: High\nBlocks: yes\nb.ts:4 leaks\nSeverity: High\nBlocks: yes","main":["CRITICAL","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Medium: cache key drift (c.ts:4)\n Blocks the event loop on every miss - High","main":["Medium","High"]},{"source":"pinned","reader":"outside-voice","output":"2. Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"> Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"A in a.ts:1.\nSeverity: Low\n\nB in b.ts:2.\nBlocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"A in a.ts:1.\nSeverity: Low\nB in b.ts:2.\nBlocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Findings:\n A in a.ts:1.\n Severity: Low\n B in b.ts:2.\n Blocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"A in a.ts:1.\nSeverity: Low\nB in b.ts:2 drops rows. Blocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"**Finding 1.** a.ts:3 races. Severity: Low\n**Finding 2.** b.ts:4 leaks. Blocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"FIXABLE: race in a.ts:3\nSeverity: Medium\nWhy: the writer races\nBlocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"- Unlabeled thing in a.ts:3\n- Severity: Medium\n- Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Token never expires (auth.ts:9)\nFix: check exp\nBlocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Retry storm (r.ts:2)\nSeverity High","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native/adversarial","output":"FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"FIXABLE: race in a.ts:3\nSeverity: Medium","main":["Medium"]},{"source":"pinned","reader":"outside-voice","output":"1. Retry loses writes (queue.ts:78). Severity: Medium\n Fix: reject the batch. Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"specialist-batched","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\nI couldn't run the review because the diff base doesn't exist. `git diff 1b68e637c07cea38f01c01120e02d184575da82c` fails with \"bad object\", and fetching that sha from origin is refused with \"not our ref\", so the commit is neither in this checkout nor on the remote.\n\nThis checkout is on `main` at 047dad7 (v1.91.357.0), tracking origin/main with no uncommitted changes, so there's nothing local to review against either. I'm not returning NO FINDINGS, because that would claim a clean diff I never saw.\n\nTo continue I need one of these: a diff base that exists, or the branch or sha holding the c3-36-p1 change. It may only exist on another machine and was never pushed. I've read all three checklists (testing, security, state-machines) and will apply them as soon as I have a diff.","main":[]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\n","main":[]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\nI could not access the diff: git diff failed with \"bad object\", so there is nothing here to review.\n","main":[]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\n{\"severity\":\"CRITICAL\",\"path\":\"a.ts\",\"line\":3,\"category\":\"logic\",\"summary\":\"off by one\",\"specialist\":\"testing\"}\n","main":["CRITICAL"]},{"source":"pinned","reader":"specialist/testing","output":"NO FINDINGS\n","main":[]},{"source":"pinned","reader":"specialist-batched","output":"`NO FINDINGS`\n","main":[]},{"source":"pinned","reader":"targeted","output":"NO FINDINGS\n","main":[]},{"source":"pinned","reader":"fix-check","output":"NO FINDINGS\n","main":[]},{"source":"pinned","reader":"red-team","output":"Fixtures were reviewed in summary mode only.\n\nNO FINDINGS\n","main":[]},{"source":"pinned","reader":"adversarial-native","output":"Fixtures were reviewed in summary mode.\n\nRecommendation: Ship as-is because no finding survived tracing\n","main":[]},{"source":"pinned","reader":"adversarial-native","output":"NO FINDINGS\n","main":[]},{"source":"pinned","reader":"outside-voice","output":"No actionable findings in the supplied diff. Static review only.\n\nRecommendation: approve because the diff introduces no demonstrated bypass.\n","main":[]},{"source":"pinned","reader":"outside-voice","output":"NO FINDINGS\n","main":[]},{"source":"pinned","reader":"specialist/testing","output":"Recommendation: ship because it is clean\n","main":[]},{"source":"pinned","reader":"specialist/testing","output":"I'm not returning NO FINDINGS, because I never saw the diff.\n","main":[]},{"source":"pinned","reader":"red-team","output":"","main":[]},{"source":"pinned","reader":"fix-check","output":"The fixes look fine to me.\n","main":[]},{"source":"pinned","reader":"adversarial-native","output":"I could not read the diff.\n","main":[]},{"source":"pinned","reader":"adversarial-native","fixture":"review-ledger/gsta-196-native.txt","main":["High","CRITICAL","CRITICAL","Low"]},{"source":"pinned","reader":"outside-voice","output":"1. FIXABLE: race in a.ts:3\n- Unlabeled sibling at b.ts:9\nSeverity: Medium","main":["FIXABLE","Medium"]},{"source":"pinned","reader":"outside-voice","output":"**Retry storm in r.ts:2 (INVESTIGATE).**\n- detail\n- High: a second defect in s.ts:4\nSeverity: Medium","main":["INVESTIGATE","High"]},{"source":"pinned","reader":"outside-voice","output":"Low: cache TTL mismatch (cache.ts:4)\n\n2. Token never expires (auth.ts:9)\nSeverity: High\nBlocks: yes","main":["Low","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"**Race in a.ts:3 (High).**\n- the writer holds no lock\nSeverity: Medium","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"**Race in a.ts:3 (Medium).**\n\n- the writer holds no lock\n\nSeverity: Medium. Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"fix-check","output":"{\"severity\":\"INFORMATIONAL\",\"path\":\"a.ts\",\"line\":3,\"summary\":\"Severity: High is the wrong word here\"}\n","main":["INFORMATIONAL"]},{"source":"pinned","reader":"outside-voice","output":"| 1 | `CRITICAL` | High | token leak in log.ts:3 |","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"| log.ts:3 | `CRITICAL` | High | token leak |","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"None of the writes are atomic and are CRITICAL (queue.ts:78); High confidence","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing guards the write path the failure of which is CRITICAL","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"| a.ts:3 | race | not CRITICAL, Low |","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"| a.ts:3 | maps `FIXABLE` to `CRITICAL` in a comment | Low |","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Neither path checks auth so this is CRITICAL (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"None of the inputs are sanitized so this is CRITICAL (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing checks the token which is CRITICAL (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No bounds check lets CRITICAL data leak (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No defects in parsing but the upload handler is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Without auth this is CRITICAL (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Not checking expiry at auth.ts:9 lets stale tokens through. Severity: High","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"The lock isn't released on error (pool.ts:12): High","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"A retry storm could escalate up to CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Low: the parser now maps `FIXABLE` to `CRITICAL` in a comment (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No input validation is CRITICAL (upload.ts:4)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No CSRF token is a CRITICAL gap at form.ts:12","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing escaped here is CRITICAL: stored XSS at view.ts:8","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No timeout here is High (fetch.ts:3)","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"Upgraded: not High, CRITICAL (a.ts:3)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Severity: not Medium, CRITICAL","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing else is CRITICAL except the token leak in log.ts:3.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No other issue is CRITICAL besides the auth bypass in auth.ts:9.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"None of the other findings are CRITICAL, but the SQL injection in db.ts:12 is.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing here is CRITICAL apart from the unescaped query (db.ts:12).","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing else is CRITICAL except the token leak.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Fix the token leak in log.ts:3 first; nothing else is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"The token leak in log.ts:3 blocks the merge. Nothing else is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Token leak in log.ts:3 must be fixed before landing.\n\nNothing else is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"The token leak blocks the merge.\nNo other issue is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"The token leak in log.ts:3 blocks the merge. None of the other findings are CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"The token leak in log.ts:3 must block the merge. The other two are nits. Neither finding is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Apart from the token leak in log.ts:3, I found two nits. Neither finding is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Besides the auth bypass at auth.ts:9 there are three nits. None of the three findings are CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"The token leak in log.ts:3 must block the merge. Nothing here is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No auth check guards the admin route, so nothing CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"adversarial-native","fixture":"review-ledger/harn15-152-p1-adversarial-native.txt","main":["CRITICAL","INVESTIGATE","INVESTIGATE","CRITICAL","INVESTIGATE","INVESTIGATE","CRITICAL"]},{"source":"pinned","reader":"adversarial-native","output":"**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low. Blocks: no","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"1. Race in a.ts:3 (FIXABLE).","main":["FIXABLE"]},{"source":"pinned","reader":"adversarial-native","output":"No rate limit is FIXABLE (login.ts:9)","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","fixture":"review-ledger/harn15-ship2-red-team.txt","main":["CRITICAL","INFORMATIONAL","INFORMATIONAL","INFORMATIONAL"]},{"source":"pinned","reader":"red-team","fixture":"review-ledger/harn15-ship2-p2-red-team.txt","main":["CRITICAL","INFORMATIONAL","INFORMATIONAL","INFORMATIONAL"]},{"source":"pinned","reader":"fix-check","fixture":"review-ledger/harn15-ship2-p2-fix-check.txt","main":["CRITICAL","INFORMATIONAL","INFORMATIONAL","INFORMATIONAL","INFORMATIONAL"]},{"source":"pinned","reader":"fix-check","fixture":"review-ledger/harn15-152-p2-fix-check.txt","main":["CRITICAL","INFORMATIONAL","INFORMATIONAL"]},{"source":"pinned","reader":"fix-check","fixture":"review-ledger/geva-77-fix-check.txt","main":["INFORMATIONAL","INFORMATIONAL","CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"There are no CRITICAL findings.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"No CRITICAL or High findings to report.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"I found nothing CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"So there's nothing at CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Neither finding is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing here is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Neither fix is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Nothing in this diff is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"This is not CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Severity: Low (not High)","main":["High"]},{"source":"pinned","reader":"adversarial-native","fixture":"review-ledger/harn15-ship2-p2-adversarial-native.txt","main":["CRITICAL","CRITICAL","CRITICAL","CRITICAL","CRITICAL","Low"]},{"source":"pinned","reader":"outside-voice","output":"1. **Race on the lock** (a.ts:3)\n - the writer holds no lock\n\n Fix: take it first. Severity: High. Blocks: no.","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"1. Lock race (a.ts:3)\n\n Fix: take it first. Blocks: yes\n2. Cache drift (c.ts:4)","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"1. Unescaped query\n {\"severity\":\"critical\",\"summary\":\"Low-level race in q.ts\"}","main":["critical"]},{"source":"pinned","reader":"red-team","output":"1. Unescaped query\n **Critical**\n2. Token leak in the logger","main":["Critical"]},{"source":"pinned","reader":"red-team","output":"1. Unescaped query\n ## High\n2. Token leak in the logger","main":["High"]},{"source":"pinned","reader":"red-team","output":"1. Race in queue\n- Severity: Low\n this one blocks. Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"red-team","output":"1. Unescaped query\n {\"severity\":\"low\",\"summary\":\"race\"}\n this blocks the merge. Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"The problems below are about accounting and what the post leaves out. None of them breaks a test, production or custody.\n\n1. **The per-task cost leaves out model spend.** The P0 and bulk go or no-go will re-project cost from these figures, so they will come out too low. Severity: Medium. Blocks: no.\n\n2. **One round-2 K1 pair is not accounted for.** The post sets a precedent for P0 yield math, so that drop should be named. Severity: Low. Blocks: no.\n\n3. **The two probe populations are not reconciled.** The stage table probes 189 candidates. Severity: Low. Blocks: no.\n\n4. **The P0 impact of the shortfall is missing.** The plan's P0 (line 323) needs 6 K4 replays and 2 decoys. The post says the shortfall but not that it blocks P0. Severity: Low. Blocks: no.\n\n6. **Some wording is ambiguous.** Two examples:\n - \"Repositories 4 (3 for build tasks)\" is set against a target of \"at least 5 repositories\".\n\n Severity: Low. Blocks: no.\n\nRecommendation: ship after a one-line fix because finding 1's per-task cost leaves out measured model spend.","main":["P0","P0","Low","P0","Low"]},{"source":"pinned","reader":"outside-voice","output":"[P0] Token leak in log.ts:3","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"P0: token leak in log.ts:3","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. Token leak in log.ts:3\n Severity: P0","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot drops the last row (q.ts:7). Severity: P0. Blocks: no","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"**Critical**\n- Token leak in log.ts:3","main":["Critical"]},{"source":"pinned","reader":"outside-voice","output":"## P0\n- Token leak in log.ts:3","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"### CRITICAL: missing auth check in api.ts:10","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot loses rows on retry (r.ts:2). Severity: Low. Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot loses rows on retry (r.ts:2)\n Severity: Medium\n Blocks: yes","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row (t.md:3)","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. api.ts:10 CRITICAL: missing auth. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. api.ts:10 CRITICAL: missing auth\n Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. 🔴 CRITICAL: missing auth. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. Token leak in auth.ts:3 lets anyone read sessions. Severity: Critical\n\nSeverity: Low","main":["Critical"]},{"source":"pinned","reader":"outside-voice","output":"- auth.ts:3 token leak. Severity: Low (pre-exploit), Severity: Critical (once exposed)","main":["Critical"]},{"source":"pinned","reader":"outside-voice","output":"- Missing auth on DELETE /users lets any user wipe accounts (CRITICAL); the old ticket filed it at severity low.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"- api.ts:10 lets any user wipe accounts. Earlier triage said Severity: Low; it is CRITICAL.","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. CRITICAL missing auth. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. CRITICAL, missing auth on DELETE. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. CRITICAL (api.ts:10) missing auth. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row (t.md:3) Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row\nSeverity Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row\n**Severity** Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. Bypass x.ts:3 is CRITICAL (Severity: Low in pass 1)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Still open: x.ts:3 is CRITICAL, Severity: Low in pass 1 understated it","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. lib/x.ts:42-58 CRITICAL: drops rows. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. lib/x.ts:42:7 CRITICAL: drops rows. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. (lib/x.ts:42, 50) CRITICAL: drops rows. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row\nSeverity: Low: only the pilot","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row\nSeverity: Low because it is internal","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot table drops a row\nSeverity: Low/Medium","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"Severity: Low: only the P0 pilot table","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. The P0 pilot drops a row. Severity: Low/P3","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"1. drops rows\nSeverity: High/Critical","main":["Critical"]},{"source":"pinned","reader":"outside-voice","output":"Severity: Medium/CRITICAL","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. x.ts:3 drops prod rows. Severity: Low/CRITICAL","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. x.ts:3 is CRITICAL (Severity: Low/Medium in pass 1)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"1. x.ts:3 is CRITICAL (Severity: Low: pass 1)","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Still open: x.ts:3 is P0, Severity: Low: pass 1 understated it","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"============================================================ P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"============================================================ High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"============================================================ CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"============================================================ a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"------------------------------------------------------------ P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"------------------------------------------------------------ High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"------------------------------------------------------------ CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"------------------------------------------------------------ a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"|------------------------------------------------------------| P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"|------------------------------------------------------------| High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"|------------------------------------------------------------| CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"|------------------------------------------------------------| a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴 P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴 High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴 CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴 a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":". . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":". . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":". . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":". . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-. P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-. High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-. CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-. a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"```````````````````````````````````````````````````````````` P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"```````````````````````````````````````````````````````````` High-risk. Severity: Low","main":["High"]},{"source":"pinned","reader":"outside-voice","output":"```````````````````````````````````````````````````````````` CRITICAL findings follow. Severity: Low","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"```````````````````````````````````````````````````````````` a.ts:3 P0 pilot. Severity: Low","main":["P0"]},{"source":"pinned","reader":"outside-voice","output":"---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- P0 pilot table. Severity: Low","main":["P0"]},{"source":"pinned","reader":"specialist/testing","output":"REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\nI could not access the diff.\n","main":[]},{"source":"pinned","reader":"outside-voice","output":"Codex timed out after 9 minutes and was terminated; this pass produced NO findings.\n","main":[]},{"source":"pinned","reader":"outside-voice","output":"1. **CRITICAL**: token leak in a.ts:3\nCodex then timed out.\n","main":["CRITICAL"]},{"source":"pinned","reader":"specialist/testing","output":"- **REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt**\nNO FINDINGS\n","main":[]},{"source":"pinned","reader":"outside-voice","output":"1. **CRITICAL**: auth bypass in a.ts:3\nBlocks: yes\n\nRecommendation: request changes\n","main":["CRITICAL"]},{"source":"pinned","reader":"outside-voice","output":"Codex timed out before it reviewed anything.\n","main":[]},{"source":"pinned","reader":"outside-voice","fixture":"review-wrong-tree/gpt-6.1-sol-gst850-p1.out","main":["CRITICAL","CRITICAL","CRITICAL","CRITICAL","CRITICAL"]},{"source":"pinned","reader":"outside-voice","fixture":"review-wrong-tree/gpt-6.1-sol-gst850-p2.out","main":["CRITICAL","CRITICAL","CRITICAL"]},{"source":"pinned","reader":"specialist/testing","output":"cd '/home/user/.capy/work/gst976/wt' && echo \"REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)\"\nprinted: REVIEWED_TREE: d0dd42d6a88fbc2a6abca4f6b2dfde7e35520c5a /home/user/.capy/work/gst976/wt\nNO FINDINGS\n","main":[]},{"source":"fixture","reader":"outside-voice","fixture":"review-ledger/main-labels.json","main":["CRITICAL"]},{"source":"fixture","reader":"outside-voice","fixture":"review-ledger/gsta-196-native.txt","main":["High","Low","Low","Low"]},{"source":"fixture","reader":"outside-voice","fixture":"review-ledger/gsta-196-refusal.txt","main":[]}],"shapes":{"bodies":["1. The P0 pilot drops rows (a.ts:3)","1. CRITICAL: drops rows in a.ts:3","- High: the cache leaks (c.ts:4)","**Race in q.ts:7** nothing CRITICAL here","FIXABLE: race in r.ts:2","Note on b.ts:4 for the p1 pilot"],"fields":["Severity: Low","Severity: low","Severity: High/Critical","Severity: Medium/CRITICAL","Severity: Low/Medium","Severity: Low → Medium","Severity: Low: only the pilot","Severity: Low because it is internal","Severity: Low in pass 1, P0 now","(Severity: Low in pass 1)","(Severity: Low/Medium in pass 1)","(Severity: Low: pass 1)","**Severity:** Medium","**Severity** Low","Severity Low","SEVERITY: LOW","Severity: P3","Severity: Low (not High)","Severity: Low. Blocks: no","Severity: Low. Blocks: yes"],"joins":[". ","\n","\n "," — ",", "," "],"readers":["outside-voice","adversarial-native"],"generated":[[0,0,0,0,["P0"]],[0,0,0,1,["P0"]],[0,0,1,0,["P0"]],[0,0,1,1,["P0"]],[0,0,2,0,["P0"]],[0,0,2,1,["P0"]],[0,0,3,0,["P0"]],[0,0,3,1,["P0"]],[0,0,4,0,["P0"]],[0,0,4,1,["P0"]],[0,0,5,0,["P0"]],[0,0,5,1,["P0"]],[0,1,0,0,["P0"]],[0,1,0,1,["P0"]],[0,1,1,0,["P0"]],[0,1,1,1,["P0"]],[0,1,2,0,["P0"]],[0,1,2,1,["P0"]],[0,1,3,0,["P0"]],[0,1,3,1,["P0"]],[0,1,4,0,["P0"]],[0,1,4,1,["P0"]],[0,1,5,0,["P0"]],[0,1,5,1,["P0"]],[0,2,0,0,["P0"]],[0,2,0,1,["P0"]],[0,2,1,0,["P0"]],[0,2,1,1,["P0"]],[0,2,2,0,["P0"]],[0,2,2,1,["P0"]],[0,2,3,0,["P0"]],[0,2,3,1,["P0"]],[0,2,4,0,["P0"]],[0,2,4,1,["P0"]],[0,2,5,0,["P0"]],[0,2,5,1,["P0"]],[0,3,0,0,["P0"]],[0,3,0,1,["P0"]],[0,3,1,0,["P0"]],[0,3,1,1,["P0"]],[0,3,2,0,["P0"]],[0,3,2,1,["P0"]],[0,3,3,0,["P0"]],[0,3,3,1,["P0"]],[0,3,4,0,["P0"]],[0,3,4,1,["P0"]],[0,3,5,0,["P0"]],[0,3,5,1,["P0"]],[0,4,0,0,["P0"]],[0,4,0,1,["P0"]],[0,4,1,0,["P0"]],[0,4,1,1,["P0"]],[0,4,2,0,["P0"]],[0,4,2,1,["P0"]],[0,4,3,0,["P0"]],[0,4,3,1,["P0"]],[0,4,4,0,["P0"]],[0,4,4,1,["P0"]],[0,4,5,0,["P0"]],[0,4,5,1,["P0"]],[0,5,0,0,["P0"]],[0,5,0,1,["P0"]],[0,5,1,0,["P0"]],[0,5,1,1,["P0"]],[0,5,2,0,["P0"]],[0,5,2,1,["P0"]],[0,5,3,0,["P0"]],[0,5,3,1,["P0"]],[0,5,4,0,["P0"]],[0,5,4,1,["P0"]],[0,5,5,0,["P0"]],[0,5,5,1,["P0"]],[0,6,0,0,["P0"]],[0,6,0,1,["P0"]],[0,6,1,0,["P0"]],[0,6,1,1,["P0"]],[0,6,2,0,["P0"]],[0,6,2,1,["P0"]],[0,6,3,0,["P0"]],[0,6,3,1,["P0"]],[0,6,4,0,["P0"]],[0,6,4,1,["P0"]],[0,6,5,0,["P0"]],[0,6,5,1,["P0"]],[0,7,0,0,["P0"]],[0,7,0,1,["P0"]],[0,7,1,0,["P0"]],[0,7,1,1,["P0"]],[0,7,2,0,["P0"]],[0,7,2,1,["P0"]],[0,7,3,0,["P0"]],[0,7,3,1,["P0"]],[0,7,4,0,["P0"]],[0,7,4,1,["P0"]],[0,7,5,0,["P0"]],[0,7,5,1,["P0"]],[0,8,0,0,["P0"]],[0,8,0,1,["P0"]],[0,8,1,0,["P0"]],[0,8,1,1,["P0"]],[0,8,2,0,["P0"]],[0,8,2,1,["P0"]],[0,8,3,0,["P0"]],[0,8,3,1,["P0"]],[0,8,4,0,["P0"]],[0,8,4,1,["P0"]],[0,8,5,0,["P0"]],[0,8,5,1,["P0"]],[0,9,0,0,["P0"]],[0,9,0,1,["P0"]],[0,9,1,0,["P0","Low"]],[0,9,1,1,["P0","Low"]],[0,9,2,0,["P0","Low"]],[0,9,2,1,["P0","Low"]],[0,9,3,0,["P0"]],[0,9,3,1,["P0"]],[0,9,4,0,["P0"]],[0,9,4,1,["P0"]],[0,9,5,0,["P0"]],[0,9,5,1,["P0"]],[0,10,0,0,["P0"]],[0,10,0,1,["P0"]],[0,10,1,0,["P0","Medium"]],[0,10,1,1,["P0","Medium"]],[0,10,2,0,["P0","Medium"]],[0,10,2,1,["P0","Medium"]],[0,10,3,0,["P0"]],[0,10,3,1,["P0"]],[0,10,4,0,["P0"]],[0,10,4,1,["P0"]],[0,10,5,0,["P0"]],[0,10,5,1,["P0"]],[0,11,0,0,["P0"]],[0,11,0,1,["P0"]],[0,11,1,0,["P0","Low"]],[0,11,1,1,["P0","Low"]],[0,11,2,0,["P0","Low"]],[0,11,2,1,["P0","Low"]],[0,11,3,0,["P0"]],[0,11,3,1,["P0"]],[0,11,4,0,["P0"]],[0,11,4,1,["P0"]],[0,11,5,0,["P0"]],[0,11,5,1,["P0"]],[0,12,0,0,["P0"]],[0,12,0,1,["P0"]],[0,12,1,0,["P0"]],[0,12,1,1,["P0"]],[0,12,2,0,["P0"]],[0,12,2,1,["P0"]],[0,12,3,0,["P0"]],[0,12,3,1,["P0"]],[0,12,4,0,["P0"]],[0,12,4,1,["P0"]],[0,12,5,0,["P0"]],[0,12,5,1,["P0"]],[0,13,0,0,["P0"]],[0,13,0,1,["P0"]],[0,13,1,0,["P0"]],[0,13,1,1,["P0"]],[0,13,2,0,["P0"]],[0,13,2,1,["P0"]],[0,13,3,0,["P0"]],[0,13,3,1,["P0"]],[0,13,4,0,["P0"]],[0,13,4,1,["P0"]],[0,13,5,0,["P0"]],[0,13,5,1,["P0"]],[0,14,0,0,["P0"]],[0,14,0,1,["P0"]],[0,14,1,0,["P0"]],[0,14,1,1,["P0"]],[0,14,2,0,["P0"]],[0,14,2,1,["P0"]],[0,14,3,0,["P0"]],[0,14,3,1,["P0"]],[0,14,4,0,["P0"]],[0,14,4,1,["P0"]],[0,14,5,0,["P0"]],[0,14,5,1,["P0"]],[0,15,0,0,["P0"]],[0,15,0,1,["P0"]],[0,15,1,0,["P0"]],[0,15,1,1,["P0"]],[0,15,2,0,["P0"]],[0,15,2,1,["P0"]],[0,15,3,0,["P0"]],[0,15,3,1,["P0"]],[0,15,4,0,["P0"]],[0,15,4,1,["P0"]],[0,15,5,0,["P0"]],[0,15,5,1,["P0"]],[0,16,0,0,["P0"]],[0,16,0,1,["P0"]],[0,16,1,0,["P0"]],[0,16,1,1,["P0"]],[0,16,2,0,["P0"]],[0,16,2,1,["P0"]],[0,16,3,0,["P0"]],[0,16,3,1,["P0"]],[0,16,4,0,["P0"]],[0,16,4,1,["P0"]],[0,16,5,0,["P0"]],[0,16,5,1,["P0"]],[0,17,0,0,["P0"]],[0,17,0,1,["P0"]],[0,17,1,0,["P0"]],[0,17,1,1,["P0"]],[0,17,2,0,["P0"]],[0,17,2,1,["P0"]],[0,17,3,0,["P0"]],[0,17,3,1,["P0"]],[0,17,4,0,["P0"]],[0,17,4,1,["P0"]],[0,17,5,0,["P0"]],[0,17,5,1,["P0"]],[0,18,0,0,["P0"]],[0,18,0,1,["P0"]],[0,18,1,0,["P0"]],[0,18,1,1,["P0"]],[0,18,2,0,["P0"]],[0,18,2,1,["P0"]],[0,18,3,0,["P0"]],[0,18,3,1,["P0"]],[0,18,4,0,["P0"]],[0,18,4,1,["P0"]],[0,18,5,0,["P0"]],[0,18,5,1,["P0"]],[0,19,0,0,["CRITICAL"]],[0,19,0,1,["CRITICAL"]],[0,19,1,0,["CRITICAL"]],[0,19,1,1,["CRITICAL"]],[0,19,2,0,["CRITICAL"]],[0,19,2,1,["CRITICAL"]],[0,19,3,0,["CRITICAL"]],[0,19,3,1,["CRITICAL"]],[0,19,4,0,["CRITICAL"]],[0,19,4,1,["CRITICAL"]],[0,19,5,0,["CRITICAL"]],[0,19,5,1,["CRITICAL"]],[1,0,0,0,["CRITICAL"]],[1,0,0,1,["CRITICAL"]],[1,0,1,0,["CRITICAL"]],[1,0,1,1,["CRITICAL"]],[1,0,2,0,["CRITICAL"]],[1,0,2,1,["CRITICAL"]],[1,0,3,0,["CRITICAL"]],[1,0,3,1,["CRITICAL"]],[1,0,4,0,["CRITICAL"]],[1,0,4,1,["CRITICAL"]],[1,0,5,0,["CRITICAL"]],[1,0,5,1,["CRITICAL"]],[1,1,0,0,["CRITICAL"]],[1,1,0,1,["CRITICAL"]],[1,1,1,0,["CRITICAL"]],[1,1,1,1,["CRITICAL"]],[1,1,2,0,["CRITICAL"]],[1,1,2,1,["CRITICAL"]],[1,1,3,0,["CRITICAL"]],[1,1,3,1,["CRITICAL"]],[1,1,4,0,["CRITICAL"]],[1,1,4,1,["CRITICAL"]],[1,1,5,0,["CRITICAL"]],[1,1,5,1,["CRITICAL"]],[1,2,0,0,["CRITICAL"]],[1,2,0,1,["CRITICAL"]],[1,2,1,0,["CRITICAL"]],[1,2,1,1,["CRITICAL"]],[1,2,2,0,["CRITICAL"]],[1,2,2,1,["CRITICAL"]],[1,2,3,0,["CRITICAL"]],[1,2,3,1,["CRITICAL"]],[1,2,4,0,["CRITICAL"]],[1,2,4,1,["CRITICAL"]],[1,2,5,0,["CRITICAL"]],[1,2,5,1,["CRITICAL"]],[1,3,0,0,["CRITICAL"]],[1,3,0,1,["CRITICAL"]],[1,3,1,0,["CRITICAL"]],[1,3,1,1,["CRITICAL"]],[1,3,2,0,["CRITICAL"]],[1,3,2,1,["CRITICAL"]],[1,3,3,0,["CRITICAL"]],[1,3,3,1,["CRITICAL"]],[1,3,4,0,["CRITICAL"]],[1,3,4,1,["CRITICAL"]],[1,3,5,0,["CRITICAL"]],[1,3,5,1,["CRITICAL"]],[1,4,0,0,["CRITICAL"]],[1,4,0,1,["CRITICAL"]],[1,4,1,0,["CRITICAL"]],[1,4,1,1,["CRITICAL"]],[1,4,2,0,["CRITICAL"]],[1,4,2,1,["CRITICAL"]],[1,4,3,0,["CRITICAL"]],[1,4,3,1,["CRITICAL"]],[1,4,4,0,["CRITICAL"]],[1,4,4,1,["CRITICAL"]],[1,4,5,0,["CRITICAL"]],[1,4,5,1,["CRITICAL"]],[1,5,0,0,["CRITICAL"]],[1,5,0,1,["CRITICAL"]],[1,5,1,0,["CRITICAL"]],[1,5,1,1,["CRITICAL"]],[1,5,2,0,["CRITICAL"]],[1,5,2,1,["CRITICAL"]],[1,5,3,0,["CRITICAL"]],[1,5,3,1,["CRITICAL"]],[1,5,4,0,["CRITICAL"]],[1,5,4,1,["CRITICAL"]],[1,5,5,0,["CRITICAL"]],[1,5,5,1,["CRITICAL"]],[1,6,0,0,["CRITICAL"]],[1,6,0,1,["CRITICAL"]],[1,6,1,0,["CRITICAL"]],[1,6,1,1,["CRITICAL"]],[1,6,2,0,["CRITICAL"]],[1,6,2,1,["CRITICAL"]],[1,6,3,0,["CRITICAL"]],[1,6,3,1,["CRITICAL"]],[1,6,4,0,["CRITICAL"]],[1,6,4,1,["CRITICAL"]],[1,6,5,0,["CRITICAL"]],[1,6,5,1,["CRITICAL"]],[1,7,0,0,["CRITICAL"]],[1,7,0,1,["CRITICAL"]],[1,7,1,0,["CRITICAL"]],[1,7,1,1,["CRITICAL"]],[1,7,2,0,["CRITICAL"]],[1,7,2,1,["CRITICAL"]],[1,7,3,0,["CRITICAL"]],[1,7,3,1,["CRITICAL"]],[1,7,4,0,["CRITICAL"]],[1,7,4,1,["CRITICAL"]],[1,7,5,0,["CRITICAL"]],[1,7,5,1,["CRITICAL"]],[1,8,0,0,["CRITICAL"]],[1,8,0,1,["CRITICAL"]],[1,8,1,0,["CRITICAL"]],[1,8,1,1,["CRITICAL"]],[1,8,2,0,["CRITICAL"]],[1,8,2,1,["CRITICAL"]],[1,8,3,0,["CRITICAL"]],[1,8,3,1,["CRITICAL"]],[1,8,4,0,["CRITICAL"]],[1,8,4,1,["CRITICAL"]],[1,8,5,0,["CRITICAL"]],[1,8,5,1,["CRITICAL"]],[1,9,0,0,["CRITICAL"]],[1,9,0,1,["CRITICAL"]],[1,9,1,0,["CRITICAL","Low"]],[1,9,1,1,["CRITICAL","Low"]],[1,9,2,0,["CRITICAL","Low"]],[1,9,2,1,["CRITICAL","Low"]],[1,9,3,0,["CRITICAL"]],[1,9,3,1,["CRITICAL"]],[1,9,4,0,["CRITICAL"]],[1,9,4,1,["CRITICAL"]],[1,9,5,0,["CRITICAL"]],[1,9,5,1,["CRITICAL"]],[1,10,0,0,["CRITICAL"]],[1,10,0,1,["CRITICAL"]],[1,10,1,0,["CRITICAL","Medium"]],[1,10,1,1,["CRITICAL","Medium"]],[1,10,2,0,["CRITICAL","Medium"]],[1,10,2,1,["CRITICAL","Medium"]],[1,10,3,0,["CRITICAL"]],[1,10,3,1,["CRITICAL"]],[1,10,4,0,["CRITICAL"]],[1,10,4,1,["CRITICAL"]],[1,10,5,0,["CRITICAL"]],[1,10,5,1,["CRITICAL"]],[1,11,0,0,["CRITICAL"]],[1,11,0,1,["CRITICAL"]],[1,11,1,0,["CRITICAL","Low"]],[1,11,1,1,["CRITICAL","Low"]],[1,11,2,0,["CRITICAL","Low"]],[1,11,2,1,["CRITICAL","Low"]],[1,11,3,0,["CRITICAL"]],[1,11,3,1,["CRITICAL"]],[1,11,4,0,["CRITICAL"]],[1,11,4,1,["CRITICAL"]],[1,11,5,0,["CRITICAL"]],[1,11,5,1,["CRITICAL"]],[1,12,0,0,["CRITICAL"]],[1,12,0,1,["CRITICAL"]],[1,12,1,0,["CRITICAL"]],[1,12,1,1,["CRITICAL"]],[1,12,2,0,["CRITICAL"]],[1,12,2,1,["CRITICAL"]],[1,12,3,0,["CRITICAL"]],[1,12,3,1,["CRITICAL"]],[1,12,4,0,["CRITICAL"]],[1,12,4,1,["CRITICAL"]],[1,12,5,0,["CRITICAL"]],[1,12,5,1,["CRITICAL"]],[1,13,0,0,["CRITICAL"]],[1,13,0,1,["CRITICAL"]],[1,13,1,0,["CRITICAL"]],[1,13,1,1,["CRITICAL"]],[1,13,2,0,["CRITICAL"]],[1,13,2,1,["CRITICAL"]],[1,13,3,0,["CRITICAL"]],[1,13,3,1,["CRITICAL"]],[1,13,4,0,["CRITICAL"]],[1,13,4,1,["CRITICAL"]],[1,13,5,0,["CRITICAL"]],[1,13,5,1,["CRITICAL"]],[1,14,0,0,["CRITICAL"]],[1,14,0,1,["CRITICAL"]],[1,14,1,0,["CRITICAL"]],[1,14,1,1,["CRITICAL"]],[1,14,2,0,["CRITICAL"]],[1,14,2,1,["CRITICAL"]],[1,14,3,0,["CRITICAL"]],[1,14,3,1,["CRITICAL"]],[1,14,4,0,["CRITICAL"]],[1,14,4,1,["CRITICAL"]],[1,14,5,0,["CRITICAL"]],[1,14,5,1,["CRITICAL"]],[1,15,0,0,["CRITICAL"]],[1,15,0,1,["CRITICAL"]],[1,15,1,0,["CRITICAL"]],[1,15,1,1,["CRITICAL"]],[1,15,2,0,["CRITICAL"]],[1,15,2,1,["CRITICAL"]],[1,15,3,0,["CRITICAL"]],[1,15,3,1,["CRITICAL"]],[1,15,4,0,["CRITICAL"]],[1,15,4,1,["CRITICAL"]],[1,15,5,0,["CRITICAL"]],[1,15,5,1,["CRITICAL"]],[1,16,0,0,["CRITICAL"]],[1,16,0,1,["CRITICAL"]],[1,16,1,0,["CRITICAL"]],[1,16,1,1,["CRITICAL"]],[1,16,2,0,["CRITICAL"]],[1,16,2,1,["CRITICAL"]],[1,16,3,0,["CRITICAL"]],[1,16,3,1,["CRITICAL"]],[1,16,4,0,["CRITICAL"]],[1,16,4,1,["CRITICAL"]],[1,16,5,0,["CRITICAL"]],[1,16,5,1,["CRITICAL"]],[1,17,0,0,["CRITICAL"]],[1,17,0,1,["CRITICAL"]],[1,17,1,0,["CRITICAL"]],[1,17,1,1,["CRITICAL"]],[1,17,2,0,["CRITICAL"]],[1,17,2,1,["CRITICAL"]],[1,17,3,0,["CRITICAL"]],[1,17,3,1,["CRITICAL"]],[1,17,4,0,["CRITICAL"]],[1,17,4,1,["CRITICAL"]],[1,17,5,0,["CRITICAL"]],[1,17,5,1,["CRITICAL"]],[1,18,0,0,["CRITICAL"]],[1,18,0,1,["CRITICAL"]],[1,18,1,0,["CRITICAL"]],[1,18,1,1,["CRITICAL"]],[1,18,2,0,["CRITICAL"]],[1,18,2,1,["CRITICAL"]],[1,18,3,0,["CRITICAL"]],[1,18,3,1,["CRITICAL"]],[1,18,4,0,["CRITICAL"]],[1,18,4,1,["CRITICAL"]],[1,18,5,0,["CRITICAL"]],[1,18,5,1,["CRITICAL"]],[1,19,0,0,["CRITICAL"]],[1,19,0,1,["CRITICAL"]],[1,19,1,0,["CRITICAL"]],[1,19,1,1,["CRITICAL"]],[1,19,2,0,["CRITICAL"]],[1,19,2,1,["CRITICAL"]],[1,19,3,0,["CRITICAL"]],[1,19,3,1,["CRITICAL"]],[1,19,4,0,["CRITICAL"]],[1,19,4,1,["CRITICAL"]],[1,19,5,0,["CRITICAL"]],[1,19,5,1,["CRITICAL"]],[2,0,0,0,["High"]],[2,0,0,1,["High"]],[2,0,1,0,["High"]],[2,0,1,1,["High"]],[2,0,2,0,["High"]],[2,0,2,1,["High"]],[2,0,3,0,["High"]],[2,0,3,1,["High"]],[2,0,4,0,["High"]],[2,0,4,1,["High"]],[2,0,5,0,["High"]],[2,0,5,1,["High"]],[2,1,0,0,["High"]],[2,1,0,1,["High"]],[2,1,1,0,["High"]],[2,1,1,1,["High"]],[2,1,2,0,["High"]],[2,1,2,1,["High"]],[2,1,3,0,["High"]],[2,1,3,1,["High"]],[2,1,4,0,["High"]],[2,1,4,1,["High"]],[2,1,5,0,["High"]],[2,1,5,1,["High"]],[2,2,0,0,["Critical"]],[2,2,0,1,["Critical"]],[2,2,1,0,["Critical"]],[2,2,1,1,["Critical"]],[2,2,2,0,["Critical"]],[2,2,2,1,["Critical"]],[2,2,3,0,["Critical"]],[2,2,3,1,["Critical"]],[2,2,4,0,["Critical"]],[2,2,4,1,["Critical"]],[2,2,5,0,["Critical"]],[2,2,5,1,["Critical"]],[2,3,0,0,["CRITICAL"]],[2,3,0,1,["CRITICAL"]],[2,3,1,0,["CRITICAL"]],[2,3,1,1,["CRITICAL"]],[2,3,2,0,["CRITICAL"]],[2,3,2,1,["CRITICAL"]],[2,3,3,0,["CRITICAL"]],[2,3,3,1,["CRITICAL"]],[2,3,4,0,["CRITICAL"]],[2,3,4,1,["CRITICAL"]],[2,3,5,0,["CRITICAL"]],[2,3,5,1,["CRITICAL"]],[2,4,0,0,["High"]],[2,4,0,1,["High"]],[2,4,1,0,["High"]],[2,4,1,1,["High"]],[2,4,2,0,["High"]],[2,4,2,1,["High"]],[2,4,3,0,["High"]],[2,4,3,1,["High"]],[2,4,4,0,["High"]],[2,4,4,1,["High"]],[2,4,5,0,["High"]],[2,4,5,1,["High"]],[2,5,0,0,["High"]],[2,5,0,1,["High"]],[2,5,1,0,["High"]],[2,5,1,1,["High"]],[2,5,2,0,["High"]],[2,5,2,1,["High"]],[2,5,3,0,["High"]],[2,5,3,1,["High"]],[2,5,4,0,["High"]],[2,5,4,1,["High"]],[2,5,5,0,["High"]],[2,5,5,1,["High"]],[2,6,0,0,["High"]],[2,6,0,1,["High"]],[2,6,1,0,["High"]],[2,6,1,1,["High"]],[2,6,2,0,["High"]],[2,6,2,1,["High"]],[2,6,3,0,["High"]],[2,6,3,1,["High"]],[2,6,4,0,["High"]],[2,6,4,1,["High"]],[2,6,5,0,["High"]],[2,6,5,1,["High"]],[2,7,0,0,["High"]],[2,7,0,1,["High"]],[2,7,1,0,["High"]],[2,7,1,1,["High"]],[2,7,2,0,["High"]],[2,7,2,1,["High"]],[2,7,3,0,["High"]],[2,7,3,1,["High"]],[2,7,4,0,["High"]],[2,7,4,1,["High"]],[2,7,5,0,["High"]],[2,7,5,1,["High"]],[2,8,0,0,["P0"]],[2,8,0,1,["P0"]],[2,8,1,0,["P0"]],[2,8,1,1,["P0"]],[2,8,2,0,["P0"]],[2,8,2,1,["P0"]],[2,8,3,0,["P0"]],[2,8,3,1,["P0"]],[2,8,4,0,["P0"]],[2,8,4,1,["P0"]],[2,8,5,0,["P0"]],[2,8,5,1,["P0"]],[2,9,0,0,["High"]],[2,9,0,1,["High"]],[2,9,1,0,["High","Low"]],[2,9,1,1,["High","Low"]],[2,9,2,0,["High","Low"]],[2,9,2,1,["High","Low"]],[2,9,3,0,["High"]],[2,9,3,1,["High"]],[2,9,4,0,["High"]],[2,9,4,1,["High"]],[2,9,5,0,["High"]],[2,9,5,1,["High"]],[2,10,0,0,["High"]],[2,10,0,1,["High"]],[2,10,1,0,["High","Medium"]],[2,10,1,1,["High","Medium"]],[2,10,2,0,["High","Medium"]],[2,10,2,1,["High","Medium"]],[2,10,3,0,["High"]],[2,10,3,1,["High"]],[2,10,4,0,["High"]],[2,10,4,1,["High"]],[2,10,5,0,["High"]],[2,10,5,1,["High"]],[2,11,0,0,["High"]],[2,11,0,1,["High"]],[2,11,1,0,["High","Low"]],[2,11,1,1,["High","Low"]],[2,11,2,0,["High","Low"]],[2,11,2,1,["High","Low"]],[2,11,3,0,["High"]],[2,11,3,1,["High"]],[2,11,4,0,["High"]],[2,11,4,1,["High"]],[2,11,5,0,["High"]],[2,11,5,1,["High"]],[2,12,0,0,["High"]],[2,12,0,1,["High"]],[2,12,1,0,["High"]],[2,12,1,1,["High"]],[2,12,2,0,["High"]],[2,12,2,1,["High"]],[2,12,3,0,["High"]],[2,12,3,1,["High"]],[2,12,4,0,["High"]],[2,12,4,1,["High"]],[2,12,5,0,["High"]],[2,12,5,1,["High"]],[2,13,0,0,["High"]],[2,13,0,1,["High"]],[2,13,1,0,["High"]],[2,13,1,1,["High"]],[2,13,2,0,["High"]],[2,13,2,1,["High"]],[2,13,3,0,["High"]],[2,13,3,1,["High"]],[2,13,4,0,["High"]],[2,13,4,1,["High"]],[2,13,5,0,["High"]],[2,13,5,1,["High"]],[2,14,0,0,["High"]],[2,14,0,1,["High"]],[2,14,1,0,["High"]],[2,14,1,1,["High"]],[2,14,2,0,["High"]],[2,14,2,1,["High"]],[2,14,3,0,["High"]],[2,14,3,1,["High"]],[2,14,4,0,["High"]],[2,14,4,1,["High"]],[2,14,5,0,["High"]],[2,14,5,1,["High"]],[2,15,0,0,["High"]],[2,15,0,1,["High"]],[2,15,1,0,["High"]],[2,15,1,1,["High"]],[2,15,2,0,["High"]],[2,15,2,1,["High"]],[2,15,3,0,["High"]],[2,15,3,1,["High"]],[2,15,4,0,["High"]],[2,15,4,1,["High"]],[2,15,5,0,["High"]],[2,15,5,1,["High"]],[2,16,0,0,["High"]],[2,16,0,1,["High"]],[2,16,1,0,["High"]],[2,16,1,1,["High"]],[2,16,2,0,["High"]],[2,16,2,1,["High"]],[2,16,3,0,["High"]],[2,16,3,1,["High"]],[2,16,4,0,["High"]],[2,16,4,1,["High"]],[2,16,5,0,["High"]],[2,16,5,1,["High"]],[2,17,0,0,["High"]],[2,17,0,1,["High"]],[2,17,1,0,["High"]],[2,17,1,1,["High"]],[2,17,2,0,["High"]],[2,17,2,1,["High"]],[2,17,3,0,["High"]],[2,17,3,1,["High"]],[2,17,4,0,["High"]],[2,17,4,1,["High"]],[2,17,5,0,["High"]],[2,17,5,1,["High"]],[2,18,0,0,["High"]],[2,18,0,1,["High"]],[2,18,1,0,["High"]],[2,18,1,1,["High"]],[2,18,2,0,["High"]],[2,18,2,1,["High"]],[2,18,3,0,["High"]],[2,18,3,1,["High"]],[2,18,4,0,["High"]],[2,18,4,1,["High"]],[2,18,5,0,["High"]],[2,18,5,1,["High"]],[2,19,0,0,["CRITICAL"]],[2,19,0,1,["CRITICAL"]],[2,19,1,0,["CRITICAL"]],[2,19,1,1,["CRITICAL"]],[2,19,2,0,["CRITICAL"]],[2,19,2,1,["CRITICAL"]],[2,19,3,0,["CRITICAL"]],[2,19,3,1,["CRITICAL"]],[2,19,4,0,["CRITICAL"]],[2,19,4,1,["CRITICAL"]],[2,19,5,0,["CRITICAL"]],[2,19,5,1,["CRITICAL"]],[3,0,0,0,["CRITICAL"]],[3,0,0,1,["CRITICAL"]],[3,0,1,0,["CRITICAL"]],[3,0,1,1,["CRITICAL"]],[3,0,2,0,["CRITICAL"]],[3,0,2,1,["CRITICAL"]],[3,0,3,0,["CRITICAL"]],[3,0,3,1,["CRITICAL"]],[3,0,4,0,["CRITICAL"]],[3,0,4,1,["CRITICAL"]],[3,0,5,0,["CRITICAL"]],[3,0,5,1,["CRITICAL"]],[3,1,0,0,["CRITICAL"]],[3,1,0,1,["CRITICAL"]],[3,1,1,0,["CRITICAL"]],[3,1,1,1,["CRITICAL"]],[3,1,2,0,["CRITICAL"]],[3,1,2,1,["CRITICAL"]],[3,1,3,0,["CRITICAL"]],[3,1,3,1,["CRITICAL"]],[3,1,4,0,["CRITICAL"]],[3,1,4,1,["CRITICAL"]],[3,1,5,0,["CRITICAL"]],[3,1,5,1,["CRITICAL"]],[3,2,0,0,["CRITICAL"]],[3,2,0,1,["CRITICAL"]],[3,2,1,0,["CRITICAL"]],[3,2,1,1,["CRITICAL"]],[3,2,2,0,["CRITICAL"]],[3,2,2,1,["CRITICAL"]],[3,2,3,0,["CRITICAL"]],[3,2,3,1,["CRITICAL"]],[3,2,4,0,["CRITICAL"]],[3,2,4,1,["CRITICAL"]],[3,2,5,0,["CRITICAL"]],[3,2,5,1,["CRITICAL"]],[3,3,0,0,["CRITICAL"]],[3,3,0,1,["CRITICAL"]],[3,3,1,0,["CRITICAL"]],[3,3,1,1,["CRITICAL"]],[3,3,2,0,["CRITICAL"]],[3,3,2,1,["CRITICAL"]],[3,3,3,0,["CRITICAL"]],[3,3,3,1,["CRITICAL"]],[3,3,4,0,["CRITICAL"]],[3,3,4,1,["CRITICAL"]],[3,3,5,0,["CRITICAL"]],[3,3,5,1,["CRITICAL"]],[3,4,0,0,["CRITICAL"]],[3,4,0,1,["CRITICAL"]],[3,4,1,0,["CRITICAL"]],[3,4,1,1,["CRITICAL"]],[3,4,2,0,["CRITICAL"]],[3,4,2,1,["CRITICAL"]],[3,4,3,0,["CRITICAL"]],[3,4,3,1,["CRITICAL"]],[3,4,4,0,["CRITICAL"]],[3,4,4,1,["CRITICAL"]],[3,4,5,0,["CRITICAL"]],[3,4,5,1,["CRITICAL"]],[3,5,0,0,["CRITICAL"]],[3,5,0,1,["CRITICAL"]],[3,5,1,0,["CRITICAL"]],[3,5,1,1,["CRITICAL"]],[3,5,2,0,["CRITICAL"]],[3,5,2,1,["CRITICAL"]],[3,5,3,0,["CRITICAL"]],[3,5,3,1,["CRITICAL"]],[3,5,4,0,["CRITICAL"]],[3,5,4,1,["CRITICAL"]],[3,5,5,0,["CRITICAL"]],[3,5,5,1,["CRITICAL"]],[3,6,0,0,["CRITICAL"]],[3,6,0,1,["CRITICAL"]],[3,6,1,0,["CRITICAL"]],[3,6,1,1,["CRITICAL"]],[3,6,2,0,["CRITICAL"]],[3,6,2,1,["CRITICAL"]],[3,6,3,0,["CRITICAL"]],[3,6,3,1,["CRITICAL"]],[3,6,4,0,["CRITICAL"]],[3,6,4,1,["CRITICAL"]],[3,6,5,0,["CRITICAL"]],[3,6,5,1,["CRITICAL"]],[3,7,0,0,["CRITICAL"]],[3,7,0,1,["CRITICAL"]],[3,7,1,0,["CRITICAL"]],[3,7,1,1,["CRITICAL"]],[3,7,2,0,["CRITICAL"]],[3,7,2,1,["CRITICAL"]],[3,7,3,0,["CRITICAL"]],[3,7,3,1,["CRITICAL"]],[3,7,4,0,["CRITICAL"]],[3,7,4,1,["CRITICAL"]],[3,7,5,0,["CRITICAL"]],[3,7,5,1,["CRITICAL"]],[3,8,0,0,["CRITICAL"]],[3,8,0,1,["CRITICAL"]],[3,8,1,0,["CRITICAL"]],[3,8,1,1,["CRITICAL"]],[3,8,2,0,["CRITICAL"]],[3,8,2,1,["CRITICAL"]],[3,8,3,0,["CRITICAL"]],[3,8,3,1,["CRITICAL"]],[3,8,4,0,["CRITICAL"]],[3,8,4,1,["CRITICAL"]],[3,8,5,0,["CRITICAL"]],[3,8,5,1,["CRITICAL"]],[3,9,0,0,["CRITICAL"]],[3,9,0,1,["CRITICAL"]],[3,9,1,0,["CRITICAL","Low"]],[3,9,1,1,["CRITICAL","Low"]],[3,9,2,0,["CRITICAL","Low"]],[3,9,2,1,["CRITICAL","Low"]],[3,9,3,0,["CRITICAL"]],[3,9,3,1,["CRITICAL"]],[3,9,4,0,["CRITICAL"]],[3,9,4,1,["CRITICAL"]],[3,9,5,0,["CRITICAL"]],[3,9,5,1,["CRITICAL"]],[3,10,0,0,["CRITICAL"]],[3,10,0,1,["CRITICAL"]],[3,10,1,0,["CRITICAL","Medium"]],[3,10,1,1,["CRITICAL","Medium"]],[3,10,2,0,["CRITICAL","Medium"]],[3,10,2,1,["CRITICAL","Medium"]],[3,10,3,0,["CRITICAL"]],[3,10,3,1,["CRITICAL"]],[3,10,4,0,["CRITICAL"]],[3,10,4,1,["CRITICAL"]],[3,10,5,0,["CRITICAL"]],[3,10,5,1,["CRITICAL"]],[3,11,0,0,["CRITICAL"]],[3,11,0,1,["CRITICAL"]],[3,11,1,0,["CRITICAL","Low"]],[3,11,1,1,["CRITICAL","Low"]],[3,11,2,0,["CRITICAL","Low"]],[3,11,2,1,["CRITICAL","Low"]],[3,11,3,0,["CRITICAL"]],[3,11,3,1,["CRITICAL"]],[3,11,4,0,["CRITICAL"]],[3,11,4,1,["CRITICAL"]],[3,11,5,0,["CRITICAL"]],[3,11,5,1,["CRITICAL"]],[3,12,0,0,["CRITICAL"]],[3,12,0,1,["CRITICAL"]],[3,12,1,0,["CRITICAL"]],[3,12,1,1,["CRITICAL"]],[3,12,2,0,["CRITICAL"]],[3,12,2,1,["CRITICAL"]],[3,12,3,0,["CRITICAL"]],[3,12,3,1,["CRITICAL"]],[3,12,4,0,["CRITICAL"]],[3,12,4,1,["CRITICAL"]],[3,12,5,0,["CRITICAL"]],[3,12,5,1,["CRITICAL"]],[3,13,0,0,["CRITICAL"]],[3,13,0,1,["CRITICAL"]],[3,13,1,0,["CRITICAL"]],[3,13,1,1,["CRITICAL"]],[3,13,2,0,["CRITICAL"]],[3,13,2,1,["CRITICAL"]],[3,13,3,0,["CRITICAL"]],[3,13,3,1,["CRITICAL"]],[3,13,4,0,["CRITICAL"]],[3,13,4,1,["CRITICAL"]],[3,13,5,0,["CRITICAL"]],[3,13,5,1,["CRITICAL"]],[3,14,0,0,["CRITICAL"]],[3,14,0,1,["CRITICAL"]],[3,14,1,0,["CRITICAL"]],[3,14,1,1,["CRITICAL"]],[3,14,2,0,["CRITICAL"]],[3,14,2,1,["CRITICAL"]],[3,14,3,0,["CRITICAL"]],[3,14,3,1,["CRITICAL"]],[3,14,4,0,["CRITICAL"]],[3,14,4,1,["CRITICAL"]],[3,14,5,0,["CRITICAL"]],[3,14,5,1,["CRITICAL"]],[3,15,0,0,["CRITICAL"]],[3,15,0,1,["CRITICAL"]],[3,15,1,0,["CRITICAL"]],[3,15,1,1,["CRITICAL"]],[3,15,2,0,["CRITICAL"]],[3,15,2,1,["CRITICAL"]],[3,15,3,0,["CRITICAL"]],[3,15,3,1,["CRITICAL"]],[3,15,4,0,["CRITICAL"]],[3,15,4,1,["CRITICAL"]],[3,15,5,0,["CRITICAL"]],[3,15,5,1,["CRITICAL"]],[3,16,0,0,["CRITICAL"]],[3,16,0,1,["CRITICAL"]],[3,16,1,0,["CRITICAL"]],[3,16,1,1,["CRITICAL"]],[3,16,2,0,["CRITICAL"]],[3,16,2,1,["CRITICAL"]],[3,16,3,0,["CRITICAL"]],[3,16,3,1,["CRITICAL"]],[3,16,4,0,["CRITICAL"]],[3,16,4,1,["CRITICAL"]],[3,16,5,0,["CRITICAL"]],[3,16,5,1,["CRITICAL"]],[3,17,0,0,["CRITICAL"]],[3,17,0,1,["CRITICAL"]],[3,17,1,0,["CRITICAL"]],[3,17,1,1,["CRITICAL"]],[3,17,2,0,["CRITICAL"]],[3,17,2,1,["CRITICAL"]],[3,17,3,0,["CRITICAL"]],[3,17,3,1,["CRITICAL"]],[3,17,4,0,["CRITICAL"]],[3,17,4,1,["CRITICAL"]],[3,17,5,0,["CRITICAL"]],[3,17,5,1,["CRITICAL"]],[3,18,0,0,["CRITICAL"]],[3,18,0,1,["CRITICAL"]],[3,18,1,0,["CRITICAL"]],[3,18,1,1,["CRITICAL"]],[3,18,2,0,["CRITICAL"]],[3,18,2,1,["CRITICAL"]],[3,18,3,0,["CRITICAL"]],[3,18,3,1,["CRITICAL"]],[3,18,4,0,["CRITICAL"]],[3,18,4,1,["CRITICAL"]],[3,18,5,0,["CRITICAL"]],[3,18,5,1,["CRITICAL"]],[3,19,0,0,["CRITICAL"]],[3,19,0,1,["CRITICAL"]],[3,19,1,0,["CRITICAL"]],[3,19,1,1,["CRITICAL"]],[3,19,2,0,["CRITICAL"]],[3,19,2,1,["CRITICAL"]],[3,19,3,0,["CRITICAL"]],[3,19,3,1,["CRITICAL"]],[3,19,4,0,["CRITICAL"]],[3,19,4,1,["CRITICAL"]],[3,19,5,0,["CRITICAL"]],[3,19,5,1,["CRITICAL"]],[4,0,0,0,["Low"]],[4,0,0,1,["CRITICAL"]],[4,0,1,0,["Low"]],[4,0,1,1,["CRITICAL"]],[4,0,2,0,["Low"]],[4,0,2,1,["CRITICAL"]],[4,0,3,0,["Low"]],[4,0,3,1,["CRITICAL"]],[4,0,4,0,["Low"]],[4,0,4,1,["CRITICAL"]],[4,0,5,0,["Low"]],[4,0,5,1,["CRITICAL"]],[4,1,0,0,["FIXABLE"]],[4,1,0,1,["CRITICAL"]],[4,1,1,0,["FIXABLE"]],[4,1,1,1,["CRITICAL"]],[4,1,2,0,["FIXABLE"]],[4,1,2,1,["CRITICAL"]],[4,1,3,0,["FIXABLE"]],[4,1,3,1,["CRITICAL"]],[4,1,4,0,["FIXABLE"]],[4,1,4,1,["CRITICAL"]],[4,1,5,0,["FIXABLE"]],[4,1,5,1,["CRITICAL"]],[4,2,0,0,["Critical"]],[4,2,0,1,["CRITICAL"]],[4,2,1,0,["Critical"]],[4,2,1,1,["CRITICAL"]],[4,2,2,0,["Critical"]],[4,2,2,1,["CRITICAL"]],[4,2,3,0,["Critical"]],[4,2,3,1,["CRITICAL"]],[4,2,4,0,["Critical"]],[4,2,4,1,["CRITICAL"]],[4,2,5,0,["Critical"]],[4,2,5,1,["CRITICAL"]],[4,3,0,0,["CRITICAL"]],[4,3,0,1,["CRITICAL"]],[4,3,1,0,["CRITICAL"]],[4,3,1,1,["CRITICAL"]],[4,3,2,0,["CRITICAL"]],[4,3,2,1,["CRITICAL"]],[4,3,3,0,["CRITICAL"]],[4,3,3,1,["CRITICAL"]],[4,3,4,0,["CRITICAL"]],[4,3,4,1,["CRITICAL"]],[4,3,5,0,["CRITICAL"]],[4,3,5,1,["CRITICAL"]],[4,4,0,0,["Medium"]],[4,4,0,1,["CRITICAL"]],[4,4,1,0,["Medium"]],[4,4,1,1,["CRITICAL"]],[4,4,2,0,["Medium"]],[4,4,2,1,["CRITICAL"]],[4,4,3,0,["Medium"]],[4,4,3,1,["CRITICAL"]],[4,4,4,0,["Medium"]],[4,4,4,1,["CRITICAL"]],[4,4,5,0,["Medium"]],[4,4,5,1,["CRITICAL"]],[4,5,0,0,["Medium"]],[4,5,0,1,["CRITICAL"]],[4,5,1,0,["Medium"]],[4,5,1,1,["CRITICAL"]],[4,5,2,0,["Medium"]],[4,5,2,1,["CRITICAL"]],[4,5,3,0,["Medium"]],[4,5,3,1,["CRITICAL"]],[4,5,4,0,["Medium"]],[4,5,4,1,["CRITICAL"]],[4,5,5,0,["Medium"]],[4,5,5,1,["CRITICAL"]],[4,6,0,0,["Low"]],[4,6,0,1,["CRITICAL"]],[4,6,1,0,["Low"]],[4,6,1,1,["CRITICAL"]],[4,6,2,0,["Low"]],[4,6,2,1,["CRITICAL"]],[4,6,3,0,["Low"]],[4,6,3,1,["CRITICAL"]],[4,6,4,0,["Low"]],[4,6,4,1,["CRITICAL"]],[4,6,5,0,["Low"]],[4,6,5,1,["CRITICAL"]],[4,7,0,0,["Low"]],[4,7,0,1,["CRITICAL"]],[4,7,1,0,["Low"]],[4,7,1,1,["CRITICAL"]],[4,7,2,0,["Low"]],[4,7,2,1,["CRITICAL"]],[4,7,3,0,["Low"]],[4,7,3,1,["CRITICAL"]],[4,7,4,0,["Low"]],[4,7,4,1,["CRITICAL"]],[4,7,5,0,["Low"]],[4,7,5,1,["CRITICAL"]],[4,8,0,0,["P0"]],[4,8,0,1,["CRITICAL"]],[4,8,1,0,["P0"]],[4,8,1,1,["CRITICAL"]],[4,8,2,0,["P0"]],[4,8,2,1,["CRITICAL"]],[4,8,3,0,["P0"]],[4,8,3,1,["CRITICAL"]],[4,8,4,0,["P0"]],[4,8,4,1,["CRITICAL"]],[4,8,5,0,["P0"]],[4,8,5,1,["CRITICAL"]],[4,9,0,0,["Low"]],[4,9,0,1,["CRITICAL"]],[4,9,1,0,["FIXABLE","Low"]],[4,9,1,1,["CRITICAL","Low"]],[4,9,2,0,["FIXABLE","Low"]],[4,9,2,1,["CRITICAL","Low"]],[4,9,3,0,["Low"]],[4,9,3,1,["CRITICAL"]],[4,9,4,0,["Low"]],[4,9,4,1,["CRITICAL"]],[4,9,5,0,["Low"]],[4,9,5,1,["CRITICAL"]],[4,10,0,0,["Medium"]],[4,10,0,1,["CRITICAL"]],[4,10,1,0,["FIXABLE","Medium"]],[4,10,1,1,["CRITICAL","Medium"]],[4,10,2,0,["FIXABLE","Medium"]],[4,10,2,1,["CRITICAL","Medium"]],[4,10,3,0,["Medium"]],[4,10,3,1,["CRITICAL"]],[4,10,4,0,["Medium"]],[4,10,4,1,["CRITICAL"]],[4,10,5,0,["Medium"]],[4,10,5,1,["CRITICAL"]],[4,11,0,0,["Low"]],[4,11,0,1,["CRITICAL"]],[4,11,1,0,["FIXABLE","Low"]],[4,11,1,1,["CRITICAL","Low"]],[4,11,2,0,["FIXABLE","Low"]],[4,11,2,1,["CRITICAL","Low"]],[4,11,3,0,["Low"]],[4,11,3,1,["CRITICAL"]],[4,11,4,0,["Low"]],[4,11,4,1,["CRITICAL"]],[4,11,5,0,["Low"]],[4,11,5,1,["CRITICAL"]],[4,12,0,0,["Medium"]],[4,12,0,1,["CRITICAL"]],[4,12,1,0,["Medium"]],[4,12,1,1,["CRITICAL"]],[4,12,2,0,["Medium"]],[4,12,2,1,["CRITICAL"]],[4,12,3,0,["Medium"]],[4,12,3,1,["CRITICAL"]],[4,12,4,0,["Medium"]],[4,12,4,1,["CRITICAL"]],[4,12,5,0,["Medium"]],[4,12,5,1,["CRITICAL"]],[4,13,0,0,["Low"]],[4,13,0,1,["CRITICAL"]],[4,13,1,0,["Low"]],[4,13,1,1,["CRITICAL"]],[4,13,2,0,["Low"]],[4,13,2,1,["CRITICAL"]],[4,13,3,0,["Low"]],[4,13,3,1,["CRITICAL"]],[4,13,4,0,["Low"]],[4,13,4,1,["CRITICAL"]],[4,13,5,0,["Low"]],[4,13,5,1,["CRITICAL"]],[4,14,0,0,["Low"]],[4,14,0,1,["CRITICAL"]],[4,14,1,0,["Low"]],[4,14,1,1,["CRITICAL"]],[4,14,2,0,["Low"]],[4,14,2,1,["CRITICAL"]],[4,14,3,0,["Low"]],[4,14,3,1,["CRITICAL"]],[4,14,4,0,["Low"]],[4,14,4,1,["CRITICAL"]],[4,14,5,0,["Low"]],[4,14,5,1,["CRITICAL"]],[4,15,0,0,["LOW"]],[4,15,0,1,["CRITICAL"]],[4,15,1,0,["LOW"]],[4,15,1,1,["CRITICAL"]],[4,15,2,0,["LOW"]],[4,15,2,1,["CRITICAL"]],[4,15,3,0,["LOW"]],[4,15,3,1,["CRITICAL"]],[4,15,4,0,["LOW"]],[4,15,4,1,["CRITICAL"]],[4,15,5,0,["LOW"]],[4,15,5,1,["CRITICAL"]],[4,16,0,0,["P3"]],[4,16,0,1,["CRITICAL"]],[4,16,1,0,["P3"]],[4,16,1,1,["CRITICAL"]],[4,16,2,0,["P3"]],[4,16,2,1,["CRITICAL"]],[4,16,3,0,["P3"]],[4,16,3,1,["CRITICAL"]],[4,16,4,0,["P3"]],[4,16,4,1,["CRITICAL"]],[4,16,5,0,["P3"]],[4,16,5,1,["CRITICAL"]],[4,17,0,0,["High"]],[4,17,0,1,["CRITICAL"]],[4,17,1,0,["High"]],[4,17,1,1,["CRITICAL"]],[4,17,2,0,["High"]],[4,17,2,1,["CRITICAL"]],[4,17,3,0,["High"]],[4,17,3,1,["CRITICAL"]],[4,17,4,0,["High"]],[4,17,4,1,["CRITICAL"]],[4,17,5,0,["High"]],[4,17,5,1,["CRITICAL"]],[4,18,0,0,["Low"]],[4,18,0,1,["CRITICAL"]],[4,18,1,0,["Low"]],[4,18,1,1,["CRITICAL"]],[4,18,2,0,["Low"]],[4,18,2,1,["CRITICAL"]],[4,18,3,0,["Low"]],[4,18,3,1,["CRITICAL"]],[4,18,4,0,["Low"]],[4,18,4,1,["CRITICAL"]],[4,18,5,0,["Low"]],[4,18,5,1,["CRITICAL"]],[4,19,0,0,["CRITICAL"]],[4,19,0,1,["CRITICAL"]],[4,19,1,0,["CRITICAL"]],[4,19,1,1,["CRITICAL"]],[4,19,2,0,["CRITICAL"]],[4,19,2,1,["CRITICAL"]],[4,19,3,0,["CRITICAL"]],[4,19,3,1,["CRITICAL"]],[4,19,4,0,["CRITICAL"]],[4,19,4,1,["CRITICAL"]],[4,19,5,0,["CRITICAL"]],[4,19,5,1,["CRITICAL"]],[5,0,0,0,["Low"]],[5,0,0,1,["Low"]],[5,0,1,0,["Low"]],[5,0,1,1,["Low"]],[5,0,2,0,["Low"]],[5,0,2,1,["Low"]],[5,0,3,0,["Low"]],[5,0,3,1,["Low"]],[5,0,4,0,["Low"]],[5,0,4,1,["Low"]],[5,0,5,0,["Low"]],[5,0,5,1,["Low"]],[5,1,0,0,[]],[5,1,0,1,[]],[5,1,1,0,[]],[5,1,1,1,[]],[5,1,2,0,[]],[5,1,2,1,[]],[5,1,3,0,[]],[5,1,3,1,[]],[5,1,4,0,[]],[5,1,4,1,[]],[5,1,5,0,[]],[5,1,5,1,[]],[5,2,0,0,["Critical"]],[5,2,0,1,["Critical"]],[5,2,1,0,["Critical"]],[5,2,1,1,["Critical"]],[5,2,2,0,["Critical"]],[5,2,2,1,["Critical"]],[5,2,3,0,["Critical"]],[5,2,3,1,["Critical"]],[5,2,4,0,["Critical"]],[5,2,4,1,["Critical"]],[5,2,5,0,["Critical"]],[5,2,5,1,["Critical"]],[5,3,0,0,["CRITICAL"]],[5,3,0,1,["CRITICAL"]],[5,3,1,0,["CRITICAL"]],[5,3,1,1,["CRITICAL"]],[5,3,2,0,["CRITICAL"]],[5,3,2,1,["CRITICAL"]],[5,3,3,0,["CRITICAL"]],[5,3,3,1,["CRITICAL"]],[5,3,4,0,["CRITICAL"]],[5,3,4,1,["CRITICAL"]],[5,3,5,0,["CRITICAL"]],[5,3,5,1,["CRITICAL"]],[5,4,0,0,["Medium"]],[5,4,0,1,["Medium"]],[5,4,1,0,["Medium"]],[5,4,1,1,["Medium"]],[5,4,2,0,["Medium"]],[5,4,2,1,["Medium"]],[5,4,3,0,["Medium"]],[5,4,3,1,["Medium"]],[5,4,4,0,["Medium"]],[5,4,4,1,["Medium"]],[5,4,5,0,["Medium"]],[5,4,5,1,["Medium"]],[5,5,0,0,["Medium"]],[5,5,0,1,["Medium"]],[5,5,1,0,["Medium"]],[5,5,1,1,["Medium"]],[5,5,2,0,["Medium"]],[5,5,2,1,["Medium"]],[5,5,3,0,["Medium"]],[5,5,3,1,["Medium"]],[5,5,4,0,["Medium"]],[5,5,4,1,["Medium"]],[5,5,5,0,["Medium"]],[5,5,5,1,["Medium"]],[5,6,0,0,["Low"]],[5,6,0,1,["Low"]],[5,6,1,0,["Low"]],[5,6,1,1,["Low"]],[5,6,2,0,["Low"]],[5,6,2,1,["Low"]],[5,6,3,0,["Low"]],[5,6,3,1,["Low"]],[5,6,4,0,["Low"]],[5,6,4,1,["Low"]],[5,6,5,0,["Low"]],[5,6,5,1,["Low"]],[5,7,0,0,["Low"]],[5,7,0,1,["Low"]],[5,7,1,0,["Low"]],[5,7,1,1,["Low"]],[5,7,2,0,["Low"]],[5,7,2,1,["Low"]],[5,7,3,0,["Low"]],[5,7,3,1,["Low"]],[5,7,4,0,["Low"]],[5,7,4,1,["Low"]],[5,7,5,0,["Low"]],[5,7,5,1,["Low"]],[5,8,0,0,["P0"]],[5,8,0,1,["P0"]],[5,8,1,0,["P0"]],[5,8,1,1,["P0"]],[5,8,2,0,["P0"]],[5,8,2,1,["P0"]],[5,8,3,0,["P0"]],[5,8,3,1,["P0"]],[5,8,4,0,["P0"]],[5,8,4,1,["P0"]],[5,8,5,0,["P0"]],[5,8,5,1,["P0"]],[5,9,0,0,["Low"]],[5,9,0,1,["Low"]],[5,9,1,0,["Low"]],[5,9,1,1,["Low"]],[5,9,2,0,["Low"]],[5,9,2,1,["Low"]],[5,9,3,0,["Low"]],[5,9,3,1,["Low"]],[5,9,4,0,["Low"]],[5,9,4,1,["Low"]],[5,9,5,0,["Low"]],[5,9,5,1,["Low"]],[5,10,0,0,["Medium"]],[5,10,0,1,["Medium"]],[5,10,1,0,["Medium"]],[5,10,1,1,["Medium"]],[5,10,2,0,["Medium"]],[5,10,2,1,["Medium"]],[5,10,3,0,["Medium"]],[5,10,3,1,["Medium"]],[5,10,4,0,["Medium"]],[5,10,4,1,["Medium"]],[5,10,5,0,["Medium"]],[5,10,5,1,["Medium"]],[5,11,0,0,["Low"]],[5,11,0,1,["Low"]],[5,11,1,0,["Low"]],[5,11,1,1,["Low"]],[5,11,2,0,["Low"]],[5,11,2,1,["Low"]],[5,11,3,0,["Low"]],[5,11,3,1,["Low"]],[5,11,4,0,["Low"]],[5,11,4,1,["Low"]],[5,11,5,0,["Low"]],[5,11,5,1,["Low"]],[5,12,0,0,["Medium"]],[5,12,0,1,["Medium"]],[5,12,1,0,["Medium"]],[5,12,1,1,["Medium"]],[5,12,2,0,["Medium"]],[5,12,2,1,["Medium"]],[5,12,3,0,["Medium"]],[5,12,3,1,["Medium"]],[5,12,4,0,["Medium"]],[5,12,4,1,["Medium"]],[5,12,5,0,["Medium"]],[5,12,5,1,["Medium"]],[5,13,0,0,["Low"]],[5,13,0,1,["Low"]],[5,13,1,0,["Low"]],[5,13,1,1,["Low"]],[5,13,2,0,["Low"]],[5,13,2,1,["Low"]],[5,13,3,0,["Low"]],[5,13,3,1,["Low"]],[5,13,4,0,["Low"]],[5,13,4,1,["Low"]],[5,13,5,0,["Low"]],[5,13,5,1,["Low"]],[5,14,0,0,["Low"]],[5,14,0,1,["Low"]],[5,14,1,0,["Low"]],[5,14,1,1,["Low"]],[5,14,2,0,["Low"]],[5,14,2,1,["Low"]],[5,14,3,0,["Low"]],[5,14,3,1,["Low"]],[5,14,4,0,["Low"]],[5,14,4,1,["Low"]],[5,14,5,0,["Low"]],[5,14,5,1,["Low"]],[5,15,0,0,["LOW"]],[5,15,0,1,["LOW"]],[5,15,1,0,["LOW"]],[5,15,1,1,["LOW"]],[5,15,2,0,["LOW"]],[5,15,2,1,["LOW"]],[5,15,3,0,["LOW"]],[5,15,3,1,["LOW"]],[5,15,4,0,["LOW"]],[5,15,4,1,["LOW"]],[5,15,5,0,["LOW"]],[5,15,5,1,["LOW"]],[5,16,0,0,["P3"]],[5,16,0,1,["P3"]],[5,16,1,0,["P3"]],[5,16,1,1,["P3"]],[5,16,2,0,["P3"]],[5,16,2,1,["P3"]],[5,16,3,0,["P3"]],[5,16,3,1,["P3"]],[5,16,4,0,["P3"]],[5,16,4,1,["P3"]],[5,16,5,0,["P3"]],[5,16,5,1,["P3"]],[5,17,0,0,["High"]],[5,17,0,1,["High"]],[5,17,1,0,["High"]],[5,17,1,1,["High"]],[5,17,2,0,["High"]],[5,17,2,1,["High"]],[5,17,3,0,["High"]],[5,17,3,1,["High"]],[5,17,4,0,["High"]],[5,17,4,1,["High"]],[5,17,5,0,["High"]],[5,17,5,1,["High"]],[5,18,0,0,["Low"]],[5,18,0,1,["Low"]],[5,18,1,0,["Low"]],[5,18,1,1,["Low"]],[5,18,2,0,["Low"]],[5,18,2,1,["Low"]],[5,18,3,0,["Low"]],[5,18,3,1,["Low"]],[5,18,4,0,["Low"]],[5,18,4,1,["Low"]],[5,18,5,0,["Low"]],[5,18,5,1,["Low"]],[5,19,0,0,["CRITICAL"]],[5,19,0,1,["CRITICAL"]],[5,19,1,0,["CRITICAL"]],[5,19,1,1,["CRITICAL"]],[5,19,2,0,["CRITICAL"]],[5,19,2,1,["CRITICAL"]],[5,19,3,0,["CRITICAL"]],[5,19,3,1,["CRITICAL"]],[5,19,4,0,["CRITICAL"]],[5,19,4,1,["CRITICAL"]],[5,19,5,0,["CRITICAL"]],[5,19,5,1,["CRITICAL"]]]}} diff --git a/test/gstack-codex-session-import.test.ts b/test/gstack-codex-session-import.test.ts index be48c75ae..ac234ed52 100644 --- a/test/gstack-codex-session-import.test.ts +++ b/test/gstack-codex-session-import.test.ts @@ -109,6 +109,18 @@ describe('marker-first import (source=codex-import-marker)', () => { expect(events[0].user_choice).toContain('Fix now'); expect(events[0].recommended).toContain('Fix now'); }); + + test('carries a marker signal hint into the log (#3442)', () => { + const sessionPath = writeSessionFile([ + agentMessage('D1 — Caching\n Add caching?\nA) Expand with caching (recommended)\nB) Hold scope'), + userMessage('A'), + ]); + expect(runImport(sessionPath).status).toBe(0); + const events = readImportedEvents(); + expect(events[0].question_id).toBe('plan-ceo-review-add-caching'); + expect(events[0].signal_key).toBe('scope-appetite'); + expect(events[0].user_choice).toBe('expand'); + }); }); // ---------------------------------------------------------------------- diff --git a/test/gstack-developer-profile.test.ts b/test/gstack-developer-profile.test.ts index cffaadebc..c9ccb91c7 100644 --- a/test/gstack-developer-profile.test.ts +++ b/test/gstack-developer-profile.test.ts @@ -331,6 +331,43 @@ describe('gstack-developer-profile --derive', () => { expect(p.inferred.signal_events).toBe(2); }); + test('a signal hint on an unregistered id moves its dimension (#3442)', () => { + logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-add-caching', question_summary: 'caching?', user_choice: 'expand', signal_key: 'scope-appetite', session_id: 's1' }); + runDev('--derive'); + const p = readProfile() as any; + expect(p.inferred.contributing_events.scope_appetite).toBe(1); + expect(p.inferred.hinted_events).toBe(1); + expect(p.inferred.values.scope_appetite).toBeGreaterThan(0.5); + expect(runDev('--trace', 'scope_appetite').stdout).toContain('(hinted)'); + }); + + test('the same unregistered answer without a hint moves nothing (#3442)', () => { + logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-add-caching', question_summary: 'caching?', user_choice: 'expand', session_id: 's1' }); + runDev('--derive'); + const p = readProfile() as any; + expect(p.inferred.contributing_events.scope_appetite).toBe(0); + expect(p.inferred.hinted_events).toBe(0); + }); + + test('an unknown hint, a choice outside the signal and a registered id ignore the hint (#3442)', () => { + logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-a', question_summary: 'a?', user_choice: 'expand', signal_key: 'made-up', session_id: 's1' }); + logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-b', question_summary: 'b?', user_choice: 'maybe', signal_key: 'scope-appetite', session_id: 's2' }); + logQuestion({ skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', question_summary: 'arch?', user_choice: 'fix-now', signal_key: 'scope-appetite', session_id: 's3' }); + runDev('--derive'); + const p = readProfile() as any; + expect(p.inferred.contributing_events.scope_appetite).toBe(0); + expect(p.inferred.contributing_events.architecture_care).toBe(1); + expect(p.inferred.hinted_events).toBe(0); + }); + + test('hinted rows share the per-day cap (#3442)', () => { + for (let i = 0; i < 5; i++) { + logQuestion({ skill: 'plan-ceo-review', question_id: `plan-ceo-review-q${i}`, question_summary: 'q?', user_choice: 'expand', signal_key: 'scope-appetite', session_id: `s${i}`, ts: '2026-10-31T12:00:00Z' }); + } + runDev('--derive'); + expect(runDev('--trace', 'scope_appetite').stdout).toContain('capped to +0.060'); + }); + test('contributing_events is all zero when no answer matches a signal', () => { logQuestion({ skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', question_summary: 'arch?', user_choice: 'not-a-mapped-option', session_id: 's1' }); runDev('--derive'); diff --git a/test/gstack-question-log.test.ts b/test/gstack-question-log.test.ts index be25a6712..b69187f93 100644 --- a/test/gstack-question-log.test.ts +++ b/test/gstack-question-log.test.ts @@ -294,3 +294,32 @@ describe('gstack-question-log — shared injection patterns (#1934 dedup)', () = expect(source).not.toContain('const INJECTION_PATTERNS'); }); }); + +describe('gstack-question-log — signal hints (#3442)', () => { + const base = { skill: 'plan-ceo-review', question_summary: 'caching?', session_id: 's1' }; + + test('a valid hint is kept and a key-word label maps to the option key', () => { + const r = run(JSON.stringify({ ...base, question_id: 'plan-ceo-review-add-caching', signal_key: 'Scope-Appetite', user_choice: 'B) Expand it (recommended)', source: 'hook' })); + expect(r.status).toBe(0); + const row = JSON.parse(readLog().at(-1)!); + expect(row.signal_key).toBe('scope-appetite'); + expect(row.user_choice).toBe('expand'); + expect(row.choice_label).toBe('B) Expand it (recommended)'); + }); + + test('an unknown hint is dropped with a stable code and the row is still logged', () => { + const r = run(JSON.stringify({ ...base, question_id: 'plan-ceo-review-add-caching', signal_key: 'made-up', user_choice: 'expand' })); + expect(r.status).toBe(0); + expect(r.stderr).toContain('QLOG_SIGNAL_KEY_DROPPED'); + const row = JSON.parse(readLog().at(-1)!); + expect(row.signal_key).toBeUndefined(); + expect(row.user_choice).toBe('expand'); + }); + + test('a hint on a registered id is dropped', () => { + const r = run(JSON.stringify({ ...base, skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', signal_key: 'scope-appetite', user_choice: 'fix-now' })); + expect(r.status).toBe(0); + expect(r.stderr).toContain('a registered id keeps its own signal'); + expect(JSON.parse(readLog().at(-1)!).signal_key).toBeUndefined(); + }); +}); diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 7de60fa39..6a2f6c3cd 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -361,6 +361,34 @@ describe('ready', () => { expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/); }, 180_000); + // Value: protects=a head whose required targeted reader is missing or failed is never READY (GST-966, gstack-evals#140); + // fails_when=ready prints REVIEW_TARGETED verdict=missing or failed and still exits 0, or refuses a covered or unrequired head; + // seam=bindShippedReceipt (ready, train build and land share it); no_claim=the ledger recording a clean reader as failed (GSTA-167) + test('ready refuses QUEUE_RECEIPT_TARGETED_MISSING when a required targeted class is missing or failed, and passes a covered head', () => { + const repo = fixture(); + const head = makePr(repo, 'pr-auth', { file: 'session.ts' }); + const pregate = pregateFor(repo, head); + const shim = (body: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: head, url: 'https://github.com/o/r/pull/1', body } }, checks: { [head]: green } }); + const base = repo.git('rev-parse', 'origin/main'); + const depth = { gstack_version: '1.92.61.0', review_depth: { depth: 'full' as const, class: 'code' as const, runtime_lines: 1, rules: '551486253e156572', base, registry: base, gate_scan: { tagged: 0, head }, reasons: ['runtime=1'], risk: [] } }; + const targeted = (status: 'done' | 'failed') => ({ ...depth, review_targeted: { classes: ['auth' as const], rules: 'r', brief_sha: 'a'.repeat(64), product_patch_id: 'p', status, findings: 0, raised_by: 'targeted' } }); + const legacy = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(shippedBody('1', head, depth))); + expect(line(legacy.out, 'REVIEW_TARGETED:')).toBe('REVIEW_TARGETED: required=auth receipt=legacy verdict=missing'); + expect(legacy.status).toBe(EXIT.refused); + expect(legacy.err).toMatch(/requires the targeted reader on auth.*records no targeted pass.*\(QUEUE_RECEIPT_TARGETED_MISSING\)/); + const failed = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(shippedBody('1', head, targeted('failed')))); + expect(line(failed.out, 'REVIEW_TARGETED:')).toBe('REVIEW_TARGETED: required=auth receipt=none verdict=failed'); + expect(failed.status).toBe(EXIT.refused); + expect(failed.err).toMatch(/requires the targeted reader on auth.*targeted pass failed.*\(QUEUE_RECEIPT_TARGETED_MISSING\)/); + const done = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(shippedBody('1', head, targeted('done')))); + expect(done.status, done.err).toBe(EXIT.ok); + expect(line(done.out, 'REVIEW_TARGETED:')).toBe('REVIEW_TARGETED: required=auth receipt=auth verdict=ok'); + const plain = makePr(repo, 'pr-plain'); + const unrequired = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, plain)], ghQueueShim(repo, { prs: { '1': { headRefOid: plain, body: shippedBody('1', plain) } }, checks: { [plain]: green } })); + expect(unrequired.status, unrequired.err).toBe(EXIT.ok); + expect(line(unrequired.out, 'REVIEW_TARGETED:')).toBe('REVIEW_TARGETED: required=none receipt=legacy verdict=ok'); + }, 120_000); + // Value: protects=the re-READY after a slot's stamp push accepts the receipt /ship wrote for the pinned head, and only then; // fails_when=ready binds the receipt to HEAD alone (every stamped READY turns QUEUE_RECEIPT_STALE) or accepts any ancestor's receipt; // why_new=the READY test above binds receipts to the exact head only; seam=none; no_claim=real gh and CI (shims; unit only) diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index e889e56ae..497d49c07 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -218,6 +218,19 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit expect(stampOnlyText({ before: 'left-pad since: 1.2.3\n', after: 'left-pad since: 1.2.4.0\n', oldVersion: '1.2.3.0', newVersion: '1.2.4.0' })).toBe(false); }); + // GST-963: the carry shares the stage's bound, so a key version the branch added at its fork version (older than the + // pin's VERSION after a base merge) is a stamp on the branch's own line and a hand edit on any other. + test('stampOnlyText takes the fork-version bound only on a line the branch added', () => { + const templates = ["_KEY_VERSION = '{version}'"]; + const mine = "export const MINE_KEY_VERSION = '1.2.3.0';"; + const before = `${mine}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n`; + const moved = (text: string) => ({ before, after: text, oldVersion: '1.2.4.0', newVersion: '1.2.5.0', templates }); + const side = { fork: '1.2.3.0', added: new Set([mine]) }; + expect(stampOnlyText({ ...moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))), side })).toBe(true); + expect(stampOnlyText(moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))))).toBe(false); + expect(stampOnlyText({ ...moved(before.replace("BASE_KEY_VERSION = '1.2.3.0'", "BASE_KEY_VERSION = '1.2.5.0'")), side })).toBe(false); + }); + test('planCarry names at most CARRY_NAMED_PATHS paths', () => { const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`); const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' }); diff --git a/test/helpers/pty/boundaries.ts b/test/helpers/pty/boundaries.ts index c5520118d..c09b69611 100644 --- a/test/helpers/pty/boundaries.ts +++ b/test/helpers/pty/boundaries.ts @@ -68,7 +68,7 @@ export const engSetupAUQ: Step0BoundaryPredicate = (fp) => { return answered.length > 0 && answered.every(q => { if (!q.options.some(option => option.label === call.answers?.[q.question])) return false; const actions = q.options.map(option => option.label.trim().replace(/^[A-Z][.)]\s+/i, '')); - const id = //i.exec(q.question)?.[1]?.toLowerCase(); + const id = //i.exec(q.question)?.[1]?.toLowerCase(); const body = q.question.replace(/]*>/gi, '').trim() .replace(/^D\s*\d+\s*[—–:-]\s*/i, ''); // A finding about one component or a TODO remains substantive even if @@ -592,7 +592,7 @@ export const engFirstReviewAUQ: Step0BoundaryPredicate = (fp) => { if (!call.answers?.[q.question]) return false; // These are review section identities, including the registry's // arch-finding/test-gap IDs. Scope and onboarding IDs cannot qualify. - const id = //i.exec(q.question)?.[1] ?? ''; + const id = //i.exec(q.question)?.[1] ?? ''; if (/^plan-eng-(?:review-)?(?:arch(?:itecture)?|quality|test|perf(?:ormance)?)-(?:focus|mode|setup|routing|learnings|prerequisite|onboarding|next-steps?)(?:-|$)/i.test(id)) return false; const title = `${q.header} ${q.question.split('\n')[0]}`.replace(/]*>/gi, ''); return /^plan-eng-(?:review-)?(?:arch(?:itecture)?|quality|test|perf(?:ormance)?)-/i.test(id) && diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index c0eb45498..c8a01b96f 100644 --- a/test/plan-tune-cathedral.test.ts +++ b/test/plan-tune-cathedral.test.ts @@ -62,6 +62,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; 'archetypes.ts', 'one-way-doors.ts', 'declared-annotation.ts', + 'qid-marker.ts', ]) { const src = path.join(ROOT, 'scripts', f); if (fs.existsSync(src)) fs.copyFileSync(src, path.join(scriptsDir, f)); diff --git a/test/qid-marker.test.ts b/test/qid-marker.test.ts new file mode 100644 index 000000000..41cb3035e --- /dev/null +++ b/test/qid-marker.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test'; +import { parseQidMarker } from '../scripts/qid-marker'; +import { resolveHintedOptionKey, signalKeyLines } from '../scripts/psychographic-signals'; + +describe('parseQidMarker (#3442)', () => { + test('reads a plain marker and strips it', () => { + expect(parseQidMarker('D1 — Ship? ')).toEqual({ id: 'ship-test-failure-triage', stripped: 'D1 — Ship?' }); + }); + + test('reads a signal hint and strips the whole marker', () => { + expect(parseQidMarker('Add caching? ')).toEqual({ + id: 'plan-ceo-review-add-caching', + signal_key: 'scope-appetite', + stripped: 'Add caching?', + }); + }); + + test('lowercases an uppercase marker', () => { + expect(parseQidMarker(' q')).toMatchObject({ id: 'plan-ceo-x', signal_key: 'scope-appetite' }); + }); + + test('no marker returns undefined', () => { + expect(parseQidMarker('plain question')).toBeUndefined(); + }); +}); + +describe('resolveHintedOptionKey (#3442)', () => { + test('an exact option key passes through', () => { + expect(resolveHintedOptionKey('scope-appetite', 'expand')).toBe('expand'); + }); + + test('a label that starts with the key words maps to the key', () => { + expect(resolveHintedOptionKey('scope-appetite', 'B) Reduce scope (recommended)')).toBe('reduce'); + expect(resolveHintedOptionKey('architecture-care', 'Accept risk and move on')).toBe('accept-risk'); + }); + + test('a cue-table word never maps, so a yes cannot invert a signal', () => { + expect(resolveHintedOptionKey('scope-appetite', 'Yes, cut it')).toBeUndefined(); + expect(resolveHintedOptionKey('architecture-care', 'Fix it later')).toBeUndefined(); + }); + + test('an unknown signal key maps nothing', () => { + expect(resolveHintedOptionKey('made-up', 'expand')).toBeUndefined(); + }); + + test('signalKeyLines lists every signal with its option keys', () => { + expect(signalKeyLines()).toContain('architecture-care: fix-now | defer | accept-risk'); + }); +}); diff --git a/test/question-log-hook.test.ts b/test/question-log-hook.test.ts index 27b6497b9..5033a561a 100644 --- a/test/question-log-hook.test.ts +++ b/test/question-log-hook.test.ts @@ -137,6 +137,30 @@ describe('PostToolUse hook (native AskUserQuestion)', () => { // Marker stripped from summary expect((events[0].question_summary as string).includes(' { + runHook({ + session_id: 'sess-hint', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-hint', + tool_input: { + questions: [ + { + question: 'D3 — Add caching? ', + options: ['A) Expand to add caching (recommended)', 'B) Hold scope'], + }, + ], + }, + tool_response: { answers: [{ option_label: 'A) Expand to add caching (recommended)' }] }, + cwd: ROOT, + }); + const events = readLog(); + expect(events.length).toBe(1); + expect(events[0].question_id).toBe('plan-ceo-review-add-caching'); + expect(events[0].signal_key).toBe('scope-appetite'); + expect(events[0].user_choice).toBe('expand'); + expect((events[0].question_summary as string).includes('gstack-qid')).toBe(false); + }); }); // ---------------------------------------------------------------------- diff --git a/test/restamp-base-merge.test.ts b/test/restamp-base-merge.test.ts index d8d6944d7..471dc006e 100644 --- a/test/restamp-base-merge.test.ts +++ b/test/restamp-base-merge.test.ts @@ -17,7 +17,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { mergeChangelogs } from '../lib/changelog-check'; -import { branchAddedLines, stampOnlyLine } from '../lib/restamp'; +import { branchAddedLines } from '../lib/restamp'; +import { stampOnlyLine } from '../lib/stamp-only'; import { gitArgvIn } from './helpers/scratch-repo'; import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index a59c0e9c4..3913d7e92 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -15,7 +15,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { rewriteStamps, tentativeMentions } from '../lib/restamp'; import type { GitFixtureTemplate } from './helpers/git-fixture-template'; -import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; +import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); @@ -88,6 +88,11 @@ const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', '']. function policy(templates: boolean) { return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) }; } +/** The policy plus the lockfile --next stamps, so the tree receipt's stamp_files allowlist covers the whole stamp commit. */ +function receiptPolicy(templates: boolean) { + const p = policy(templates); + return { ...p, stamp_files: [...p.stamp_files, 'package-lock.json'] }; +} function write(repo: FixtureRepo, rel: string, text: string): void { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), text); @@ -157,7 +162,7 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(r.status).toBe(EXIT.ok); const sp = stageDetail(r.out, 'stamp_paths')!; expect(sp[1]).toBe('skipped'); - expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)'); + expect(sp[2]).toBe('no tentative mention at or newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)'); expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"])); expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m); }); @@ -243,6 +248,18 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete'); }); + // Value: protects=the lost-slot stamp the stage writes is one the train's tree receipt accepts (GST-963 review: one bound + // for the writer and the checks); fails_when=the tree receipt reads the moved own row as a product change; seam=stampOnlyLine + test('the lost-slot stamp passes the tree receipt', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-mentions-receipt-', policy: receiptPolicy(true) }); repos.push(repo); + lostSlot(repo); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp mine'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps'); + }); + test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => { const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo); lostSlot(repo); @@ -252,3 +269,64 @@ describe('a PR whose VERSION already equals the base (gbrain #7640)', () => { expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')])); }); }); + +describe('a branch-added row left at the fork version after the base moved (GST-963, GBRA-277, gbrain #8425)', () => { + // Value: protects=a stamp-form row the branch added at its fork version (not a tentative next version) is its own + // stale stamp once the base has moved past the fork, so --next moves it; the base's rows at that version, and a + // branch-added row older than the fork, stay; fails_when=the stage skips with "no tentative mention newer than the + // fork version" and leaves the row at the fork version (gbrain #8425), or moves a base row or a pre-fork row; + // seam=lib/restamp.ts rewriteStampPaths; no_claim=finish's route (test/ship-rereview-triggers.test.ts) + test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the fork-version row moves, the base rows and an older branch row stay', (_, templates) => { + const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-', policy: receiptPolicy(templates) }); repos.push(repo); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-mine'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.3.0', 'mine')])); + repo.git('commit', '-qam', 'mine: a row at the fork version'); + makePr(repo, 'pr-other'); + write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')])); + repo.git('commit', '-qam', 'other row'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-mine'); + mergeMain(repo); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + const gated = repo.git('rev-parse', 'HEAD'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(stageDetail(r.out, 'stamp_paths')![1]).toBe('done'); + expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', row('1.2.2.0', 'older'), '', row('1.2.5.0', 'mine')])); + repo.git('commit', '-qam', 'stamp mine'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out, receipt.err).toContain('TREE: same-modulo-stamps'); + }); + + // Value: protects=the four stamp checks share one bound, so a stamp the writer makes is one the train accepts; + // fails_when=the tree receipt (stamp, verify, gate-ahead replay) reads a moved fork-version row, or a base row at that + // version moved by hand, the same way; seam=lib/stamp-only.ts stampOnlyLine with the branch side + test('the tree receipt accepts the moved branch row and refuses the same move on a base row', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-fork-row-receipt-', policy: receiptPolicy(true) }); repos.push(repo); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-mine'); + write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.3.0', 'mine')])); + repo.git('commit', '-qam', 'mine: a row at the fork version'); + makePr(repo, 'pr-other'); + write(repo, 'other.txt', 'other\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-mine'); + mergeMain(repo); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + write(repo, NOTICE, readFile(repo, NOTICE).replace(row('1.2.3.0', 'base_row'), row('1.2.5.0', 'base_row'))); + repo.git('commit', '-qam', 'stamp mine, and move the base row by hand'); + const receipt = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']); + expect(receipt.out).toContain('TREE: changed'); + expect(receipt.out).toContain("id: 'base_row'"); + expect(receipt.out).not.toContain("id: 'mine'"); + }); +}); diff --git a/test/review-depth.test.ts b/test/review-depth.test.ts index 4769dd47c..a4fde116c 100644 --- a/test/review-depth.test.ts +++ b/test/review-depth.test.ts @@ -5,6 +5,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { classifyDiff, classifyPath, dataCandidate, depthFor, gateScanNeeded, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth'; import { summarizeReviewLoop } from '../lib/review-scope'; +import { sectionTexts } from '../lib/review-brief'; +import { createHash } from 'node:crypto'; const ROOT = path.join(import.meta.dir, '..'); const none = scanNumbers({}); @@ -70,6 +72,123 @@ describe('nextStep: the transition table', () => { test('a clean fix-check is done', () => expect(nextStep({ fullPasses: 1, last: 'fix-check' }).step).toBe('done')); }); +// Value: protects=on gate class Step 11's adversarial, outside and targeted voices run before any owner exit the round cap reaches, and a recorded owner grant dispatches its one scoped fix-check after round 2, once; fails_when=a gate-class round-2 blocker fix stops before Step 11 (C6-36 after-opus: next=stop, no voice ran), or a recorded grant still stops (C6-36 after-opus-grant: the lane hand-dispatched the fix-check); +// why_new=GST-967; seam=nextStep, planReview; no_claim=the voices' or the fix-check's verdicts +describe('GST-967: the round cap limits fix rounds, not the gate-class voices; a recorded grant runs its fix-check', () => { + const capStop = { fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true } as const; + test('a gate-class round-2 blocker fix runs Step 11\'s voices before the owner stop, then stops', () => { + expect(nextStep({ ...capStop, gate: true })).toMatchObject({ step: 'voices', reason: expect.stringContaining("Step 11's voices") }); + for (const o of [{ blockingOpen: true }, { fixedBlocking: false, fixedBelow: true, touchedGate: true }, { fullPasses: 1, last: 'fix-check' as const }, { fullPasses: 1, blockingOpen: true }]) expect(nextStep({ ...capStop, gate: true, ...o }).step).toBe('voices'); + expect(nextStep({ ...capStop, gate: true, adversarialRan: true }).step).toBe('stop'); + expect(nextStep(capStop).step).toBe('stop'); + }); + test('C6-36 run 1 replanned: the plan lists Step 11\'s three voices, and once they ran the stop stands', () => { + const h = { ...capStop, touchedGate: true }; + const p = planReview(base({ gateTagged: 1, targeted: { classes: ['gate'] }, history: h })); + expect([p.class, p.next.step, roles(p)]).toEqual(['gate', 'voices', ['adversarial-native:adversarial:full', 'outside-voice:outside-voice:host', 'targeted:targeted:full']]); + expect(planReview(base({ gateTagged: 1, adversarialRan: true, targeted: { classes: ['gate'] }, history: h })).next.step).toBe('stop'); + }); + test('a recorded grant dispatches one scoped fix-check after a round-2 full pass, after the gate-class voices, and never a second', () => { + const g = { ...capStop, granted: 'a-feb4aba31633' }; + expect(nextStep(g)).toMatchObject({ step: 'fix-check', reason: expect.stringContaining('a-feb4aba31633') }); + expect(roles(planReview(base({ history: g })))).toEqual(['fix-check:fix-check:full']); + expect(nextStep({ ...g, gate: true }).step).toBe('voices'); + expect(nextStep({ ...g, gate: true, adversarialRan: true }).step).toBe('fix-check'); + expect(nextStep({ ...g, fixedBlocking: false, fixedBelow: true, touchedGate: true }).step).toBe('fix-check'); + expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBlocking: true, granted: 'a-1' }).step).toBe('stop'); + expect(nextStep({ ...g, blockingOpen: true }).step).toBe('stop'); + expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'fix-check', granted: 'a-1' }).step).toBe('done'); + expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'fix-check', fixedBlocking: true, granted: 'a-1' }).step).toBe('stop'); + }); + test('docs/review-policy.md\'s transitions table and rule 3 say both', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs', 'review-policy.md'), 'utf8'); + const table = doc.slice(doc.indexOf(''), doc.indexOf('A **fix-check** is one reviewer')); + expect(table).toContain("| a stop (any row above) on gate class before Step 11's voices ran | Step 11's voices (adversarial, outside, targeted), then ask again with `--adversarial-ran` |"); + expect(table).toContain('| a round-2 full pass that stops for a fixed blocking finding or a gate-class edit, with the owner\'s recorded grant (`--grant `) | one scoped fix-check (round 3), after Step 11 on gate class |'); + const rule3 = doc.slice(doc.indexOf('3. **Two rounds is the cap**'), doc.indexOf('')).replace(/\s+/g, ' '); + expect(rule3).toContain("The cap limits fix rounds, not Step 11's voices"); + expect(rule3).toContain('`gstack-review-plan --grant --range-base `'); + }); +}); + +/** + * GST-967's safety property, as a permanent invariant over the transitions table (Captain, 2026-11-02: the invariant is the + * gate, a reader the second check). Every state: passes 0-3, each last step, every mix of the four fix flags, gate class or + * not, Step 11 run or not, grant recorded or not. + */ +describe('GST-967 invariant: no gate-class owner exit before Step 11\'s voices; a recorded grant always gets its fix-check', () => { + // Value: protects=a gate-class plan never stops (the exit where the owner may ship with open notes) before Step 11's adversarial, outside and targeted voices ran, voices is planned only then and dispatches all three, and a recorded grant always turns the stop after a round-2 full pass with a fixed finding into exactly one fix-check while changing nothing else, so it never repeats; fails_when=any state breaks one of those, so a later edit to nextStep cannot fail open; + // why_new=GST-967 (three earlier fixes failed open and a reviewer caught each after the fact); seam=nextStep, planReview; no_claim=the parent following NEXT + test('every state of the table holds the property', () => { + let states = 0, voices = 0, grants = 0; + for (const fullPasses of [0, 1, 2, 3]) for (const last of ['none', 'full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) + for (const gate of [false, true]) for (const adversarialRan of [false, true]) { + const h = { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8), gate, adversarialRan }; + const plain = nextStep(h), granted = nextStep({ ...h, granted: 'a-1' }); + const ungatedStop = nextStep({ ...h, gate: false }).step === 'stop'; + states++; + for (const n of [plain, granted]) { + if (gate && !adversarialRan) expect(n.step).not.toBe('stop'); + if (n.step === 'voices') expect([gate, adversarialRan, ungatedStop]).toEqual([true, false, true]); + } + if (plain.step === 'voices') voices++; + const owed = ungatedStop && !h.blockingOpen && last === 'full' && fullPasses === FULL_PASS_CAP && (!gate || adversarialRan); + if (owed) grants++; + expect([granted.step === 'fix-check' && plain.step !== 'fix-check', granted.step === plain.step]).toEqual([owed, !owed]); + if (owed) expect(granted.reason).toContain('a-1'); + } + expect(states).toBe(4 * 3 * 16 * 2 * 2); + expect([voices > 0, grants > 0]).toEqual([true, true]); + const gatePlan = (o: Partial) => planReview(base({ gateTagged: 1, targeted: { classes: ['gate'] }, ...o })); + for (const fullPasses of [1, 2]) for (const last of ['full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) { + const p = gatePlan({ history: { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8), granted: bits & 1 ? 'a-1' : undefined } }); + expect(p.next.step).not.toBe('stop'); + if (p.next.step === 'voices') expect(p.dispatches.map(d => d.role)).toEqual(['adversarial-native', 'outside-voice', 'targeted']); + } + }); +}); + +/** + * The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/review-packets-removed.test.ts). + * On a 1,440-case grid (five diffs: light, standard, gate, docs, tests; passes 0-2; last none, full or fix-check; every mix of + * the four fix flags; Step 11 run or not), main's planReview at 1022f4f (v1.92.81.0) hashes to MAIN_PLANS. The branch's plan, + * with each voices step read back as main's stop (its reason is the parenthesised tail, no dispatch), hashes to the same value, + * and every voices step is a gate-class stop before Step 11 that adds exactly Step 11's three voices. No grant is passed (main + * takes none). The brief bodies lib/review-brief.ts cuts from the ship sections hash to MAIN_SECTIONS, main's value. + */ +const MAIN_PLANS = '9d13d6bae86bfc9207ce7e0cec8da5b33db2518c1653e8e009160a98b0f04c89'; +const MAIN_SECTIONS = 'a2b999aa2de2c4cdbb953aeb0a4fac6a67e01a2c5d74121e191b95781e68e345'; +describe('GST-967 dispatch-superset no-loss check', () => { + // Value: protects=the no-loss rule for review-depth changes: GST-967 drops, renames and re-efforts no reviewer main dispatches on any grid case, and changes no brief body; fails_when=a non-stop plan changes, a voices step appears off gate class or after Step 11, it dispatches anything but the three voices, or a brief body moves; + // why_new=GST-967; seam=planReview, sectionTexts; no_claim=what the added voices find (they only add coverage) + test('the grid matches main except gate-class stops before Step 11, which add only the three voices; brief bodies are main\'s', () => { + const sha = (x: unknown) => createHash('sha256').update(JSON.stringify(x)).digest('hex'); + const fixtures: Record> = { + light: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120 }, + standard: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, floor: 'standard' }, + gate: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true' }, gateTagged: 1, diffLines: 120, targeted: { classes: ['gate'] } }, + docs: { paths: ['README.md'], scope: {}, gateTagged: null, diffLines: 30 }, + tests: { paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' }, gateTagged: 0, diffLines: 40 }, + }; + const rows: unknown[] = []; + let voices = 0; + for (const [name, f] of Object.entries(fixtures)) for (const fullPasses of [0, 1, 2]) for (const last of ['none', 'full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) for (const adversarialRan of [false, true]) { + const history = { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8) }; + const p = planReview({ ...f, numbers: none, history, adversarialRan } as PlanInput); + let { step, reason } = p.next; + let dispatches = p.dispatches; + if (step === 'voices') { + voices++; + expect([p.class, adversarialRan, dispatches.map(d => `${d.role}:${d.effort}`)]).toEqual(['gate', false, ['adversarial-native:full', 'outside-voice:host', 'targeted:full']]); + [step, reason, dispatches] = ['stop', reason.slice(reason.indexOf('--adversarial-ran (') + '--adversarial-ran ('.length, -1), []]; + } + rows.push({ name, fullPasses, last, bits, adversarialRan, class: p.class, step, reason, dispatches, skipped: p.skipped }); + } + expect([rows.length, voices, sha(rows)]).toEqual([1440, 71, MAIN_PLANS]); + expect(sha(sectionTexts(ROOT))).toBe(MAIN_SECTIONS); + }); +}); + describe('scanNumbers', () => { test('finds claims and fingerprints them', () => { const a = scanNumbers({ 'docs/bench.md': ['Median dropped 48% to 1.2s', 'no numbers here'] }); diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index 56e140c84..c58e92af0 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { BriefError, checkReviewedTree, promptHash, renderBrief, sectionTexts, workspaceParagraph, workspaceStep } from '../lib/review-brief'; -import { collect, judge, parseFindings, readJsonl, type MergedLine } from '../lib/review-ledger'; +import { collect, judge, parseFindings, rank, readJsonl, type MergedLine } from '../lib/review-ledger'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-review-dispatch'); @@ -305,8 +305,9 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change // Value: protects=a negated severity word still collects at its label, so a reader's closing prose ("found nothing CRITICAL") is a // known phantom lead judgment folds and never a dropped line (GSTA-167 S1 reverts GST-766's negation drop); fails_when=any negated - // form collects nothing or a weaker label; why_new=GST-766's drop lost blockers named in exception clauses; seam=parseFindings; - // no_claim=that a negated line is a real finding (judge folds phantoms) + // form collects nothing or a weaker label, except under the finding's own Severity field, which decides it (GST-976); + // why_new=GST-766's drop lost blockers named in exception clauses; seam=parseFindings; no_claim=that a negated line is a real + // finding (judge folds phantoms) test('a negated severity word still collects at its label: GEVA-92 fix-check#5 and its negated forms are known phantoms', () => { const fixCheck = [ 'REVIEWED_TREE: bedd511efa0fa16c9277821073c68e438dc760d2 /workspace/gstack-evals', @@ -332,7 +333,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change expect(parseFindings('r', 'No CRITICAL lock guards the write in Makefile:7\nSeverity: CRITICAL').map(f => f.label)).toEqual(['CRITICAL']); expect(parseFindings('r', '1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL lock guards the write in Makefile:7\nSeverity: CRITICAL').map(f => [f.label, f.raw])).toEqual([['LOW', '1. auth.ts:3 LOW: log line too verbose'], ['CRITICAL', 'No CRITICAL lock guards the write in Makefile:7 | Severity: CRITICAL']]); expect(parseFindings('r', '1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL issues remain.').map(f => f.label)).toEqual(['LOW', 'CRITICAL']); - expect(parseFindings('outside-voice', '- a.ts:3 race. Severity: Low. Blocks: no\n Separately, nothing CRITICAL about b.ts:9, but it leaks fds.\n Severity: High').map(f => [f.label, f.raw])).toEqual([['Low', '- a.ts:3 race. Severity: Low. Blocks: no'], ['CRITICAL', 'Separately, nothing CRITICAL about b.ts:9, but it leaks fds. | Severity: High']]); + expect(parseFindings('outside-voice', '- a.ts:3 race. Severity: Low. Blocks: no\n Separately, nothing CRITICAL about b.ts:9, but it leaks fds.\n Severity: High').map(f => [f.label, f.raw])).toEqual([['Low', '- a.ts:3 race. Severity: Low. Blocks: no'], ['High', 'Separately, nothing CRITICAL about b.ts:9, but it leaks fds. | Severity: High']]); expect(parseFindings('r', '1. A CRITICAL race in queue.ts:78 that no lock covers').map(f => f.label)).toEqual(['CRITICAL']); expect(parseFindings('r', '- A divide by zero CRITICAL bug in parse()').map(f => f.label)).toEqual(['CRITICAL']); expect(parseFindings('adversarial-native', 'FIXABLE: no timeout on the fetch in a.ts:3').map(f => f.label)).toEqual(['CRITICAL']); @@ -406,8 +407,8 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change expect(parseFindings('outside-voice', '> Blocks: yes').map(f => f.label)).toEqual(['CRITICAL']); }); // Value: protects=a later finding's Severity/Blocks line never lands on the finding before it, and an explicit Severity label - // decides its line (#3660 pass 2 Red Team); fails_when=the later blocker's text is lost and the earlier finding takes its - // CRITICAL, or 'Severity: Low (not High)' collects as High; seam=parseFindings + // decides its line unless a stronger label leads that line (#3660 pass 2 Red Team, GST-976); fails_when=the later blocker's text + // is lost and the earlier finding takes its CRITICAL, or 'Severity: Low (not High)' collects as High; seam=parseFindings test('prose after a finding\'s field, a blank-line paragraph or an indented block starts the next finding; an explicit Severity label wins', () => { const raws = (o: string) => parseFindings('outside-voice', o).map(f => [f.label, f.raw]); const two = [['Low', 'A in a.ts:1. | Severity: Low'], ['CRITICAL', 'B in b.ts:2. | Blocks: yes']]; @@ -603,7 +604,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change for (const line of ['There are no CRITICAL findings.', 'No CRITICAL or High findings to report.', 'I found nothing CRITICAL.', "So there's nothing at CRITICAL.", 'Neither finding is CRITICAL.', 'Nothing here is CRITICAL.', 'Neither fix is CRITICAL.', 'Nothing in this diff is CRITICAL.', 'This is not CRITICAL.']) expect([line, parseFindings('outside-voice', line).map(f => f.label)]).toEqual([line, ['CRITICAL']]); - expect(parseFindings('outside-voice', 'Severity: Low (not High)').map(f => f.label)).toEqual(['High']); + expect(parseFindings('outside-voice', 'Severity: Low (not High)').map(f => f.label)).toEqual(['Low']); }); // Value: protects=item layouts collect as on main: an indented JSON finding keeps its JSON severity, an indented label heading sets @@ -626,6 +627,82 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change .toEqual([['CRITICAL', '{"severity":"low","summary":"race"} | this blocks the merge. Blocks: yes']]); }); + // Value: protects=a finding's own Severity and Blocks fields set its collected label, so a severity word in its body never raises it + // (gstack-evals #143: the outside voice marked six findings Blocks: no, and the "P0" in #1, #2 and #4 was the plan's phase name), + // while a real P0 or CRITICAL in label position, a Severity: P0 field and Blocks: yes still collect as blocking; fails_when=a body + // token outranks the finding's fields, or a label-position P0/CRITICAL or Blocks: yes collects below CRITICAL rank; + // why_new=GST-976: #1, #2 and #4 collected as P0, judge refused the downgrade (JUDGMENT_REJECTED), verify stayed dirty and the + // receipt needed accepted_by; seam=parseFindings, collect and judge; no_claim=that a body-only label on a line with no Severity field + // is a real finding (it still collects at that label, a known phantom judge may fold; GSTA-167 S1 keeps body labels with no fields + // because dropping them lost blockers) + test('GST-976: a severity word in a finding body never outranks its Severity and Blocks fields; real P0 and CRITICAL still block', () => { + const blocking = (label: string) => rank(label) === 4; + // The #143 outside-voice reply, cut to each finding's title, its P0 sentence and its fields (gstack-evals is private). + const reply = [ + 'The problems below are about accounting and what the post leaves out. None of them breaks a test, production or custody.', + '', + '1. **The per-task cost leaves out model spend.** The P0 and bulk go or no-go will re-project cost from these figures, so they will come out too low. Severity: Medium. Blocks: no.', + '', + '2. **One round-2 K1 pair is not accounted for.** The post sets a precedent for P0 yield math, so that drop should be named. Severity: Low. Blocks: no.', + '', + '3. **The two probe populations are not reconciled.** The stage table probes 189 candidates. Severity: Low. Blocks: no.', + '', + "4. **The P0 impact of the shortfall is missing.** The plan's P0 (line 323) needs 6 K4 replays and 2 decoys. The post says the shortfall but not that it blocks P0. Severity: Low. Blocks: no.", + '', + '6. **Some wording is ambiguous.** Two examples:', + ' - "Repositories 4 (3 for build tasks)" is set against a target of "at least 5 repositories".', + '', + ' Severity: Low. Blocks: no.', + '', + "Recommendation: ship after a one-line fix because finding 1's per-task cost leaves out measured model spend.", + ].join('\n'); + const d = tmp(); + const merged = path.join(d, 'merged.jsonl'); + const r = collect(path.join(d, 'l.jsonl'), 'outside-voice', reply, merged); + expect(r.lines.map(l => [l.label, l.stated])).toEqual([['Medium', { severity: 'Medium', blocks: 'no' }], ...Array(4).fill(['Low', { severity: 'Low', blocks: 'no' }])]); + expect(r.lines.filter(l => blocking(l.label)).map(l => l.id)).toEqual([]); + const judged = path.join(d, 'judged.jsonl'); + fs.writeFileSync(judged, r.lines.map(l => JSON.stringify({ id: l.id, severity: l.stated!.severity })).join('\n') + '\n'); + expect(judge(readJsonl(merged), readJsonl(judged))).toMatchObject({ ok: true, downgraded: [] }); + const real: Array<[string, string]> = [['[P0] Token leak in log.ts:3', 'P0'], ['P0: token leak in log.ts:3', 'P0'], ['1. Token leak in log.ts:3\n Severity: P0', 'P0'], + ['1. The P0 pilot drops the last row (q.ts:7). Severity: P0. Blocks: no', 'P0'], ['**Critical**\n- Token leak in log.ts:3', 'Critical'], ['## P0\n- Token leak in log.ts:3', 'P0'], + ['### CRITICAL: missing auth check in api.ts:10', 'CRITICAL'], ['1. The P0 pilot loses rows on retry (r.ts:2). Severity: Low. Blocks: yes', 'CRITICAL'], + ['1. The P0 pilot loses rows on retry (r.ts:2)\n Severity: Medium\n Blocks: yes', 'CRITICAL']]; + for (const [output, label] of real) { + const got = parseFindings('outside-voice', output).map(f => f.label); + expect([output, got, got.map(blocking)]).toEqual([output, [label], [true]]); + } + expect(parseFindings('outside-voice', '1. The P0 pilot table drops a row (t.md:3)').map(f => f.label)).toEqual(['P0']); + // Review of this fix (gst976-p1): a locus-first or emoji-led label, an inline field the next field line follows, a second field on + // the line, and prose that only mentions a severity never lower a blocker the reader labeled. + const kept: Array<[string, string]> = [['1. api.ts:10 CRITICAL: missing auth. Severity: Low', 'CRITICAL'], ['1. api.ts:10 CRITICAL: missing auth\n Severity: Low', 'CRITICAL'], + ['1. 🔴 CRITICAL: missing auth. Severity: Low', 'CRITICAL'], ['1. Token leak in auth.ts:3 lets anyone read sessions. Severity: Critical\n\nSeverity: Low', 'Critical'], + ['- auth.ts:3 token leak. Severity: Low (pre-exploit), Severity: Critical (once exposed)', 'Critical'], + ['- Missing auth on DELETE /users lets any user wipe accounts (CRITICAL); the old ticket filed it at severity low.', 'CRITICAL'], + ['- api.ts:10 lets any user wipe accounts. Earlier triage said Severity: Low; it is CRITICAL.', 'CRITICAL']]; + for (const [output, label] of kept) expect([output, parseFindings('outside-voice', output).map(f => f.label)]).toEqual([output, [label]]); + // gst976-p2: an all-caps lead before a space, comma or parenthesis; a field after a locus's parenthesis or with no colon at the + // line's start; and a long run of marks before a label stays linear. + const p2: Array<[string, string]> = [['1. CRITICAL missing auth. Severity: Low', 'CRITICAL'], ['1. CRITICAL, missing auth on DELETE. Severity: Low', 'CRITICAL'], + ['1. CRITICAL (api.ts:10) missing auth. Severity: Low', 'CRITICAL'], ['1. The P0 pilot table drops a row (t.md:3) Severity: Low', 'Low'], + ['1. The P0 pilot table drops a row\nSeverity Low', 'Low'], ['1. The P0 pilot table drops a row\n**Severity** Low', 'Low'], + ['1. Bypass x.ts:3 is CRITICAL (Severity: Low in pass 1)', 'CRITICAL'], ['Still open: x.ts:3 is CRITICAL, Severity: Low in pass 1 understated it', 'CRITICAL'], + ['1. lib/x.ts:42-58 CRITICAL: drops rows. Severity: Low', 'CRITICAL'], ['1. lib/x.ts:42:7 CRITICAL: drops rows. Severity: Low', 'CRITICAL'], + ['1. (lib/x.ts:42, 50) CRITICAL: drops rows. Severity: Low', 'CRITICAL'], + ['1. The P0 pilot table drops a row\nSeverity: Low: only the pilot', 'Low'], ['1. The P0 pilot table drops a row\nSeverity: Low because it is internal', 'Low'], + ['1. The P0 pilot table drops a row\nSeverity: Low/Medium', 'P0'], ['Severity: Low: only the P0 pilot table', 'Low'], + ['1. The P0 pilot drops a row. Severity: Low/P3', 'P0'], ['1. drops rows\nSeverity: High/Critical', 'Critical'], + ['Severity: Medium/CRITICAL', 'CRITICAL'], ['1. x.ts:3 drops prod rows. Severity: Low/CRITICAL', 'CRITICAL'], + ['1. x.ts:3 is CRITICAL (Severity: Low/Medium in pass 1)', 'CRITICAL'], ['1. x.ts:3 is CRITICAL (Severity: Low: pass 1)', 'CRITICAL'], + ['Still open: x.ts:3 is P0, Severity: Low: pass 1 understated it', 'P0']]; + for (const [output, label] of p2) expect([output, parseFindings('outside-voice', output).map(f => f.label)]).toEqual([output, [label]]); + const t0 = performance.now(); + for (const run of ['='.repeat(60), '-'.repeat(60), '|' + '-'.repeat(60) + '|', '🔴'.repeat(60), '. '.repeat(60), '-.'.repeat(60), '`'.repeat(60)]) + for (const tail of [' P0 pilot table', ' High-risk', ' CRITICAL findings follow', ' a.ts:3 P0 pilot']) parseFindings('outside-voice', run + tail + '. Severity: Low'); + parseFindings('outside-voice', '-'.repeat(16000) + ' P0 pilot table. Severity: Low'); + expect(performance.now() - t0).toBeLessThan(1000); + }); + // Value: protects=a non-review the same dispatch later answers with a review is voided (counted, VERIFY clean), and a failed outside // voice recorded with --unavailable reads UNAVAILABLE (missing coverage, never a review) without blocking, while --unavailable is // refused for any other reader; fails_when=one outside-voice timeout or one refusal makes VERIFY dirty for good, or --unavailable diff --git a/test/review-ledger-invariant.test.ts b/test/review-ledger-invariant.test.ts new file mode 100644 index 000000000..f2edd39d3 --- /dev/null +++ b/test/review-ledger-invariant.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseFindings, rank } from '../lib/review-ledger'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const LABELS = 'CRITICAL|INFORMATIONAL|Critical|Informational|High|HIGH|Medium|MEDIUM|Low|LOW|P[0-3]|FIXABLE|INVESTIGATE'; +const CHAIN = `(?:${LABELS})(?:\\s*[/→]\\s*\\**\\s*(?:${LABELS}))*`; +/** A finding's own Severity field as GST-976 defines it: at a line's start (colon optional), or after a separator with its colon and a closed clause. */ +const FIELD = new RegExp(`(?:(?:^|\\| )(?:[-*+>]\\s+|\\d+[.)]\\s+)?\\**\\s*Severity\\**\\s*:?\\s*\\**\\s*(${CHAIN})(?![A-Za-z0-9])|(?:[.;,|()]\\s*|\\s[—–-]\\s*)\\**\\s*Severity\\**\\s*:\\s*\\**\\s*(${CHAIN})(?![A-Za-z0-9])(?=\\**\\s*(?:$|[.;,|()*—–]|-\\s|Blocks)))`, 'gi'); +const LEAD = new RegExp(`(?:^|\\| )[^\\p{L}\\p{N}]*(?:\\d+[.)]\\s*[^\\p{L}\\p{N}]*)?(?:\\w[\\w./-]*:\\d+(?:[-–:,]\\s*\\d+)*\\S*\\s+[^\\p{L}\\p{N}]*)?(?:${LABELS})(?![a-z])`, 'u'); +const HEADING = new RegExp(`^(?:#{1,6}\\s*|\\*\\*)(?:${LABELS})\\b`, 'm'); +const LABEL = new RegExp(`${LABELS}`, 'gi'); + +describe('parseFindings is never weaker than main (GST-976 invariant)', () => { + // Value: protects=no input the suite pins, no reader fixture and no Severity field shape collects below the label main (82e9d7ec) + // collected, unless the finding's own Severity field states exactly one label, and such a finding collects at that label unless a + // leading label, a label heading, Blocks: yes or the native voice's FIXABLE raises it; fails_when=a field chain (High/Critical) keeps + // its weaker label (b3ddd6c) or a field whose clause runs on stops counting and a body P0 raises the finding (f3810d4); + // why_new=three GST-976 review rounds each found a shape the row tables missed; seam=parseFindings; no_claim=that main's label was + // right (it is the floor), or coverage of stored private reader outputs (checked outside the repo) + test('every pinned input, reader fixture and generated field shape', () => { + const { entries, shapes } = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/main-labels.json'), 'utf8')); + const { bodies, fields: tails, joins, readers, generated } = shapes; + for (const [b, f, j, r, main] of generated) entries.push({ reader: readers[r], output: bodies[b] + joins[j] + tails[f], main }); + const bad: string[] = []; + let findings = 0; + for (const e of entries as Array<{ reader: string; output?: string; fixture?: string; main: string[] }>) { + const output = e.output ?? fs.readFileSync(path.join(ROOT, 'test/fixtures', e.fixture!), 'utf8'); + const got = parseFindings(e.reader, output); + if (!e.main.length) continue; + if (got.length !== e.main.length) { bad.push(`count ${got.length}/${e.main.length} ${JSON.stringify(output.slice(0, 80))}`); continue; } + got.forEach((f, i) => { + findings++; + const fields = [...f.raw.matchAll(FIELD)].map(m => (m[1] ?? m[2])!); + const single = fields.length === 1 && fields[0]!.match(LABEL)!.length === 1 ? fields[0]!.match(LABEL)![0] : undefined; + const raised = /Blocks:?\**:?\s*\**\s*yes/i.test(f.raw) || (e.reader.startsWith('adversarial-native') && /FIXABLE/.test(f.raw)) || LEAD.test(f.raw) || HEADING.test(output); + const ok = single === undefined ? (rank(f.label) ?? 0) >= (rank(e.main[i]) ?? 0) + : (rank(f.label) ?? 0) >= (rank(single) ?? 0) && (raised || (rank(f.label) ?? 0) === (rank(single) ?? 0)); + if (!ok) bad.push(`${e.reader} main=${e.main[i]} got=${f.label} field=${single ?? '-'} ${JSON.stringify(f.raw.slice(0, 100))}`); + }); + } + expect(bad).toEqual([]); + expect(findings).toBeGreaterThan(1000); + }); +}); diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts index 1ae42964e..fa40c3db5 100644 --- a/test/review-packets-removed.test.ts +++ b/test/review-packets-removed.test.ts @@ -61,11 +61,12 @@ const NO_LOSS_REPLAYS = { 'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged", 'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged", 'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged", + 'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged", } as const; type Replay = keyof typeof NO_LOSS_REPLAYS; const REVIEWER_BRIEFS: Record = { - 'ship/sections/review-army.md': { sha: 'b7ca291a1cc63750bfb9710a4c12fd971523b9598da2a32ef80fb258eb0eceab', replay: 'L' }, - 'ship/sections/adversarial.md': { sha: 'd776ee5bc6e0c13930f8f4de05bad2f1ed9e52bc9fc5df80ae3ba7680455b2bb', replay: 'GST-257' }, + 'ship/sections/review-army.md': { sha: '7c58dfd1bf1f16f967186a4872191cb16ebff3be81a50ebbf19067020af3ec53', replay: 'GST-967' }, + 'ship/sections/adversarial.md': { sha: '5d712bb568573931108a79b1bd40b74db6189610f47c547099c13098ef67bef8', replay: 'GST-967' }, 'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' }, 'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' }, 'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' }, @@ -82,7 +83,7 @@ const REVIEWER_BRIEFS: Record = { 'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' }, 'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' }, 'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' }, - 'lib/review-depth.ts': { sha: '166dd7b1148a272b345dd9d2ceccd14348cc59c52ef9c88109436f6f61cf6b69', replay: 'GST-911-redos' }, + 'lib/review-depth.ts': { sha: 'c5fd958b6c9a1116be9fc3418844239c01168476abb72c1db85bc26ab0adf4b4', replay: 'GST-967' }, 'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' }, }; diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 10ffa4f8d..2e8ec1126 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -740,6 +740,26 @@ describe('Capy pass program (fake host, real bins)', () => { expect(h.creates.find(c => c.title === 'ship x21p2 red-team')!.prompt).toContain('DIFF_BASE=' + rangeBase); }, 240_000); + // Value: protects=the pass program dispatches the owner's granted fix-check from the plan, as decision 3.5 runs any fix-check, instead of PASS: none; fails_when=a recorded grant still prints PASS: none next=stop and the lane renders, sends and collects the fix-check by hand (C6-36 after-opus-grant); + // why_new=GST-967; seam=the pass program; no_claim=the fix-check reviewer's verdict + test('GST-967: a recorded grant after round 2 runs its one scoped fix-check through the program', async () => { + const { dir, env } = repo(); + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const add = spawnSync(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${head.slice(0, 12)})`, '--words', 'one scoped fix-check after round 2', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: dir, env: { ...env, GSTACK_APPROVALS_MIRROR: '0' }, encoding: 'utf8', timeout: 60_000 }); + expect(add.status, add.stderr).toBe(0); + const id = JSON.parse(add.stdout).added[0] as string; + const files = path.join(dir, '.state/gfc'); + fs.mkdirSync(files, { recursive: true }); + const fc: Record = {}; + for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n'], ['callers', 'none\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); } + const h = host(dir, env); + const res = await runPass(h, { base: 'main', out: path.join(dir, '.state/run/gfc'), pass: 'x30gfc', planFlags: ['--pass', '2', '--last', 'full', '--fixed-blocking', '--range-base', head, '--grant', id], fixCheck: fc, logStart: ['review-fix-check'] }); + expect(res).not.toContain('PASS: none'); + expect(res).toContain(`NEXT: fix-check (the owner's grant ${id}`); + expect(h.creates.map(c => c.title)).toEqual(['ship x30gfc fix-check']); + expect(res).toContain('NEXT: judge (Phase 4)'); + }, 240_000); + // Value: protects=the Capy pass program runs a scoped repeat with no fix-check files, dispatches its Red Team and leaves the fix-check to Step 3.5 as GST-619 does on the CLI; fails_when=the program stops a scoped plan for missing files or creates a fix-check reader in the scoped manifest; // why_new=#3670 Step 11 (adversarial 1, red team 1), re-scoped to GST-619's model in the merge of main; seam=none; no_claim=the Step 3.5 fix-check itself test('a scoped repeat runs without fixCheck files and leaves its fix-check to Step 3.5 (GST-619)', async () => { diff --git a/test/review-plan-cli.test.ts b/test/review-plan-cli.test.ts index eee3e5159..1fe679b17 100644 --- a/test/review-plan-cli.test.ts +++ b/test/review-plan-cli.test.ts @@ -62,6 +62,43 @@ describe('gstack-review-plan', () => { expect(r.stdout).not.toContain('NEXT: stop'); expect(run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'fix-check', '--fixed-below', '--blocking-open']).stdout).toContain('NEXT: stop'); }); + // Value: protects=a grant the owner recorded with gstack-approvals is something the plan can use, once, in the invocation it answers: --grant plans the one scoped fix-check after a round-2 full pass when the row names review_loop_grant and the stop head (at or after --range-base, at or before HEAD); the fix-check never earns another; an unrecorded id, an unrelated approval, a review_loop_exception acceptance row, or a grant for an earlier head is refused; fails_when=the CLI ignores the grant and prints NEXT: stop (C6-36 after-opus-grant), accepts any of those rows, or grants a second fix-check; + // why_new=GST-967 (round-2 red team: a grant from an earlier /ship on the branch could be reused); seam=bin/gstack-review-plan; no_claim=who may grant (the owner's words are the approval record's) + test('GST-967: --grant plans the one scoped fix-check for the stop head it names; any other row, or a grant for an earlier head, is refused', () => { + const r = repo({ 'src/a.ts': 'export const a = 1;\n' }); + const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_STATE_ROOT: r.state, GSTACK_APPROVALS_MIRROR: '0' }; + delete env.GSTACK_APPROVALS_DIR; + const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const commit = (n: number) => { fs.appendFileSync(path.join(r.dir, 'src/a.ts'), `export const v${n} = ${n};\n`); git('commit', '-qam', `c${n}`); return git('rev-parse', 'HEAD'); }; + const stale = git('rev-parse', 'HEAD'); + const passHead = commit(1); + const stopHead = commit(2); + const add = (ask: string) => { + const a = spawnSync(path.join(path.dirname(BIN), 'gstack-approvals'), ['add', '--ask', ask, '--words', 'do it', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + expect(a.status, a.stderr).toBe(0); + return JSON.parse(a.stdout).added[0] as string; + }; + const id = add(`Owner grant: one scoped fix-check after round 2 (review_loop_grant ${stopHead.slice(0, 12)})`); + const refusedIds = [add(`Owner grant from an earlier run (review_loop_grant ${stale.slice(0, 12)})`), add('Owner grant: one scoped fix-check (review_loop_grant)'), + add(`Owner accepts the stop after the fix-check (review_loop_exception ${stopHead.slice(0, 12)})`), add('Approve the plan for the README rewrite'), 'a-000000000000']; + const plan = (a: string[]) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', ...a], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + const capStop = ['--pass', '2', '--last', 'full', '--fixed-blocking', '--range-base', passHead]; + expect(plan(capStop).stdout).toContain('NEXT: stop'); + const granted = plan([...capStop, '--grant', id]); + expect(granted.status, granted.stderr).toBe(0); + expect(granted.stdout).toContain(`NEXT: fix-check (the owner's grant ${id}`); + expect(granted.stdout).toContain('DISPATCH: fix-check fix-check effort=full'); + commit(3); + expect(plan([...capStop, '--grant', id]).stdout).toContain(`NEXT: fix-check (the owner's grant ${id}`); + expect(plan(['--pass', '2', '--last', 'fix-check', '--fixed-blocking', '--range-base', passHead, '--grant', id]).stdout).toContain('NEXT: stop'); + for (const other of refusedIds) { + const refused = plan([...capStop, '--grant', other]); + expect([other, refused.status, refused.stdout]).toEqual([other, 3, '']); + expect(refused.stderr).toContain(`--grant ${other}`); + } + expect(plan([...capStop.slice(0, -2), '--grant', id]).status).toBe(2); + expect(plan([...capStop, '--grant', '--json']).status).toBe(2); + }); test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => { const r = repo({ 'docs/howto.md': '# x\n' }); const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; }; diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts index 9ef2217d3..8cf6ca497 100644 --- a/test/review-targeted.test.ts +++ b/test/review-targeted.test.ts @@ -3,7 +3,7 @@ * (lib/review-targeted.ts, docs/review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code). * The classifier, the plan's TARGETED and DISPATCH lines, the dispatch-superset no-loss check * against main's reviewers, the production path from render to the receipt and the review-loop - * summary, and the queue's report-only REVIEW_TARGETED line. + * summary, and the queue's REVIEW_TARGETED verdict (refused when a required class is uncovered, GST-966). */ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; @@ -15,7 +15,7 @@ import { classifyDiff, planReview, scanNumbers, type PlanInput } from '../lib/re import { raisedBy, summarizeReviewLoop } from '../lib/review-scope'; import { TARGETED_RULES, targetedBrief, targetedBriefSha, targetedClasses, type TargetedClass, type TargetedInput } from '../lib/review-targeted'; import { carryReceipt, extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; -import { bindShippedReceipt, parseReviewTargeted, receiptTargetedLine } from '../lib/ship-receipt-depth'; +import { bindShippedReceipt, parseReviewTargeted, receiptTargetedVerdict } from '../lib/ship-receipt-depth'; import type { ShipReceipt } from '../lib/headless-artifacts'; import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; import { checkoutTree } from '../lib/review-brief'; @@ -333,9 +333,9 @@ describe('the targeted role end to end on a fixture repository', () => { expect(ok).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=money verdict=ok' }); const { review_targeted: _drop, ...legacy } = receipt; const missing = bindShippedReceipt(repo.work, 'origin/main', renderReceiptBlock(legacy as ShipReceipt), undefined, [head]); - expect(missing).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=legacy verdict=missing' }); + expect(missing).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_TARGETED_MISSING', targetedLine: 'REVIEW_TARGETED: required=money receipt=legacy verdict=missing' }); const failed = bindShippedReceipt(repo.work, 'origin/main', renderReceiptBlock({ ...receipt, review_targeted: { ...receipt.review_targeted!, status: 'failed' } }), undefined, [head]); - expect(failed).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=none verdict=missing' }); + expect(failed).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_TARGETED_MISSING', targetedLine: 'REVIEW_TARGETED: required=money receipt=none verdict=failed' }); }, 180_000); }); @@ -345,16 +345,19 @@ describe('the receipt record and the queue line', () => { expect(parseReviewTargeted(record, 'p1')).toEqual({ ok: true, targeted: { ...record, classes: ['money', 'gate'], product_patch_id: 'p1' } as any }); for (const bad of [{ ...record, classes: ['cost'] }, { ...record, status: 'skipped' }, { ...record, findings: -1 }, { ...record, brief_sha: 'abc' }, { ...record, extra: 1 }, null]) expect(parseReviewTargeted(bad, 'p1').ok).toBe(false); }); - // Value: protects=PR 1's queue only reports; fails_when=the line misreads a legacy, failed or partial receipt as covered, or a - // receipt without classes as required; seam=receiptTargetedLine (the line ready and train build print) - test('REVIEW_TARGETED reports ok, missing and legacy', () => { + // Value: protects=the queue refuses an uncovered required class (GST-966); fails_when=the verdict misreads a legacy, failed or partial + // receipt as covered, or a receipt without classes as required; seam=receiptTargetedVerdict (what ready, train build and land refuse on) + test('REVIEW_TARGETED reports ok, missing, failed and legacy', () => { const r = (rt?: object) => ({ schema_version: 1, head: 'h', base: 'main', ...(rt ? { review_targeted: { ...record, product_patch_id: 'p', ...rt } } : {}) }) as ShipReceipt; - expect(receiptTargetedLine([], r())).toBe('REVIEW_TARGETED: required=none receipt=legacy verdict=ok'); - expect(receiptTargetedLine(['money'], r())).toBe('REVIEW_TARGETED: required=money receipt=legacy verdict=missing'); - expect(receiptTargetedLine(['money'], r({}))).toBe('REVIEW_TARGETED: required=money receipt=gate,money verdict=ok'); - expect(receiptTargetedLine(['money', 'secret'], r({}))).toBe('REVIEW_TARGETED: required=money,secret receipt=gate,money verdict=missing'); - expect(receiptTargetedLine(['gate'], r({ status: 'failed' }))).toBe('REVIEW_TARGETED: required=gate receipt=none verdict=missing'); - expect(receiptTargetedLine([], r({ classes: [], status: 'not-run' }))).toBe('REVIEW_TARGETED: required=none receipt=none verdict=ok'); + expect(receiptTargetedVerdict([], r())).toEqual({ ok: true, line: 'REVIEW_TARGETED: required=none receipt=legacy verdict=ok' }); + expect(receiptTargetedVerdict(['money'], r())).toEqual({ ok: true, line: 'REVIEW_TARGETED: required=money receipt=legacy verdict=missing (legacy receipt, written before receipts recorded review_targeted; accepted)' }); + const current = { ...r(), gstack_version: '1.92.61.0' } as ShipReceipt; + expect(receiptTargetedVerdict(['money'], current)).toMatchObject({ ok: false, line: 'REVIEW_TARGETED: required=money receipt=legacy verdict=missing', message: expect.stringContaining('records no targeted pass') }); + expect(receiptTargetedVerdict(['money'], r({}))).toEqual({ ok: true, line: 'REVIEW_TARGETED: required=money receipt=gate,money verdict=ok' }); + expect(receiptTargetedVerdict(['money', 'secret'], r({}))).toMatchObject({ ok: false, line: 'REVIEW_TARGETED: required=money,secret receipt=gate,money verdict=missing', message: expect.stringContaining('on secret, but the receipt covers gate,money only') }); + expect(receiptTargetedVerdict(['gate'], r({ status: 'failed' }))).toMatchObject({ ok: false, line: 'REVIEW_TARGETED: required=gate receipt=none verdict=failed', message: expect.stringContaining('targeted pass failed') }); + expect(receiptTargetedVerdict(['gate'], r({ status: 'not-run' }))).toMatchObject({ ok: false, line: 'REVIEW_TARGETED: required=gate receipt=none verdict=missing', message: expect.stringContaining('did not run') }); + expect(receiptTargetedVerdict([], r({ classes: [], status: 'not-run' }))).toEqual({ ok: true, line: 'REVIEW_TARGETED: required=none receipt=none verdict=ok' }); }); test('a carry keeps review_targeted with the unchanged product patch', () => { const receipt = { schema_version: 1, head: 'a'.repeat(40), base: 'main', product_patch_id: 'p', review_targeted: { ...record, product_patch_id: 'p' } } as ShipReceipt; diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index 8ae814052..4dd9f7f27 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -136,4 +136,19 @@ describe('stamped train prefixes', () => { const product = tamper('a.txt', () => 'not a\n'); expect(stampMismatch(o, merged, product, o.plain[2]!.tree)).toContain('a.txt'); }, 60_000); + + // Value: protects=the train's stamp check bounds a branch-added row the same way the train restamp writes it, so a pin + // with a row left at the fork version still stamps; fails_when=stampMismatch reads that row against the pin's own + // VERSION and refuses the moved row; seam=lib/ship-queue-train-stamp.ts stampMismatch baseRef (GST-963) + test('a pin claiming 1.2.4.0 with a stamp_paths row left at the base 1.2.3.0 stamps, and the row moves (GST-963)', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + pin(r, 'f', 'origin/main', '- f change\n'); + fs.writeFileSync(path.join(r.work, 'VERSION'), '1.2.4.0\n'); + fs.writeFileSync(path.join(r.work, 'docs', 'f.md'), '// since: 1.2.3.0\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'f: claims 1.2.4.0, row at the fork version'); + const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]])); + if ('code' in out) throw new Error(`${out.code}: ${out.message}`); + expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0']); + expect(show(r, out.stamped[0]!.commit + ':docs/f.md')).toBe('// since: 1.2.4.0'); + }, 60_000); }); diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts index 4b7cf240f..2fd996f70 100644 --- a/test/ship-receipt-depth.test.ts +++ b/test/ship-receipt-depth.test.ts @@ -66,8 +66,11 @@ describe('the queue refuses a receipt below the depth its head needs', () => { expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', runtime_lines: 80, risk: ['money'] }); const base = repo.git('rev-parse', 'origin/main').slice(0, 12); const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]); - const good = bind(fs.readFileSync(body, 'utf8')); - expect(good.ok).toBe(true); + const untargeted = bind(fs.readFileSync(body, 'utf8')); + expect(!untargeted.ok && untargeted.code).toBe('QUEUE_RECEIPT_TARGETED_MISSING'); + expect(!untargeted.ok && untargeted.depthLine).toContain('REVIEW_DEPTH: receipt=full required=full'); + const good = bind(rewrite(body, r => { r.review_targeted = { classes: ['money'], rules: 'r', brief_sha: 'a'.repeat(64), product_patch_id: r.product_patch_id, status: 'done', findings: 0, raised_by: 'targeted' }; })); + expect(good.ok, !good.ok ? good.message : '').toBe(true); expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=full required=full runtime_lines=80 class=code'); const low = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; })); diff --git a/test/ship-receipt-prose.test.ts b/test/ship-receipt-prose.test.ts index e5ff3a02e..2166c42df 100644 --- a/test/ship-receipt-prose.test.ts +++ b/test/ship-receipt-prose.test.ts @@ -118,6 +118,30 @@ describe('the rendered Approvals block is not prose', () => { }); }); +describe('the Review-loop exception line (GST-977, gstack-evals #139)', () => { + const excepted = { + ...receipt, + review_loop: { ...receipt.review_loop, fix_checks: 1 }, + gate: { status: 'green', run: '139', unit: { pass: 1181, skip: 70 } }, + review_loop_exception: { accepted_by: 'Garry', date: '2026-11-01', via: 'GEVA-2', reason: 'outside 1 and 2 skip detector', fix_check: { head: 'b'.repeat(40), findings: 0 } }, + }; + const line = 'Review-loop exception: accepted by Garry 2026-11-01 via GEVA-2: outside 1 and 2 skip detector (fix-check at bbbbbbbbbbbb, 0 findings)'; + + // Value: protects=a body that carries the exception line write requires can also pass --check-prose; fails_when=the + // owner's reason is read as counter claims ("2 skip" against 70); seam=lib/ship-receipt-prose.ts checkReceiptProse + test('the exact line the receipt\'s exception requires is not prose; the rest of the body still is', () => { + expect(checkReceiptProse(excepted, `${line}\n\nGate: 70 skip.\n`)).toEqual({ checked: 1, mismatches: [] }); + expect(checkReceiptProse(excepted, ` ${line}\r\nGate: 69 skip.`).mismatches).toEqual([{ field: 'gate.unit.skip', prose: 69, receipt: 70 }]); + }); + + test('the same words anywhere else, or a line for a different exception, stay prose', () => { + const twoSkip = [{ field: 'gate.unit.skip', prose: 2, receipt: 70 }]; + expect(checkReceiptProse(excepted, `Note: ${line}`).mismatches).toEqual(twoSkip); + expect(checkReceiptProse(excepted, line.replace('GEVA-2', 'GEVA-3')).mismatches).toEqual(twoSkip); + expect(checkReceiptProse({ ...excepted, review_loop_exception: undefined }, line).mismatches).toEqual(twoSkip); + }); +}); + describe('gstack-ship-receipt read --check-prose', () => { const run = (body: string) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-')); diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 581c95713..4e465c0a4 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -206,7 +206,8 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => // Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older // than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the // base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row - // at or older than the fork point, moved the same way still stops; + // older than the fork point, moved the same way still stops; a branch row at the fork version is the branch's own + // stale stamp and routes as metadata (GST-963, gbrain #8425); // fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or // drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] }); @@ -239,7 +240,7 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => const atFork = lostSlot('1.0.0.0'); atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version'); const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]); - expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]); + expect([lineOf(a.stdout, 'ROUTE'), lineOf(a.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]); const edited = lostSlot('1.0.1.0'); edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin'); diff --git a/test/size-ledger/ship/gst-967-gate-voices.json b/test/size-ledger/ship/gst-967-gate-voices.json new file mode 100644 index 000000000..413372cc8 --- /dev/null +++ b/test/size-ledger/ship/gst-967-gate-voices.json @@ -0,0 +1,4 @@ +{ + "bytes": 476, + "why": "GST-967: Step 11 voices before any gate-class stop (NEXT: voices) and the owner's recorded grant (--grant) in the repeat decision and Step 11" +} diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index 245c11962..569cc83cd 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { stampOnlyLine } from '../lib/restamp'; +import { stampOnlyLine } from '../lib/stamp-only'; import { BASE_POLICY, cleanup, ghShim, makeFixtureRepo, makePr, readFile, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; diff --git a/ubicloud/SKILL.md b/ubicloud/SKILL.md index 3b90355ae..b7a277425 100644 --- a/ubicloud/SKILL.md +++ b/ubicloud/SKILL.md @@ -52,9 +52,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse). diff --git a/write-skill/SKILL.md b/write-skill/SKILL.md index f579737cc..9042b9edd 100644 --- a/write-skill/SKILL.md +++ b/write-skill/SKILL.md @@ -55,9 +55,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/ ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. -**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides. +**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides. **Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).