feat(ship): Step 9 fix-range re-review with a mandatory final full pass (#3339)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-25 02:42:20 +00:00
1 parent e66c43c572
commit 4e54d98c00
7 files changed
+311 -63

No files matched your search

+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [Unreleased]
**/ship's Step 9 can re-review just the fixes after a fixing pass, and a narrowed pass can never be the review that ships (#3339).** Before, every fix cycle reran every specialist and the Red Team over the whole branch. On a large branch each full pass tends to find something new in code earlier passes already read, so the loop often ran to the three-cycle stop without converging. Now the pass after a fixing pass can review only what changed since that pass. One full pass still has to come last: a fix-range record is never CURRENT, and Step 11.5 can't bind it. Garry approved this on 2026-10-24 ("only with a mandatory final full pass").
### Added
- **`bin/gstack-review-log --start review --fix-range`** prints the start token, then `FIX_BASE=<sha>`. The base comes from the review log, never from the caller or the working tree, so `gstack-wtree`'s fingerprint is untouched. It is the commit of the latest pass of the same skill. The start refuses (exit 1, "run a full pass") in each of these cases: there is no earlier pass, that pass didn't complete (restoring coverage always takes a full pass), it made no fixes, its content changed while it ran, it ran on another branch, a Step 11 review followed it, or its commit no longer resolves or is no longer an ancestor of HEAD (after a rebase).
- **`lib/review-evidence.ts`**: the start token carries `scope: "fix-range"` and `fix_base`. The logged row takes `scope` and `fix_base` only from the token (a caller's values are dropped either way). On unchanged content its `review_binding` is `{state: "fix-range", range_start_wtree, range_end_wtree, …}`, with no `start_wtree`/`end_wtree` and no root `wtree`. `reviewFreshness` returns UNVERIFIED for any fix-range row ("fix-range pass; only a later full pass can clear review"), even one with a verified-looking binding.
- **`bin/gstack-specialist-stats`** skips fix-range rows, so narrowed dispatches don't push specialists toward `[GATE_CANDIDATE]`.
### Changed
- **`ship/sections/review-army.md.tmpl`**: the core checklist explains the fix-range start, and that `FIX_BASE` stands in for `origin/<base>` and `<base>` for that pass. Decision 3 allows the repeat to be a fix-range pass. Decision 4 continues to Step 10 only from a full pass. Nearby prose was tightened to pay for the growth, so ship's skeleton plus sections is 313,246 bytes against its 313,281 cap (35 bytes below main).
### Tests
- `test/review-fix-range.test.ts` (15 cases) covers the base, chained fix-range passes, the seven refusals, a caller claiming `scope: "full"` on a fix-range token (it is still recorded as fix-range) or `"fix-range"` on a full one (it is dropped), Step 11.5's missing binding fields, UNVERIFIED freshness followed by CURRENT after a full pass, shared-libs reuse refusing a fix-range row, the stats exclusion, and the rendered Step 9 rules.
### Gate integrity
- One gate change to review: Step 9 may narrow a repeat pass. Verdict: not a weakening of what ships, because the narrowed pass can't be the final review. Three things enforce that: its row is never CURRENT, Step 11.5 has no `start_wtree`/`end_wtree` to bind, and decision 4 requires a full pass before Step 10. Mutation checks, each caught by the suite: dropping the freshness check, giving a narrowed row the normal binding, allowing a fix-range start after a clean, an incomplete or a Step 11-followed pass, accepting a non-ancestor base, and counting fix-range rows in stats. No threshold, budget, timeout, trial count or case kind changed.
## [1.91.191.0] - 2026-10-25
**`gstack-version-bump write --changelog` moves the branch's `[Unreleased]` entry under the new version, and /ship Step 12 stops treating a provisional queue pick as a verified slot.** Stamping by hand meant writing VERSION with `gstack-version-bump write` and then renaming the `## [Unreleased]` heading yourself, because only `gstack-restamp` re-headed the CHANGELOG. Separately, when `gstack-next-version` could not read some open PRs' VERSION it returned `complete:false` and a provisional pick, and Step 12 printed the warning and then wrote that version as if the queue had been checked (left over from #3258, noted in GSTA-45).
+9 -3
View File
@@ -3,6 +3,10 @@
# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}'
#
# Before reading a diff: gstack-review-log --start review
# Fix-range repeat (#3339): gstack-review-log --start review --fix-range
# prints the token, then FIX_BASE=<sha>; refuses (exit 1) unless the latest
# pass completed with fixes on unchanged content and its commit is an
# ancestor of HEAD. Its row is never CURRENT; a later full pass must follow.
# After that pass: gstack-review-log '{...,"completed":true,"converged":true}' --finish TOKEN
# With a findings file: gstack-review-log '{...}' --findings findings.jsonl
# status/unresolved/issues_found/critical_gaps are DERIVED from the file (B7);
@@ -72,9 +76,11 @@ fi
SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)
export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND"
if [ "$INPUT" = --start ]; then
GSTACK_REVIEW_SKILL="${2:-}" bun --no-env-file --config=/dev/null -e '
case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac
GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config=/dev/null -e '
const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB);
console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL));
try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); }
catch (e) { console.error(e.message); process.exit(1); }
'
exit $?
fi
@@ -86,7 +92,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then
exit $?
fi
usage() {
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL | --check-shared-libs TOKEN < finding.json' >&2
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2
echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2
exit 2
}
+3 -1
View File
@@ -5,6 +5,8 @@
# Reads all *-reviews.jsonl files across branches, parses specialist fields,
# and outputs hit rates. Tags specialists as GATE_CANDIDATE (0 findings in 10+
# dispatches) or NEVER_GATE (security, data-migration, state-machines — insurance policy).
# Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch
# says nothing about a specialist's hit rate on a whole branch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -40,7 +42,7 @@ let reviewed = 0;
for (const line of lines) {
try {
const e = JSON.parse(line);
if (!e.specialists) continue;
if (!e.specialists || e.scope === 'fix-range') continue;
reviewed++;
for (const [name, info] of Object.entries(e.specialists)) {
if (!stats[name]) stats[name] = { dispatched: 0, findings: 0 };
+54 -9
View File
@@ -163,18 +163,54 @@ export function checkSharedLibsReuse(finding: unknown, token: string, env = proc
return result;
}
export function captureReviewStart(skill: string, env = process.env): string {
/**
* #3339: a fix-range repeat reviews only what changed since the latest pass of
* the same skill, and only when that pass completed, made fixes, reviewed
* unchanged content on this branch, and no Step 11 review followed it. The
* base is that pass's commit, held in the review log rather than the working
* tree, and it must still be an ancestor of HEAD. Anything else needs a full pass.
*/
function fixRangeBase(skill: string, env: NodeJS.ProcessEnv): string {
const refuse = (reason: string): never => {
throw new Error(`gstack-review-log: fix-range refused: ${reason}; run a full pass (gstack-review-log --start ${skill})`);
};
let rows: Record<string, any>[] = [];
try {
rows = readFileSync(env.GSTACK_REVIEW_LOG!, 'utf8').split('\n').filter(Boolean).flatMap(line => {
try { const row = JSON.parse(line); return record(row) ? [row] : []; } catch { return []; }
});
} catch {}
const at = rows.findLastIndex(row => row.skill === skill);
if (at < 0) refuse('no earlier pass on this branch');
if (rows.slice(at + 1).some(row => row.skill === 'adversarial-review' || row.skill === 'codex-review')) refuse('a Step 11 review followed the latest pass');
const prior = rows[at]!, binding = prior.review_binding;
if (prior.completed !== true) refuse('the latest pass did not complete');
if (prior.converged !== false) refuse('the latest pass made no fixes');
const reviewed = binding?.state === 'incomplete' ? [binding.start_wtree, binding.end_wtree]
: binding?.state === 'fix-range' ? [binding.range_start_wtree, binding.range_end_wtree] : [];
if (!reviewed[0] || reviewed[0] !== reviewed[1]) refuse('the latest pass has no unchanged captured content');
if (binding.branch_id !== sha256(env.GSTACK_REVIEW_BRANCH ?? '')) refuse('the latest pass is from another branch');
const base = prior.commit_full;
const git = (...args: string[]) => spawnSync('git', args, { cwd: env.GSTACK_REVIEW_REPO, timeout: 10_000 }).status === 0;
if (typeof base !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(base) || !git('cat-file', '-e', `${base}^{commit}`)) refuse('the latest pass has no resolvable commit');
if (!git('merge-base', '--is-ancestor', base, 'HEAD')) refuse(`${base} is not an ancestor of HEAD`);
return base;
}
export function captureReviewStart(skill: string, env = process.env, fixRange = false): string {
if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) {
throw new Error('cannot capture a diff review without a working-tree fingerprint');
}
const fixBase = fixRange ? fixRangeBase(skill, env) : undefined;
const dir = join(env.GSTACK_REVIEW_DIR!, '.review-starts');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const token = crypto.randomUUID();
writeFileSync(join(dir, `${token}.json`), JSON.stringify({
skill, repo: env.GSTACK_REVIEW_REPO, branch: env.GSTACK_REVIEW_BRANCH,
wtree: env.GSTACK_STAMP_WTREE, started_at: new Date().toISOString(),
...(fixBase ? { scope: 'fix-range', fix_base: fixBase } : {}),
}), { mode: 0o600, flag: 'wx' });
return token;
return fixBase ? `${token}\nFIX_BASE=${fixBase}` : token;
}
const GATE_ACTIONS = new Set(['kept', 'restored', 'open']);
@@ -226,7 +262,7 @@ export function deriveFromFindings(rec: Record<string, any>, findingsPath: strin
}
export function bindReview(rec: Record<string, any>, token: string, env = process.env, findingsPath?: string): Record<string, any> {
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file']) delete rec[key];
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
// The findings file is validated BEFORE the start token is consumed below:
// a rejected row must leave the token for the retry.
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
@@ -256,11 +292,19 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
const state = !start || !end ? 'uncaptured'
: start.wtree !== end ? 'changed'
: rec.completed !== true || rec.converged !== true ? 'incomplete' : 'verified';
rec.review_binding = {
state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at,
...(typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}),
};
if (state === 'verified') rec.wtree = end;
const branchId = typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {};
const narrowed = start?.scope === 'fix-range';
if (narrowed) {
rec.scope = 'fix-range';
rec.fix_base = start.fix_base;
}
// A narrowed pass on unchanged content never carries start_wtree/end_wtree,
// so Step 11.5 cannot bind it and it never becomes CURRENT; only a later full pass can.
rec.review_binding = narrowed && (state === 'incomplete' || state === 'verified')
? { state: 'fix-range', fix_base: start.fix_base, range_start_wtree: start.wtree, range_end_wtree: end, started_at: start.started_at, ...branchId }
: { state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, ...branchId };
const verified = rec.review_binding.state === 'verified';
if (verified) rec.wtree = end;
if (rec.skill === 'review' && Array.isArray(rec.findings)) {
rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION;
for (const finding of rec.findings) {
@@ -270,7 +314,7 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
const fingerprint = sharedLibsFingerprint(finding);
if (!fingerprint) continue;
finding.fingerprint = fingerprint;
finding.snapshot_covered_paths = state === 'verified' && finding.action === 'skipped'
finding.snapshot_covered_paths = verified && finding.action === 'skipped'
? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : [];
}
}
@@ -282,6 +326,7 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
const binding = rec.review_binding;
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
return { status: 'UNVERIFIED', reason: 'missing start capture or incomplete/nonconverged pass' };
+26 -25
View File
@@ -3,8 +3,8 @@
## Step 9: Pre-Landing Review
Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope
needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2),
exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4).
needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2),
QA (9.2.1), dedup (9.3), fixes and logging (9.4).
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
Steps 10–11 queue findings without editing; include those findings in this pass.
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
@@ -78,6 +78,10 @@ This pass is static; defer product probes to Step 9.2.1.
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
**Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints
REVIEW_START, then `FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass.
If it refuses, start a full pass.
2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate:
List every gate edit in the candidate (test, CI, runner/lint-config, snapshot and
@@ -121,9 +125,9 @@ fi
### Design-lite checklist
Its numbering is local to this checklist. When frontend review applies, `/ship`
automatically attempts this optional design check; `enabled` expresses that choice,
not a new user question. Step 11 has its own outside-review switch and required native pass.
Its numbering is local. `/ship` attempts this design check automatically when frontend
review applies; `enabled` is that choice, not a user question. Step 11 has its own
outside-review switch and required native pass.
## Design Review (conditional, diff-scoped)
@@ -257,10 +261,9 @@ Use the original DESIGN_START token. COMPLETED is true only when the native chec
Substitute: TIMESTAMP = ISO 8601 datetime, STATUS = "clean" if 0 findings or "issues_found", N = total findings, M = auto-fixed count, D = counted detector findings from step 0 (0 when the detector did not run), COMMIT = output of `git rev-parse --short HEAD`.
The parent owns design-lite; the Design specialist is an independent read.
Before final counting/Fix-First, merge the same evidenced design defect at the same path/line
into one item with both sources and stricter ASK. Retain actual specialist stats;
distinct defects stay separate and neither pass substitutes for the other.
Design-lite (parent) and the Design specialist are independent reads. Before counting
and Fix-First, merge one evidenced design defect at the same path/line into one item
with both sources and the stricter ASK. Keep actual specialist stats and distinct defects.
## Step 9.1: Review Army — Specialist Dispatch
@@ -623,11 +626,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix:
`[AUTO-FIXED] [file:line] Problem → what you did`
3. **If ASK items remain,** present them in ONE AskUserQuestion:
- List each with number, severity, problem, recommended fix
- Per-item options: A) Fix B) Skip
- Overall RECOMMENDATION
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or
fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip,
plus an overall RECOMMENDATION.
Save each explicit Skip immediately in the invocation action list with its
identity, scope and supporting source evidence; keep it across repeats.
@@ -674,10 +675,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
```bash
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START
```
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output;
otherwise `clean` only for completed coverage with no
unresolved non-advisory defects; otherwise `issues_found`. N counts current
unresolved defects, not original totals. Missing coverage is not a defect.
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output,
else `clean` only for completed coverage with no unresolved non-advisory defects,
else `issues_found`. N counts unresolved defects now, not original totals; missing
coverage is not a defect.
- `REVIEW_START`: this pass's Step 9 token captured before reading the diff;
never recapture at persistence to certify unreviewed fixes.
- `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass.
@@ -699,13 +700,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
`open` counts in `issues_found` and by severity. The logger stamps `actor` from
`gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on
gate findings in a non-interactive session.
Save the review output and the `Gate edits:` line — they go into the PR body in Step 19.
Any `open` gate finding also decides Step 19's draft state.
Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate
finding makes that PR a draft.
### Decide whether to repeat Step 9
After persistence, record missing dispatched output, CYCLES and applied fixes in
the invocation record. Apply these decisions in order:
After persistence, record missing output, CYCLES and fixes in the invocation record.
Apply these decisions in order:
1. **Dispatched reviewer output missing:** STOP and name each failed specialist or
Red Team. Retain queued fixes and restore coverage. If this pass made edits,
@@ -715,10 +716,10 @@ the invocation record. Apply these decisions in order:
`converged:false`; do not run a fourth fixing cycle.
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
failures and scope. Keep CYCLES and scoped approvals across this repeat.
failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat.
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
clears may you continue to Step 10. Undispatched gated/unsupported specialists
do not block independently, but never replace QA or required native review.
clears may you continue to Step 10, and only from a full pass: after a fix-range
pass, run one full Step 9 first.
**Required-probe parent gate:** With completed checklist and dispatched reviewers,
failed/unavailable required probes block continuation.
+26 -25
View File
@@ -1,8 +1,8 @@
## Step 9: Pre-Landing Review
Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope
needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2),
exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4).
needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2),
QA (9.2.1), dedup (9.3), fixes and logging (9.4).
Gated/unsupported specialists skip only their dispatch, never QA or Step 11.
Steps 10–11 queue findings without editing; include those findings in this pass.
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
@@ -18,6 +18,10 @@ This pass is static; defer product probes to Step 9.2.1.
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
**Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints
REVIEW_START, then `FIX_BASE=<sha>`, which stands in for `origin/<base>` and `<base>` in this pass.
If it refuses, start a full pass.
2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate:
{{GATE_SCAN_BLOCK}}
@@ -33,16 +37,15 @@ This pass is static; defer product probes to Step 9.2.1.
### Design-lite checklist
Its numbering is local to this checklist. When frontend review applies, `/ship`
automatically attempts this optional design check; `enabled` expresses that choice,
not a new user question. Step 11 has its own outside-review switch and required native pass.
Its numbering is local. `/ship` attempts this design check automatically when frontend
review applies; `enabled` is that choice, not a user question. Step 11 has its own
outside-review switch and required native pass.
{{DESIGN_REVIEW_LITE}}
The parent owns design-lite; the Design specialist is an independent read.
Before final counting/Fix-First, merge the same evidenced design defect at the same path/line
into one item with both sources and stricter ASK. Retain actual specialist stats;
distinct defects stay separate and neither pass substitutes for the other.
Design-lite (parent) and the Design specialist are independent reads. Before counting
and Fix-First, merge one evidenced design defect at the same path/line into one item
with both sources and the stricter ASK. Keep actual specialist stats and distinct defects.
{{REVIEW_ARMY}}
@@ -63,11 +66,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix:
`[AUTO-FIXED] [file:line] Problem → what you did`
3. **If ASK items remain,** present them in ONE AskUserQuestion:
- List each with number, severity, problem, recommended fix
- Per-item options: A) Fix B) Skip
- Overall RECOMMENDATION
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or
fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip,
plus an overall RECOMMENDATION.
Save each explicit Skip immediately in the invocation action list with its
identity, scope and supporting source evidence; keep it across repeats.
@@ -114,10 +115,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
```bash
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START
```
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output;
otherwise `clean` only for completed coverage with no
unresolved non-advisory defects; otherwise `issues_found`. N counts current
unresolved defects, not original totals. Missing coverage is not a defect.
- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output,
else `clean` only for completed coverage with no unresolved non-advisory defects,
else `issues_found`. N counts unresolved defects now, not original totals; missing
coverage is not a defect.
- `REVIEW_START`: this pass's Step 9 token captured before reading the diff;
never recapture at persistence to certify unreviewed fixes.
- `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass.
@@ -139,13 +140,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
`open` counts in `issues_found` and by severity. The logger stamps `actor` from
`gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on
gate findings in a non-interactive session.
Save the review output and the `Gate edits:` line — they go into the PR body in Step 19.
Any `open` gate finding also decides Step 19's draft state.
Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate
finding makes that PR a draft.
### Decide whether to repeat Step 9
After persistence, record missing dispatched output, CYCLES and applied fixes in
the invocation record. Apply these decisions in order:
After persistence, record missing output, CYCLES and fixes in the invocation record.
Apply these decisions in order:
1. **Dispatched reviewer output missing:** STOP and name each failed specialist or
Red Team. Retain queued fixes and restore coverage. If this pass made edits,
@@ -155,10 +156,10 @@ the invocation record. Apply these decisions in order:
`converged:false`; do not run a fourth fixing cycle.
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
failures and scope. Keep CYCLES and scoped approvals across this repeat.
failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat.
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
clears may you continue to Step 10. Undispatched gated/unsupported specialists
do not block independently, but never replace QA or required native review.
clears may you continue to Step 10, and only from a full pass: after a fix-range
pass, run one full Step 9 first.
**Required-probe parent gate:** With completed checklist and dispatched reviewers,
failed/unavailable required probes block continuation.
+175
View File
@@ -0,0 +1,175 @@
/**
* #3339: /ship Step 9 may re-review only the fix range, but a fix-range pass
* can never be the last review. The fix base comes from the review log (never
* the working tree or the caller), a narrowed row is never CURRENT and carries
* no start_wtree/end_wtree for Step 11.5 to bind, and narrowed dispatches stay
* out of specialist hit rates.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { canReuseSharedLibsAdvisory, reviewFreshness } from '../lib/review-evidence';
import { gitIn } from './helpers/scratch-repo';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
const W = 'a'.repeat(40);
let root: string, repo: string, env: NodeJS.ProcessEnv;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fix-range-'));
repo = path.join(root, 'repo');
fs.mkdirSync(repo);
env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state') };
delete env.GSTACK_HOME;
gitIn(repo, 'init -q -b feature');
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
gitIn(repo, 'add a.ts');
gitIn(repo, 'commit -q -m init');
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
function bin(name: string, args: string[]) {
const r = spawnSync(path.join(BIN, name), args, { cwd: repo, encoding: 'utf8', timeout: 60_000, env });
return { code: r.status, out: r.stdout.trim(), err: r.stderr };
}
const head = () => gitIn(repo, 'rev-parse HEAD').trim();
function fix(text: string) {
fs.writeFileSync(path.join(repo, 'a.ts'), text);
gitIn(repo, 'commit -q -am fix');
}
function pass(row: Record<string, unknown>, start: string[] = ['--start', 'review']) {
const started = bin('gstack-review-log', start);
expect(started.code).toBe(0);
const [token, base] = started.out.split('\n');
const logged = bin('gstack-review-log', [JSON.stringify({ skill: 'review', via: 'ship', status: 'clean', issues_found: 0, critical: 0, informational: 0, ...row }), '--finish', token!]);
expect(logged.code).toBe(0);
return base;
}
const fixing = { status: 'issues_found', completed: true, converged: false };
const clean = { completed: true, converged: true };
function rows(): any[] {
return bin('gstack-review-read', []).out.split('\n---CONFIG---')[0]!.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
}
const refused = () => bin('gstack-review-log', ['--start', 'review', '--fix-range']);
describe('fix-range start', () => {
test('after a completed fixing pass it prints the token and that pass commit as FIX_BASE', () => {
const reviewed = head();
pass(fixing);
fix('export const a = 2;\n');
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${reviewed}`);
});
test('chained fix-range passes take the latest fixing pass as base', () => {
pass(fixing);
fix('export const a = 2;\n');
const second = head();
pass(fixing, ['--start', 'review', '--fix-range']);
fix('export const a = 3;\n');
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${second}`);
});
test.each([
['no earlier pass', () => {}, 'no earlier pass on this branch'],
['a clean latest pass', () => pass(clean), 'the latest pass made no fixes'],
['an incomplete latest pass (coverage restoration stays full)', () => pass({ ...fixing, status: 'unavailable', completed: false }), 'the latest pass did not complete'],
['a Step 11 review after the latest pass', () => { pass(fixing); pass({ skill: 'adversarial-review', ...clean }, ['--start', 'adversarial-review']); }, 'a Step 11 review followed the latest pass'],
['content that changed during the latest pass', () => {
const token = bin('gstack-review-log', ['--start', 'review']).out;
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 9;\n');
bin('gstack-review-log', [JSON.stringify({ skill: 'review', ...fixing }), '--finish', token]);
}, 'the latest pass has no unchanged captured content'],
['a base that is no longer an ancestor of HEAD', () => {
fix('export const a = 2;\n');
pass(fixing);
gitIn(repo, 'reset -q --hard HEAD~1');
fix('export const a = 3;\n');
}, 'is not an ancestor of HEAD'],
])('refuses %s', (_, setup, reason) => {
setup();
const r = refused();
expect(r.code).toBe(1);
expect(r.out).toBe('');
expect(r.err).toContain('fix-range refused: ');
expect(r.err).toContain(reason);
expect(r.err).toContain('run a full pass (gstack-review-log --start review)');
});
test('rejects an unknown start flag with usage', () => {
expect(bin('gstack-review-log', ['--start', 'review', '--narrow']).code).toBe(2);
});
});
describe('a fix-range pass is never the last review', () => {
test('its row is fix-range scoped whatever the caller claims, unbindable at Step 11.5 and never CURRENT', () => {
const reviewed = head();
pass(fixing);
fix('export const a = 2;\n');
pass({ ...clean, scope: 'full', fix_base: 'f'.repeat(40), review_binding: { state: 'verified', start_wtree: W, end_wtree: W }, wtree: W },
['--start', 'review', '--fix-range']);
const row = rows().at(-1);
expect(row.scope).toBe('fix-range');
expect(row.fix_base).toBe(reviewed);
expect(row.review_binding.state).toBe('fix-range');
expect(row.review_binding.range_start_wtree).toBe(row.review_binding.range_end_wtree);
expect(row.review_binding).not.toHaveProperty('start_wtree');
expect(row.review_binding).not.toHaveProperty('end_wtree');
expect(row).not.toHaveProperty('wtree');
expect(row.review_freshness).toEqual({ status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' });
expect(refused().err).toContain('the latest pass made no fixes');
pass(clean);
expect(rows().at(-1).review_freshness.status).toBe('CURRENT');
});
test('a caller cannot narrow a full pass by claiming fix-range', () => {
pass({ ...clean, scope: 'fix-range', fix_base: W });
const row = rows().at(-1);
expect(row).not.toHaveProperty('scope');
expect(row).not.toHaveProperty('fix_base');
expect(row.review_freshness.status).toBe('CURRENT');
});
test('freshness rejects a fix-range row even with a verified-looking binding', () => {
const rec = { skill: 'review', scope: 'fix-range', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: W,
review_binding: { state: 'verified', start_wtree: W, end_wtree: W } };
expect(reviewFreshness(rec, W)?.status).toBe('UNVERIFIED');
expect(reviewFreshness({ ...rec, scope: undefined }, W)?.status).toBe('CURRENT');
});
test('shared-libs advisory reuse never trusts a fix-range row', () => {
const finding = { severity: 'INFORMATIONAL', advisory: true, action: 'skipped', evidence_paths: ['a.ts'], helper_target: { path: 'b.ts', symbol: 'b' } };
const fp = 'shared-libs:' + 'c'.repeat(64);
const prior = { skill: 'review', completed: true, converged: true, wtree: W, scope: 'fix-range',
review_binding: { state: 'fix-range', range_start_wtree: W, range_end_wtree: W, branch_id: 'd'.repeat(64) } };
expect(canReuseSharedLibsAdvisory({ ...finding, fingerprint: fp, snapshot_covered_paths: ['a.ts'] }, finding, prior,
{ wtree: W, branch_id: 'd'.repeat(64), covered_paths: ['a.ts'] })).toBe(false);
});
});
describe('gstack-specialist-stats', () => {
test('fix-range passes do not count toward hit rates or gate candidacy', () => {
const entry = (scope?: string) => JSON.stringify({ ...(scope ? { scope } : {}), specialists: { testing: { dispatched: true, findings: 0 } } });
const slug = bin('gstack-slug', []).out.match(/^SLUG=(.+)$/m)![1]!;
const dir = path.join(root, 'state', 'projects', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'feature-reviews.jsonl'), [...Array(9).fill(entry()), ...Array(5).fill(entry('fix-range'))].join('\n') + '\n');
const out = bin('gstack-specialist-stats', []).out;
expect(out).toContain('SPECIALIST_STATS: 9 reviews analyzed');
expect(out).toContain('testing: 9/9 dispatched, 0 findings (0%)');
expect(out).not.toContain('GATE_CANDIDATE');
});
});
describe('ship Step 9 instructions', () => {
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.');
});
});