diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index 3ec81cf09..275bee55d 100755
--- a/bin/gstack-diff-scope
+++ b/bin/gstack-diff-scope
@@ -36,6 +36,15 @@ _default_base() {
BASE="${1:-$(_default_base)}"
HEAD_REF=""
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
+_twin=""
+case "$BASE" in
+ HEAD|refs/*|*[~^@:]*) ;;
+ origin/*) if git show-ref --verify -q "refs/remotes/$BASE"; then BASE="refs/remotes/$BASE"; fi ;;
+ *) if git remote | grep -qxF "${BASE%%/*}"; then
+ if git show-ref --verify -q "refs/remotes/$BASE"; then BASE="refs/remotes/$BASE"; fi
+ elif ! printf '%s' "$BASE" | grep -Eq '^[0-9a-f]{7,64}$'; then _twin="refs/remotes/origin/${BASE}"; fi ;;
+esac
+if [ -n "$_twin" ] && git show-ref --verify -q "$_twin" && git merge-base "$_twin" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then BASE="$_twin"; fi
FRONTEND=false
BACKEND=false
diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff
index 0a1af715c..3d817a385 100755
--- a/bin/gstack-gate-diff
+++ b/bin/gstack-gate-diff
@@ -148,7 +148,7 @@ function main(argv: string[]): number {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
- if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`);
+ if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
diff --git a/changelog.d/gst-464-receipt-targeted-base.md b/changelog.d/gst-464-receipt-targeted-base.md
new file mode 100644
index 000000000..145394224
--- /dev/null
+++ b/changelog.d/gst-464-receipt-targeted-base.md
@@ -0,0 +1,14 @@
+**The /ship receipt now carries the targeted reader's record.** When the review plan asked for the targeted reader on money, gate, auth or secret code and the reader ran, `gstack-ship-queue ready` still printed `REVIEW_TARGETED: … receipt=legacy verdict=missing` (gstack-evals#95, #3682, #3612), because nothing passed the record from Step 11 to the receipt. Enforcement stays report-only (GST-464).
+
+**A stale base no longer stands in for the PR's base (GST-462).** `gstack-review-plan --base main` read a local `main` that was behind `origin/main`, so commits the base already had counted as the branch's own change. #3597 was labeled gate code at full depth until it passed `--base origin/main`, and gbrain hit the same problem with a stale local `master` (GBRA-218). On harness-evals#152, a stale base made /ship review a 17,000-file diff of main's history instead of the PR (HARN-15).
+
+### Fixed
+- **Step 11** saves the targeted record to `.gstack/tmp/targeted.json` (`gstack-review-dispatch verify --targeted-out`), and the Step 19 and handoff `gstack-ship-receipt write` commands pass it as `--review-targeted` whenever that file exists. Every Step 9 entry creates `.gstack/tmp`, adds it to `.git/info/exclude` and deletes the file. So a pass without the reader leaves the receipt legacy, an earlier pass's record can't carry over, and the record never counts as an uncommitted edit.
+- **`gstack-ship-preflight`** fetches the base into `origin/` with an explicit refspec, so a single-branch clone also diffs against the current base tip. When a PR is open at HEAD and the diff lists far more files than the PR changes (more than twice as many, plus 50), or fewer, it stops before any reader runs. Fewer files means origin's base is ahead of the PR's, as in a fork, and the gate scan would miss commits the PR lands. The stop names the PR's base, or the fork case when the two names match.
+- **`gstack-gate-diff` and `gstack-diff-scope`** compare against origin's tracking branch whenever it shares history with HEAD, the same base `gstack-review-plan`'s path list reads. A local `main` behind it counted commits the base already had; one ahead of or diverged from it left out commits the branch lands. gate-diff says which (`local main is ahead of origin/main; base is origin/main`). This applies whether you name the base or let the tool detect the default. A local branch stays the base only when origin's is missing or unrelated. Tracking branches are read by their full names (`refs/remotes/origin/main`), so while that branch exists, a tag or a fetched branch called `origin/main` can't stand in, whether you pass `main`, `origin/main` or nothing. A pinned sha is never redirected. `gstack-review-plan` reads both, so its gate scan and scope now match the merge base it already took from `origin/`.
+
+### Tests
+- `test/review-targeted.test.ts`: the generated sections' own commands carry the record from Step 11's verify to `gstack-ship-receipt write`, and ready's line reads `verdict=ok`. A run without the reader passes no flag, and Step 9 clears the file, even when `.git/info` is missing or the exclude file has no final newline.
+- `test/ship-preflight.test.ts`: a single-branch clone's preflight refreshes `origin/main`, and a 60-file diff against a 3-file or a 61-file PR stops at the pushed head. The stop quotes an unsafe base name and names the fork case. Negative controls: another head, a matching size, or a zero count goes on.
+- `test/review-plan-cli.test.ts`, `test/gate-diff-cli.test.ts`, `test/diff-scope.test.ts`: a local `main` behind, ahead of or diverged from `origin/main` yields to it, so an unpushed `test.skip` on local `main` is scanned. One next to an unrelated `origin/main` stays the base. A tag or fetched branch named `origin/main` never stands in, and a pinned sha and `HEAD` are never redirected. The review plan of a docs-only branch that merged the newer `origin/main` stays docs; before the fix, the gate scan counted origin's skipped test.
+- `test/review-packets-removed.test.ts`: the two ship section pins move under replay GST-464, a brief-identity check. Every reviewer brief `lib/review-brief.ts` cuts from those sections must stay byte-identical to the no-loss-tested bodies; a changed brief fails it.
diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts
index a9d4f07c0..37910adef 100644
--- a/lib/gate-diff/git.ts
+++ b/lib/gate-diff/git.ts
@@ -85,22 +85,35 @@ export function isShallow(cwd: string): boolean {
/**
* The base as a ref that resolves here and, when `head` is given, shares
- * history with it: the ref itself, else its remote-tracking branch
- * (`origin/main` for `main`). A clone that checked out only the feature
- * branch has no local `main`, and the merge-base against `origin/main` is the
- * same commit; a stale local `main` that shares no history with HEAD (a
- * fetched-over clone) yields to the twin too, so the fix line's fetch into the
- * tracking ref is a fix and not a loop.
+ * history with it. A bare branch name (`main`; never a sha, a relative ref or
+ * `refs/`) yields to its remote-tracking branch whenever that shares history
+ * with HEAD, the base gstack-review-plan's path list reads: a local `main`
+ * behind origin would count commits the base already has (GST-462), and one
+ * ahead of or diverged from it would leave out commits the branch lands. A
+ * missing or unrelated local branch yields to any remote's; otherwise only
+ * origin's counts. A remote-qualified ref (`origin/main`) is read as
+ * `refs/remotes/origin/main` when that exists. Tracking branches are read by
+ * their full names, so a tag or a fetched branch called `origin/main` cannot
+ * stand in. `local` says why a tracking branch won.
*/
-export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' } | null {
+export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'behind' | 'ahead' | 'diverged' } | null {
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
+ const remotes = remoteNames(cwd);
const direct = resolvesToCommit(ref, cwd);
- if (direct && related(direct)) return { ref, sha: direct };
+ if (remotes.some((remote) => ref.startsWith(`${remote}/`))) {
+ const sha = resolvesToCommit(`refs/remotes/${ref}`, cwd) ?? direct;
+ return sha ? { ref, sha } : null;
+ }
if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null;
- for (const remote of remoteNames(cwd)) {
- const tracking = `${remote}/${ref}`;
- const sha = resolvesToCommit(tracking, cwd);
- if (sha && related(sha)) return { ref: tracking, sha, local: direct ? 'unrelated' : 'missing' };
+ const local = !direct ? 'missing' : !related(direct) ? 'unrelated' : null;
+ for (const remote of remotes) {
+ if (!local && remote !== 'origin') continue;
+ const sha = resolvesToCommit(`refs/remotes/${remote}/${ref}`, cwd);
+ if (!sha || !related(sha)) continue;
+ if (sha === direct) return { ref, sha };
+ if (local) return { ref: `${remote}/${ref}`, sha, local };
+ const fork = mergeBase(direct!, sha, cwd);
+ return { ref: `${remote}/${ref}`, sha, local: fork === direct ? 'behind' : fork === sha ? 'ahead' : 'diverged' };
}
return direct ? { ref, sha: direct } : null;
}
diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts
index 519ee5475..2993eb711 100644
--- a/lib/ship-preflight.ts
+++ b/lib/ship-preflight.ts
@@ -37,7 +37,7 @@ function integrate(cwd: string, base: string, r: Report): boolean {
if (!branch || branch === base || branch === head) { r.stop(`on the base branch (${branch || 'detached HEAD'}): ship from a feature branch`); return false; }
const status = git(cwd, ['status', '--porcelain']).stdout.split('\n').filter(Boolean);
r.add(`STATUS: modified=${status.filter(l => !l.startsWith('??')).length} untracked=${status.filter(l => l.startsWith('??')).length} (uncommitted changes ship with this run)`);
- const fetch = git(cwd, ['fetch', 'origin', base], 300_000);
+ const fetch = git(cwd, ['fetch', 'origin', `+refs/heads/${base}:refs/remotes/origin/${base}`], 300_000);
if (fetch.status !== 0) { r.stop(`git fetch origin ${base} failed: ${firstLine(fetch.stderr)}; restore access first`); return false; }
const stat = git(cwd, ['diff', forkPoint(cwd, base), '--shortstat']).stdout.trim();
const lines = [...stat.matchAll(/(\d+) (insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0);
@@ -48,6 +48,7 @@ function integrate(cwd: string, base: string, r: Report): boolean {
if (!pr) r.add('PR: not checked (origin is a local path, not a hosted remote gh can map; Step 0 uses git-native commands)');
else if (pr.status !== 0) r.add(`CARRY: not checked (gh pr list failed: ${firstLine(pr.stderr)})`);
else if (/^\d+$/.test(n)) {
+ if (!prSized(cwd, base, n, r)) return false;
const carry = bin(cwd, 'gstack-ship-receipt', ['carry', '--pr', n, '--base', base, '--dry-run'], 120_000);
const line = (carry.stdout.match(/^CARRY: .*$/m) ?? [`CARRY: check failed (${firstLine(carry.stderr)})`])[0];
r.add(`PR: #${n}`).add(line);
@@ -69,6 +70,20 @@ function integrate(cwd: string, base: string, r: Report): boolean {
return merge(cwd, base, r);
}
+/** The diff against origin/ must be about the PR's size: far more files (a stale or wrong base, HARN-15's 17,000-file review of main's history) or fewer (a fork's origin ahead of the PR's base, which would leave commits out of the gate scan) stops before any reader runs. Checked only when HEAD is the PR's pushed head. */
+function prSized(cwd: string, base: string, n: string, r: Report): boolean {
+ const view = gh(cwd, ['pr', 'view', n, '--json', 'changedFiles,headRefOid,baseRefName', '--jq', '"\\(.changedFiles) \\(.headRefOid) \\(.baseRefName)"']);
+ const [pr, prHead, prBase] = view.status === 0 ? view.stdout.trim().split(' ') : [];
+ if (!pr || !/^[1-9]\d*$/.test(pr) || prHead !== git(cwd, ['rev-parse', 'HEAD']).stdout.trim()) return true;
+ const local = git(cwd, ['diff', '--name-only', forkPoint(cwd, base), 'HEAD']).stdout.split('\n').filter(Boolean).length;
+ if (local >= Number(pr) && local <= 2 * Number(pr) + 50) return true;
+ const word = /^[A-Za-z0-9._\/-]+$/.test(prBase ?? '') ? prBase! : `'${(prBase ?? '').replace(/'/g, "'\\''")}'`;
+ r.stop(`the diff against origin/${base} lists ${local} files but PR #${n} changes ${pr} (its base is ${word}): ${prBase === base
+ ? `origin is not the PR's base repository (a fork) or its ${base} has moved; fetch the PR's base repository's ${base} into origin/${base}, then rerun`
+ : `origin/${base} is not the PR's base; rerun with --base ${word} after git fetch origin +refs/heads/${word}:refs/remotes/origin/${word}`}`);
+ return false;
+}
+
const forkPoint = (cwd: string, base: string) => git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim() || `origin/${base}`;
function merge(cwd: string, base: string, r: Report): boolean {
diff --git a/ship/DRIVER.md b/ship/DRIVER.md
index 8e3b4f4ca..50c81b4d5 100644
--- a/ship/DRIVER.md
+++ b/ship/DRIVER.md
@@ -89,8 +89,8 @@ Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, t
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from
-`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
-prints `ADVERSARIAL: skipped (review plan)`).
+`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (none:
+`ADVERSARIAL: skipped (review plan)`).
## 5. Voices, bind and version: Steps 11, 11.5, 12
@@ -102,7 +102,7 @@ approvals; `VERSION: skipped|kept`: no write.
## 6. Notes and documentation: Steps 13, 14, 14.5
-Step 13: one user-facing entry from the commits file, where `NEXT:` says;
+Step 13: one user-facing entry from the commits file where `NEXT:` says;
`gstack-prose-lint --base origin/ --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and
completions, edited from the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
one program (above), then `gstack-ship-receipt docs-caps --base `; print `Documentation:`.
@@ -119,8 +119,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
-- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
-- Step 20: the metrics row via `gstack-review-log`.
+- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ [--review-targeted @.gstack/tmp/targeted.json] --body `; `gstack-post pr-body` and `pr-title` (new PR: the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
+- Step 20: a metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output.
## Where each STOP sends you (paths under `/ship/`)
diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl
index cefeda67f..1a0ca4b47 100644
--- a/ship/DRIVER.md.tmpl
+++ b/ship/DRIVER.md.tmpl
@@ -87,8 +87,8 @@ Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, t
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from
-`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
-prints `ADVERSARIAL: skipped (review plan)`).
+`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (none:
+`ADVERSARIAL: skipped (review plan)`).
## 5. Voices, bind and version: Steps 11, 11.5, 12
@@ -100,7 +100,7 @@ approvals; `VERSION: skipped|kept`: no write.
## 6. Notes and documentation: Steps 13, 14, 14.5
-Step 13: one user-facing entry from the commits file, where `NEXT:` says;
+Step 13: one user-facing entry from the commits file where `NEXT:` says;
`gstack-prose-lint --base origin/ --gate` until `PROSE_LINT: ok`. Step 14: TODOS.md deferrals and
completions, edited from the todos file. Step 14.5, every ship: read `sections/documentation.md` and run its audit as written, as
one program (above), then `gstack-ship-receipt docs-caps --base `; print `Documentation:`.
@@ -117,8 +117,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
-- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
-- Step 20: the metrics row via `gstack-review-log`.
+- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ [--review-targeted @.gstack/tmp/targeted.json] --body `; `gstack-post pr-body` and `pr-title` (new PR: the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
+- Step 20: a metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output.
## Where each STOP sends you (paths under `/ship/`)
diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md
index 0bc8600b3..374905515 100644
--- a/ship/sections/adversarial.md
+++ b/ship/sections/adversarial.md
@@ -5,7 +5,7 @@
`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0;
above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`;
with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader
-(`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
+(`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap,
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`)
and add `--reservation ` to the `--start adversarial-review` call below.
diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl
index ea99ddfdd..c437eeb37 100644
--- a/ship/sections/adversarial.md.tmpl
+++ b/ship/sections/adversarial.md.tmpl
@@ -3,7 +3,7 @@
`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0;
above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`;
with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader
-(`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
+(`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out .gstack/tmp/targeted.json` feeds the receipt's `--review-targeted`. Under a token cap,
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`)
and add `--reservation ` to the `--start adversarial-review` call below.
diff --git a/ship/sections/handoff.md b/ship/sections/handoff.md
index 55ea33fba..83f85320f 100644
--- a/ship/sections/handoff.md
+++ b/ship/sections/handoff.md
@@ -13,6 +13,7 @@ unattended run may do is docs/unattended.md#what-an-unattended-session-may-do.
~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md
```
+ When Step 11 saved `.gstack/tmp/targeted.json`, add `--review-targeted @.gstack/tmp/targeted.json` to that write.
2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:`
line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`),
pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend
diff --git a/ship/sections/handoff.md.tmpl b/ship/sections/handoff.md.tmpl
index 273bad052..2c38144fc 100644
--- a/ship/sections/handoff.md.tmpl
+++ b/ship/sections/handoff.md.tmpl
@@ -11,6 +11,7 @@ unattended run may do is docs/unattended.md#what-an-unattended-session-may-do.
~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md
```
+ When Step 11 saved `.gstack/tmp/targeted.json`, add `--review-targeted @.gstack/tmp/targeted.json` to that write.
2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:`
line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`),
pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend
diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md
index 64e2f50cc..f9a16266b 100644
--- a/ship/sections/pr-body.md
+++ b/ship/sections/pr-body.md
@@ -221,7 +221,7 @@ per-lane counts (`{"":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json &&
-~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --base --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE"
+~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --base --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) --body "$PR_BODY_FILE"
```
A Step 5 red-base pause adds `--base-red ` (a conditional receipt the queue
diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl
index 8786e9daa..1deb322d5 100644
--- a/ship/sections/pr-body.md.tmpl
+++ b/ship/sections/pr-body.md.tmpl
@@ -204,7 +204,7 @@ per-lane counts (`{"":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json &&
-~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --base --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE"
+~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --base --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) --body "$PR_BODY_FILE"
```
A Step 5 red-base pause adds `--base-red ` (a conditional receipt the queue
diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md
index 450f3adae..6136fc6a1 100644
--- a/ship/sections/review-army.md
+++ b/ship/sections/review-army.md
@@ -5,7 +5,9 @@
Set CYCLES to 0 on first entry only, with PASS set to 1,
`REVIEW_SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)` saved and the `ship: review_scope`
policy line (Step 3) kept. Every entry saves `PASS_HEAD=$(git rev-parse HEAD)`; from pass 2 on,
-`RANGE_BASE` is the previous pass's PASS_HEAD. Keep existing approvals; changed
+`RANGE_BASE` is the previous pass's PASS_HEAD. Every entry also runs
+`mkdir -p .gstack/tmp && rm -f .gstack/tmp/targeted.json && _EX=$(git rev-parse --git-path info/exclude) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || printf '\n/.gstack/tmp/\n' >> "$_EX"; }`
+(a pass without the targeted reader leaves no record, and the record never reads as an uncommitted edit). Keep existing approvals; changed
finding scope needs a new decision. Order: checklist/design, specialists (9.1),
Red Team (9.2), QA (9.2.1), dedup (9.3), scope, fixes and logging (9.4).
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl
index 8b349c4e9..c16c4a17e 100644
--- a/ship/sections/review-army.md.tmpl
+++ b/ship/sections/review-army.md.tmpl
@@ -3,7 +3,9 @@
Set CYCLES to 0 on first entry only, with PASS set to 1,
`REVIEW_SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)` saved and the `ship: review_scope`
policy line (Step 3) kept. Every entry saves `PASS_HEAD=$(git rev-parse HEAD)`; from pass 2 on,
-`RANGE_BASE` is the previous pass's PASS_HEAD. Keep existing approvals; changed
+`RANGE_BASE` is the previous pass's PASS_HEAD. Every entry also runs
+`mkdir -p .gstack/tmp && rm -f .gstack/tmp/targeted.json && _EX=$(git rev-parse --git-path info/exclude) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || printf '\n/.gstack/tmp/\n' >> "$_EX"; }`
+(a pass without the targeted reader leaves no record, and the record never reads as an uncommitted edit). Keep existing approvals; changed
finding scope needs a new decision. Order: checklist/design, specialists (9.1),
Red Team (9.2), QA (9.2.1), dedup (9.3), scope, fixes and logging (9.4).
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts
index e33469c1a..fd7dd3aa4 100644
--- a/test/diff-scope.test.ts
+++ b/test/diff-scope.test.ts
@@ -66,6 +66,66 @@ afterAll(() => {
describe('gstack-diff-scope', () => {
// --- Existing scope signals ---
+ // Value: protects=a stale local base branch does not stand in for the base: a docs-only branch that merged a newer
+ // origin/main is docs, not the backend change origin/main already has (GST-462, GBRA-218); fails_when=the script
+ // diffs against the local branch whenever it resolves (with the name given, or the default it detects); seam=a
+ // tracking ref moved with update-ref, as a fetch would; no_claim=nothing about remotes other than origin.
+ test('a stale local main yields to origin/main, named or defaulted; a tag, a qualified ref or a pinned sha is never redirected', () => {
+ const dir = createRepo([]);
+ const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, stdio: 'pipe', timeout: 5000 });
+ git('checkout', '-q', 'main');
+ mkdirSync(join(dir, 'lib'), { recursive: true });
+ writeFileSync(join(dir, 'lib', 'server.ts'), 'export const port = 1;\n');
+ git('add', '.'); git('commit', '-qm', 'upstream backend');
+ git('update-ref', 'refs/remotes/origin/main', 'HEAD');
+ git('reset', '-q', '--hard', 'HEAD~1');
+ git('checkout', '-q', '-b', 'topic', 'origin/main');
+ mkdirSync(join(dir, 'docs'), { recursive: true });
+ writeFileSync(join(dir, 'docs', 'howto.md'), '# How to\n');
+ git('add', '.'); git('commit', '-qm', 'docs');
+ for (const args of [['main'], []]) {
+ const out = spawnSync(SCRIPT, args, { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString();
+ expect(out).toContain('SCOPE_DOCS=true');
+ expect(out).toContain('SCOPE_BACKEND=false');
+ }
+ const scopeOf = (args: string[]) => spawnSync(SCRIPT, args, { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString();
+ git('tag', 'origin/main', 'HEAD');
+ expect(scopeOf(['main'])).toContain('SCOPE_DOCS=true');
+ git('tag', '-d', 'origin/main');
+ git('update-ref', 'refs/remotes/origin/origin/main', 'HEAD');
+ expect(scopeOf(['origin/main'])).toContain('SCOPE_DOCS=true');
+ git('tag', 'origin/main', 'HEAD');
+ expect(scopeOf(['origin/main'])).toContain('SCOPE_DOCS=true');
+ git('tag', '-d', 'origin/main');
+ const pinned = git('rev-parse', 'origin/main').stdout.toString().trim();
+ git('update-ref', `refs/remotes/origin/${pinned}`, 'HEAD');
+ expect(scopeOf([pinned])).toContain('SCOPE_DOCS=true');
+ git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
+ expect(spawnSync(SCRIPT, ['HEAD'], { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString()).toContain('SCOPE_DOCS=false');
+ git('update-ref', 'refs/remotes/origin/main', git('commit-tree', git('rev-parse', 'HEAD^{tree}').stdout.toString().trim(), '-m', 'unrelated').stdout.toString().trim());
+ const unrelated = spawnSync(SCRIPT, ['main'], { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString();
+ expect(unrelated).toContain('SCOPE_BACKEND=true');
+ });
+
+ // Value: protects=diff-scope reads the base gstack-review-plan's path list reads (origin/), so a test file a local
+ // main carries but origin lacks counts when the branch includes it; fails_when=a related local main ahead of origin/main
+ // stays the base (fix-check, pass 2: SCOPE_TESTS=false beside a path list naming the test); seam=a tracking ref set with
+ // update-ref; no_claim=remotes other than origin
+ test('a local main ahead of origin/main yields to it, so its unpushed test file is in scope', () => {
+ const dir = createRepo([]);
+ const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, stdio: 'pipe', timeout: 5000 });
+ git('checkout', '-q', 'main');
+ git('update-ref', 'refs/remotes/origin/main', 'HEAD');
+ mkdirSync(join(dir, 'test'), { recursive: true });
+ writeFileSync(join(dir, 'test', 'a.test.ts'), "test.skip('b', () => {});\n");
+ git('add', '.'); git('commit', '-qm', 'unpushed test');
+ git('checkout', '-q', '-b', 'topic', 'main');
+ mkdirSync(join(dir, 'docs'), { recursive: true });
+ writeFileSync(join(dir, 'docs', 'x.md'), '# x\n');
+ git('add', '.'); git('commit', '-qm', 'docs');
+ expect(spawnSync(SCRIPT, ['main'], { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString()).toContain('SCOPE_TESTS=true');
+ });
+
test('detects frontend files', () => {
const dir = createRepo(['styles.css', 'component.tsx']);
const scope = runScope(dir);
diff --git a/test/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts
index 3a8545e09..6d5632b2b 100644
--- a/test/gate-diff-cli.test.ts
+++ b/test/gate-diff-cli.test.ts
@@ -149,6 +149,94 @@ describe('gstack-gate-diff arguments and exit contract', () => {
expect(odd.stdout.trim()).toBe("GATE_ERROR=no_base ref=rel;echo pwned fix=git fetch origin '+rel;echo pwned:refs/remotes/origin/rel;echo pwned'");
});
+ // Value: protects=a local base branch that is related to HEAD but behind its remote-tracking twin yields to the twin, so
+ // commits the base already has (merged into the branch from origin) do not count as the branch's change (GST-462,
+ // GBRA-218); fails_when=resolveBase returns the local ref whenever it resolves and shares history with HEAD; seam=a
+ // tracking ref moved with update-ref, as a fetch would; no_claim=nothing about remotes other than origin.
+ test('a local main behind, ahead of or diverged from origin/main yields to it and says so; beside an unrelated origin/main it stays the base', () => {
+ const src = repo({ fixture: 'rh1-skip', commit: true });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-behind-'));
+ repos.push(dir);
+ gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
+ gitIn(dir, ['checkout', '-q', 'main']);
+ gitIn(dir, ['update-ref', 'refs/remotes/origin/main', 'refs/remotes/origin/feature']);
+ gitIn(dir, ['checkout', '-q', '-b', 'topic', 'origin/main']);
+ fs.writeFileSync(path.join(dir, 'NOTES.md'), 'topic note\n');
+ gitIn(dir, ['add', 'NOTES.md']);
+ gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'topic']);
+ expect(spawnSync('git', ['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: dir, timeout: 30_000 }).status).toBe(0);
+
+ const r = run(dir, ['main']);
+ expect(r.status).toBe(0);
+ expect(summary(r.stdout).tagged).toBe('');
+ expect(r.stderr).toContain('gstack-gate-diff: local main is behind origin/main; base is origin/main');
+ gitIn(dir, ['update-ref', 'refs/heads/main', 'refs/remotes/origin/main']);
+ const current = run(dir, ['main']);
+ expect(summary(current.stdout).tagged).toBe('');
+ expect(current.stderr).not.toContain('base is origin/main');
+ const commitOn = (ref: string, name: string) => {
+ const tree = gitIn(dir, ['rev-parse', `${ref}^{tree}`]).trim();
+ const sha = gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit-tree', tree, '-p', ref, '-m', name]).trim();
+ gitIn(dir, ['update-ref', ref, sha]);
+ };
+ commitOn('refs/heads/main', 'unpushed');
+ const ahead = run(dir, ['main']);
+ expect(ahead.status).toBe(0);
+ expect(ahead.stderr).toContain('gstack-gate-diff: local main is ahead of origin/main; base is origin/main');
+ commitOn('refs/remotes/origin/main', 'upstream moved');
+ const diverged = run(dir, ['main']);
+ expect(diverged.status).toBe(0);
+ expect(diverged.stderr).toContain('gstack-gate-diff: local main has diverged from origin/main; base is origin/main');
+ const orphan = gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit-tree', gitIn(dir, ['rev-parse', 'HEAD^{tree}']).trim(), '-m', 'unrelated']).trim();
+ gitIn(dir, ['update-ref', 'refs/remotes/origin/main', orphan]);
+ const unrelated = run(dir, ['main']);
+ expect(unrelated.status).toBe(0);
+ expect(summary(unrelated.stdout).tagged).toBe('');
+ expect(unrelated.stderr).not.toContain('base is origin/main');
+ });
+
+ // Value: protects=the gate scan reads the same base as gstack-review-plan's path list (origin/), so commits a local
+ // main carries but origin lacks are scanned when the branch includes them; fails_when=a related local main that is ahead
+ // of origin/main stays the base (fix-check, pass 2: an unpushed test.skip listed in paths, gate_tagged=0); seam=a local
+ // branch moved with update-ref; no_claim=a fork whose origin is not the PR's base repository (ship preflight's prSized)
+ test('a local main ahead of origin/main yields to it, so its unpushed test.skip is scanned', () => {
+ const src = repo({ fixture: 'rh1-skip', commit: true });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-ahead-'));
+ repos.push(dir);
+ gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
+ gitIn(dir, ['branch', '-q', '--no-track', 'main', 'origin/feature']);
+ gitIn(dir, ['checkout', '-q', '-b', 'topic', 'main']);
+ fs.writeFileSync(path.join(dir, 'NOTES.md'), 'topic note\n');
+ gitIn(dir, ['add', 'NOTES.md']);
+ gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'topic']);
+ const r = run(dir, ['main']);
+ expect(r.status).toBe(0);
+ expect(summary(r.stdout).tagged).toBe('RH-1:1');
+ expect(r.stderr).toContain('gstack-gate-diff: local main is ahead of origin/main; base is origin/main');
+ });
+
+ // Value: protects=the base cannot be redirected to the candidate by a ref the contributor controls: a tag named
+ // origin/main, or a fetched branch named origin/main (refs/remotes/origin/origin/main), never replaces origin's tracking
+ // branch, and an explicit origin/main is never re-qualified; fails_when=resolveBase resolves the twin by its short name or
+ // tries a twin for a remote-qualified ref (outside voice, pass 2: both emptied the gate scan of a test.skip); seam=refs a
+ // fetch would create; no_claim=a tag shadowing a local branch name, which predates this change
+ test('a tag or a fetched branch named origin/main cannot empty the gate scan, named bare, qualified or defaulted', () => {
+ const src = repo({ fixture: 'rh1-skip', commit: true });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shadow-'));
+ repos.push(dir);
+ gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
+ gitIn(dir, ['checkout', '-q', '-b', 'topic', 'origin/feature']);
+ expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
+ gitIn(dir, ['tag', 'origin/main', 'HEAD']);
+ expect(summary(run(dir, ['main']).stdout).tagged).toBe('RH-1:1');
+ expect(summary(run(dir, ['origin/main']).stdout).tagged).toBe('RH-1:1');
+ gitIn(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']);
+ expect(summary(run(dir, []).stdout).tagged).toBe('RH-1:1');
+ gitIn(dir, ['tag', '-d', 'origin/main']);
+ gitIn(dir, ['update-ref', 'refs/remotes/origin/origin/main', 'HEAD']);
+ expect(summary(run(dir, ['origin/main']).stdout).tagged).toBe('RH-1:1');
+ });
+
// Value: protects=a clone whose only remote is not called origin resolves the base through that remote and the fix line
// fetches from it; fails_when=remoteNames or resolveBase hard-code origin, or noBaseFix falls back to the literal 'origin'
// while a real remote exists; why_new=every existing fixture has either an origin remote or none; seam=none;
diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts
index c8ab8f706..e4b44f6d0 100644
--- a/test/review-packets-removed.test.ts
+++ b/test/review-packets-removed.test.ts
@@ -8,7 +8,9 @@ import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
+import * as os from 'os';
import * as path from 'path';
+import { sectionTexts } from '../lib/review-brief';
const ROOT = path.join(import.meta.dir, '..');
const REMOVED = [
@@ -56,13 +58,14 @@ const NO_LOSS_REPLAYS = {
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
+ 'GST-464': "GST-464 brief-identity no-loss check (the brief-body test below): every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md is byte-identical to the bodies at the L and GST-257 pins; only lead instructions outside the cut spans changed",
'GST-850': "GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged",
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record = {
- 'ship/sections/review-army.md': { sha: 'b7ca291a1cc63750bfb9710a4c12fd971523b9598da2a32ef80fb258eb0eceab', replay: 'L' },
- 'ship/sections/adversarial.md': { sha: 'd776ee5bc6e0c13930f8f4de05bad2f1ed9e52bc9fc5df80ae3ba7680455b2bb', replay: 'GST-257' },
+ 'ship/sections/review-army.md': { sha: 'c7edb2a8c19fc8b2c9aa31f443f6c49ebe28528390a0efc47ecc6ac3e3a057be', replay: 'GST-464' },
+ 'ship/sections/adversarial.md': { sha: 'b70577b6987869acd76cc3865f16eeeed743558c0ea09e5f9933d0d8aec8b91e', replay: 'GST-464' },
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' },
@@ -83,7 +86,40 @@ const REVIEWER_BRIEFS: Record = {
'lib/review-risk.ts': { sha: '18f0330ddfda0032eb93516b0eb941fa4244e0a68b028e19c436c74609093b03', replay: 'GST-850' },
};
+/**
+ * The six reviewer brief bodies lib/review-brief.ts cuts out of the two ship sections, pinned at
+ * the bytes replays L and GST-257 admitted. A section repin under GST-464 is valid only while every
+ * body still hashes here: the section may change outside the cut spans, never inside them.
+ */
+const BRIEF_BODIES: Record = {
+ specialist: '1ebe47ddb3291a9e4fe90b5a260eb57e328144959549bc95c119b68d23652cb5',
+ framing: 'aad667b5ea4c6d23d930dafffa38178c04be388cd4364fb73759fc92af17bb63',
+ fixtures: '9517c86befe773e76acbf30a0642ed828e211b8ecb524325702072b26429ef16',
+ redTeam: '1cca7957d13f9da08434870290b92958f37750207047b92c9aa52a802da789e8',
+ native: 'ddb1041ec1e4dd373c3e4ab9017e49316e78c230055c1ef3db1bbba518b143a2',
+ outside: 'bd96406da28b03742384560425a333b6d6d7ed2be95bd2026c90e0039632cee2',
+};
+const bodyHashes = (root: string) => Object.fromEntries(Object.entries(sectionTexts(root)).map(([k, v]) => [k, createHash('sha256').update(v).digest('hex')]));
+
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
+ // Value: protects=the GST-464 repin of ship/sections/review-army.md and adversarial.md admits only lead-instruction
+ // edits: every brief a reviewer reads renders byte-identical to the no-loss-tested bodies; fails_when=an edit inside a
+ // cut span changes a brief while its section pin is moved; seam=sectionTexts, the function every render uses;
+ // no_claim=the checklists and the targeted brief, pinned by their own file hashes above
+ test('every reviewer brief cut from the ship sections is byte-identical to its pinned body', () => {
+ expect(bodyHashes(ROOT)).toEqual(BRIEF_BODIES);
+ const copy = fs.mkdtempSync(path.join(os.tmpdir(), 'brief-bodies-'));
+ try {
+ fs.mkdirSync(path.join(copy, 'ship', 'sections'), { recursive: true });
+ for (const f of ['review-army.md', 'adversarial.md']) fs.copyFileSync(path.join(ROOT, 'ship', 'sections', f), path.join(copy, 'ship', 'sections', f));
+ const army = path.join(copy, 'ship', 'sections', 'review-army.md');
+ fs.writeFileSync(army, fs.readFileSync(army, 'utf8').replace('You are a specialist code reviewer.', 'You are a specialist code reviewer. Skim.'));
+ expect(bodyHashes(copy).specialist).not.toBe(BRIEF_BODIES.specialist);
+ } finally {
+ fs.rmSync(copy, { recursive: true, force: true });
+ }
+ });
+
test('every pinned reviewer brief, checklist and depth rule hashes to its pin', () => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, pin]) => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8').replace(/\r\n/g, '\n');
diff --git a/test/review-plan-cli.test.ts b/test/review-plan-cli.test.ts
index eee3e5159..adabefa92 100644
--- a/test/review-plan-cli.test.ts
+++ b/test/review-plan-cli.test.ts
@@ -29,6 +29,29 @@ describe('gstack-review-plan', () => {
expect(r.stdout).toContain('REVIEW_PLAN: class=docs pass=1 next=full dispatches=0 numbers=no');
expect(r.stdout).toContain('SKIP: outside-voice (docs class, no numeric claims)');
});
+ // Value: protects=a stale local main does not stand in for the base: a docs-only branch that merged a newer origin/main
+ // plans as docs, not as the gate change origin/main already has (GST-462: #3597 was labeled gate code at full depth
+ // until --base origin/main); fails_when=gstack-gate-diff or gstack-diff-scope diff against the local branch;
+ // seam=a tracking ref moved with update-ref, as a fetch would; no_claim=nothing about a local main ahead of origin/main.
+ test('a stale local main yields to origin/main: a docs-only branch that merged the newer origin/main plans as docs', () => {
+ const r = repo({ 'docs/howto.md': '# How to\nStep one.\n' });
+ const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 });
+ const testFile = (skip: boolean) => `import { test, expect } from 'bun:test';\ntest${skip ? '.skip' : ''}('adds', () => {\n expect(1 + 1).toBe(2);\n});\n`;
+ git('checkout', '-q', 'main');
+ fs.mkdirSync(path.join(r.dir, 'test'), { recursive: true });
+ fs.writeFileSync(path.join(r.dir, 'test', 'add.test.ts'), testFile(false));
+ git('add', '.'); git('commit', '-qm', 'tests');
+ git('checkout', '-q', 'feat'); git('merge', '-q', '--no-edit', 'main'); git('checkout', '-q', 'main');
+ fs.writeFileSync(path.join(r.dir, 'test', 'add.test.ts'), testFile(true));
+ git('commit', '-qam', 'upstream skip');
+ git('remote', 'add', 'origin', 'https://example.invalid/x.git');
+ git('update-ref', 'refs/remotes/origin/main', 'HEAD');
+ git('reset', '-q', '--hard', 'HEAD~1');
+ git('checkout', '-q', 'feat'); git('merge', '-q', '--no-edit', 'origin/main');
+ const plan = spawnSync(BIN, ['--base', 'main'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
+ expect(plan.status).toBe(0);
+ expect(plan.stdout).toContain('REVIEW_PLAN: class=docs pass=1 next=full dispatches=0 numbers=no');
+ });
test('a docs diff that publishes numbers keeps the outside voice', () => {
const r = run(repo({ 'docs/bench.md': 'Median latency fell 42% to 180 ms.\n' }), []);
expect(r.stdout).toContain('DISPATCH: outside-voice outside-voice effort=host');
diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts
index 375cf1d01..73d838072 100644
--- a/test/review-targeted.test.ts
+++ b/test/review-targeted.test.ts
@@ -339,6 +339,54 @@ describe('the targeted role end to end on a fixture repository', () => {
}, 180_000);
});
+describe('the /ship commands carry the targeted record to the receipt (GST-464)', () => {
+ // Value: protects=when the targeted reader ran, the receipt /ship writes carries its record, so ready prints verdict=ok instead
+ // of receipt=legacy (gstack-evals#95, #3682, #3612); fails_when=Step 11's verify writes the record nowhere Step 19 reads, the
+ // Step 19 or handoff write command omits --review-targeted, or a record from an earlier run survives Step 9; seam=the generated
+ // sections' own command text, expanded by bash and passed to gstack-ship-receipt write; no_claim=enforcement (report-only)
+ test('Step 11 verify writes the record where Step 19 write reads it, a run without the reader writes none, and Step 9 clears it', () => {
+ const section = (name: string) => fs.readFileSync(path.join(ROOT, 'ship', 'sections', `${name}.md`), 'utf8');
+ const recordPath = /verify --targeted-out (\S+?)`/.exec(section('adversarial'))?.[1];
+ expect(recordPath).toBe('.gstack/tmp/targeted.json');
+ const writeLine = section('pr-body').split('\n').find(l => l.includes('gstack-ship-receipt write --pr'))!;
+ const optional = /\$\(\[ -s (\S+) \] && echo (--review-targeted @\S+)\)/.exec(writeLine);
+ expect(optional?.slice(1)).toEqual([recordPath!, `--review-targeted @${recordPath}`]);
+ expect(section('handoff')).toContain(`When Step 11 saved \`${recordPath}\`, add \`--review-targeted @${recordPath}\` to that write.`);
+ expect(fs.readFileSync(path.join(ROOT, 'ship', 'DRIVER.md'), 'utf8')).toContain(`[--review-targeted @${recordPath}]`);
+ const entry = /Every entry also runs\n`([^`]+)`/.exec(section('review-army'))?.[1];
+ expect(entry).toContain(`rm -f ${recordPath}`);
+
+ const repo = makeFixtureRepo({ prefix: 'targeted-wire-' });
+ repos.push(repo);
+ repo.git('checkout', '-q', '-b', 'feat');
+ fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 5;\n');
+ repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'charge');
+ const expand = () => spawnSync('bash', ['-c', `printf '%s\\n' ${optional![0]}`], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).stdout.split('\n').filter(Boolean);
+ expect(expand()).toEqual([]);
+ fs.mkdirSync(path.join(repo.work, '.gstack', 'tmp'), { recursive: true });
+ fs.writeFileSync(path.join(repo.work, recordPath!), '{}\n');
+ expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ expect(fs.existsSync(path.join(repo.work, recordPath!))).toBe(false);
+ fs.rmSync(path.join(repo.work, '.gstack'), { recursive: true });
+ expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ const exclude = path.join(repo.work, '.git', 'info', 'exclude');
+ fs.rmSync(path.dirname(exclude), { recursive: true, force: true });
+ expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ fs.writeFileSync(exclude, '*.log');
+ expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ expect(fs.readFileSync(exclude, 'utf8').split('\n')).toEqual(expect.arrayContaining(['*.log', '/.gstack/tmp/']));
+ fs.writeFileSync(path.join(repo.work, recordPath!), JSON.stringify({ classes: ['money'], rules: TARGETED_RULES, brief_sha: targetedBriefSha(), status: 'done', findings: 0, raised_by: 'targeted' }) + '\n');
+ const args = expand();
+ expect(args).toEqual(['--review-targeted', `@${recordPath}`]);
+ expect(repo.git('ls-files', '--others', '--exclude-standard')).toBe('');
+ const write = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', ...args, '--json']);
+ expect(write.status, write.err).toBe(0);
+ const receipt = JSON.parse(write.out) as ShipReceipt;
+ expect(receiptTargetedLine(['money'], receipt)).toBe('REVIEW_TARGETED: required=money receipt=money verdict=ok');
+ });
+});
+
describe('the receipt record and the queue line', () => {
const record = { classes: ['gate', 'money'], rules: TARGETED_RULES, brief_sha: 'a'.repeat(64), status: 'done', findings: 0, raised_by: 'targeted' };
test('parseReviewTargeted binds the receipt\'s product patch, orders the classes and refuses a malformed record', () => {
diff --git a/test/ship-parts.test.ts b/test/ship-parts.test.ts
index 1064c482e..205b89428 100644
--- a/test/ship-parts.test.ts
+++ b/test/ship-parts.test.ts
@@ -83,13 +83,13 @@ describe('gstack-ship-parts next under loss and odd inputs (PR #3670 review)', (
// why_new=Step 11 findings on #3670 (outside voice 1-3, adversarial 5, QA); seam=none; no_claim=the host noticing that a return was lost
test('a lost return is sent again with --resend, and a plain next promotes it to returned', () => {
const dir = tmp();
- const first = run(['next', '--run', dir, '--phase', '3', '--room', '4000']);
+ const first = run(['next', '--run', dir, '--phase', '3', '--room', '6000']);
const ids = [...first.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!);
expect(ids.length).toBeGreaterThan(0);
- const again = run(['next', '--run', dir, '--phase', '3', '--room', '4000', '--resend']);
+ const again = run(['next', '--run', dir, '--phase', '3', '--room', '6000', '--resend']);
expect(again.status, again.stderr).toBe(0);
expect([...again.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!)).toEqual(ids);
- const later = run(['next', '--run', dir, '--phase', '3', '--room', '4000']);
+ const later = run(['next', '--run', dir, '--phase', '3', '--room', '6000']);
expect([...later.stdout.matchAll(/^PART: (\S+) /gm)].map(m => m[1]!).filter(id => ids.includes(id))).toEqual([]);
expect(JSON.parse(fs.readFileSync(path.join(dir, 'parts', 'state.json'), 'utf8')).returned).toEqual(expect.arrayContaining(ids));
});
diff --git a/test/ship-preflight.test.ts b/test/ship-preflight.test.ts
index a376a4116..d83f9a2d2 100644
--- a/test/ship-preflight.test.ts
+++ b/test/ship-preflight.test.ts
@@ -86,6 +86,52 @@ describe('gstack-ship-preflight', () => {
expect(lineOf(r.stdout, 'POLICY')).toContain('queue_mode=stamp-at-merge');
});
+ // Value: protects=Step 1 refreshes origin/ itself, so a clone whose fetch refspec leaves out the base (a single-branch
+ // clone) still diffs against the current base tip (GST-462, HARN-15); fails_when=preflight runs `git fetch origin `,
+ // which then updates only FETCH_HEAD; seam=remote.origin.fetch; no_claim=remotes other than origin
+ test('a single-branch clone: the fetch refreshes origin/, so DIFF is the branch alone', () => {
+ const f = fx({ '.gstack/ship-policy.json': '{"queue_mode":"stamp-at-merge"}\n' });
+ f.git('config', 'remote.origin.fetch', '+refs/heads/feat:refs/remotes/origin/feat');
+ f.advanceBase({ 'docs/new.md': '# new\n' });
+ f.git('fetch', '-q', f.origin, 'main');
+ f.git('merge', '-q', '--no-edit', 'FETCH_HEAD');
+ const stale = f.git('rev-parse', 'origin/main').stdout.trim();
+ const r = f.run(PREFLIGHT, ['--base', 'main']);
+ expect(r.status).toBe(0);
+ expect(f.git('rev-parse', 'origin/main').stdout.trim()).not.toBe(stale);
+ expect(lineOf(r.stdout, 'DIFF')).toMatch(/^DIFF: 1 file changed, 1 insertion\(\+\); commits=2$/);
+ });
+
+ // Value: protects=a diff far larger than the PR's own (a stale or wrong base: HARN-15 reviewed 17,000 files of main's history)
+ // stops before any reader runs, naming the PR's base; fails_when=preflight never compares the diff with the PR's changed
+ // files; seam=gh pr view (changedFiles, headRefOid, baseRefName); no_claim=GitHub's own count
+ test('a diff far beyond or short of the PR\'s changed files stops at the pushed head; another head or a matching size goes on', () => {
+ const files = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`src/f${i}.ts`, `export const f${i} = ${i};\n`]));
+ const f = fx({}, files);
+ const head = f.git('rev-parse', 'HEAD').stdout.trim();
+ const stub = (changed: number, prHead: string, prBase = 'release') => f.gh(`case "$*" in *"pr list"*) echo 7 ;; *changedFiles*) echo "${changed} ${prHead} ${prBase}" ;; esac`);
+ stub(3, head);
+ const r = f.run(PREFLIGHT, ['--base', 'main']);
+ expect(r.status).toBe(3);
+ expect(r.stdout).toContain('STOP: the diff against origin/main lists 60 files but PR #7 changes 3 (its base is release): origin/main is not the PR\'s base; rerun with --base release after git fetch origin +refs/heads/release:refs/remotes/origin/release');
+ stub(3, head, 'main');
+ const fork = f.run(PREFLIGHT, ['--base', 'main']);
+ expect(fork.stdout).toContain('(its base is main): origin is not the PR\'s base repository (a fork) or its main has moved');
+ expect(fork.stdout).not.toContain('rerun with --base');
+ stub(3, head, 'x;rm');
+ expect(f.run(PREFLIGHT, ['--base', 'main']).stdout).toContain("rerun with --base 'x;rm' after git fetch origin +refs/heads/'x;rm':refs/remotes/origin/'x;rm'");
+ stub(61, head, 'main');
+ const fewer = f.run(PREFLIGHT, ['--base', 'main']);
+ expect(fewer.status).toBe(3);
+ expect(fewer.stdout).toContain('STOP: the diff against origin/main lists 60 files but PR #7 changes 61 (its base is main): origin is not the PR\'s base repository (a fork) or its main has moved');
+ for (const [changed, prHead] of [[3, 'f'.repeat(40)], [61, 'f'.repeat(40)], [60, head], [0, head]] as const) {
+ stub(changed, prHead);
+ const go = f.run(PREFLIGHT, ['--base', 'main']);
+ expect(go.stdout).not.toContain('lists 60 files');
+ expect(lineOf(go.stdout, 'PR')).toBe('PR: #7');
+ }
+ });
+
// Value: protects=Step 1's scope check reads the fetched origin/, so a clone without a local base branch still sees frontend files; fails_when=the preflight reports frontend=false because gstack-diff-scope could not resolve a local ;
// why_new=GSTA-167 dogfood on #3633: a Capy checkout has no local main and SCOPE printed false; seam=gstack-diff-scope; no_claim=design review itself
test('no local base branch: SCOPE still reads the fetched base', () => {
diff --git a/test/size-ledger/ship/gst-464-receipt-targeted-base.json b/test/size-ledger/ship/gst-464-receipt-targeted-base.json
new file mode 100644
index 000000000..5643e4602
--- /dev/null
+++ b/test/size-ledger/ship/gst-464-receipt-targeted-base.json
@@ -0,0 +1,4 @@
+{
+ "bytes": 591,
+ "why": "GST-464: Step 9 creates .gstack/tmp, excludes it and clears the targeted record on every entry"
+}