feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups

Follow-up to the review-loop convergence PR: Step 12 and the CHANGELOG step skip cleanly in a repository with no version source (receipt version "none"); the receipt records the docs audit outcome and the owner exception (DOCS_AUDIT_BLOCKED without one) and a policy may declare doc caps (gstack-ship-receipt docs-caps); docs/unattended.md names the READY evidence and the cross-family outside voice; the ship-frozen-scope-convergence behaviour case runs the generated Step 9.4 under review_scope frozen through the real binaries; carry probes git >= 2.39 (RECEIPT_GIT_TOO_OLD); defer passes the repository to gh repo view positionally and names its search limit; late defers count without being passes; reviewers diff from RANGE_BASE on pass 2+; the cap check runs before edits; --review-fanout batches specialists by free slots; read --check-prose compares quoted counters with the receipt (RECEIPT_PROSE_MISMATCH); checklist rule ids prereg-with-results and test-keyboard-count-loop; gstack-coauthor-trailers.

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-26 20:49:56 +00:00
1 parent 576839cb8c
commit 2d73fadd56
47 files changed
+1909 -155

No files matched your search

+1 -1
View File
@@ -141,7 +141,7 @@ test('live periodic census fits the declared CI wall including setup', () => {
test('registered allocation is deterministic and preserves every discovered file', () => {
const files = collectPaidTestFiles();
expect(files).toHaveLength(110); // + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0);
expect(files).toHaveLength(111); // + skill-e2e-ship-frozen-scope (frozen review scope, GSTA-113 PR 2); + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0);
expect(files).toContain('test/skill-e2e-ship-skip.test.ts');
const m = livePlan(files);
expect(livePlan([...files].reverse())).toEqual(m);
+2
View File
@@ -327,6 +327,8 @@ describe('parity with the measured prototype', () => {
const REVIEWED_SUPERSET: Record<string, string> = {
setup: './setup, $ROOT/setup and join(..., "setup") exec edges (plan A2-2); setup is a full-gate file, so the closure stops there',
'lib/bun-isolation.ts': 'GSTA-56: every bun launcher (script-invocation, restamp, qa-deadline, the hooks) imports it, so it reaches every consumer of those launchers',
'lib/ship-receipt-docs.ts': 'bin/gstack-ship-receipt imports it (the docs audit record and doc caps), so it reaches every consumer of the receipt binary',
'lib/ship-receipt-prose.ts': 'bin/gstack-ship-receipt imports it (read --check-prose), so it reaches every consumer of the receipt binary',
};
test('the shipped map equals or is a reviewed superset of the prototype map on this checkout', () => {
+5 -4
View File
@@ -769,7 +769,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
## Step 12: Version bump (auto-decide)
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
`ship: queue_mode stamp-at-merge` replaces items 1–4.
@@ -789,9 +789,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Repair alone never re-bumps.
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
matches base. Reconcile the manual edit, then reclassify.
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
unprefixed title in Step 18 and log `"version":null` in Step 20.
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
`"version":null` in Step 20.
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
+5 -4
View File
@@ -774,7 +774,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
## Step 12: Version bump (auto-decide)
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
`ship: queue_mode stamp-at-merge` replaces items 1–4.
@@ -794,9 +794,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Repair alone never re-bumps.
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
matches base. Reconcile the manual edit, then reclassify.
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
unprefixed title in Step 18 and log `"version":null` in Step 20.
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
`"version":null` in Step 20.
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
+5 -4
View File
@@ -790,7 +790,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
## Step 12: Version bump (auto-decide)
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
`ship: queue_mode stamp-at-merge` replaces items 1–4.
@@ -811,9 +811,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Repair alone never re-bumps.
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
matches base. Reconcile the manual edit, then reclassify.
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
unprefixed title in Step 18 and log `"version":null` in Step 20.
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
`"version":null` in Step 20.
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
+5 -4
View File
@@ -771,7 +771,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
## Step 12: Version bump (auto-decide)
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Otherwise FRESH
Item 3 needs `BUMP_LEVEL`: reuse this invocation's saved level. Else FRESH
chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
`ship: queue_mode stamp-at-merge` replaces items 1–4.
@@ -792,9 +792,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
Repair alone never re-bumps.
- **DRIFT_UNEXPECTED** → STOP: package.json disagrees with VERSION while VERSION
matches base. Reconcile the manual edit, then reclassify.
- **NO_VERSION** → print `notice` verbatim and ship without a version change: skip
the rest of Step 12 and Step 13's CHANGELOG entry, never create VERSION, use an
unprefixed title in Step 18 and log `"version":null` in Step 20.
- **NO_VERSION** → print `Version bump: skipped (no version source: <path, reason>)` and
`notice`; ship without a version change: skip the rest of Step 12 and Step 13's
CHANGELOG entry, never create VERSION, use an unprefixed title in Step 18 and log
`"version":null` in Step 20.
- **Exit 2** → STOP and show stderr: a configured version file is missing, empty,
unreadable or malformed. Fix it or its pin, then reclassify. Never substitute `0.0.0.0`.
+1
View File
@@ -103,6 +103,7 @@
{ "file": "lib/evidence-bundle.ts", "movedFrom": "(new: portable evidence bundle, execution identity and ancestor check, C3/C7)" },
{ "file": "lib/tree-receipt.ts", "movedFrom": "(new: tree equality modulo stamps and per-gate reuse, C3)" },
{ "file": "lib/ship-receipt.ts", "movedFrom": "(new: the ship receipt block, PR-body placement and adoption census, C5)" },
{ "file": "lib/ship-receipt-docs.ts", "movedFrom": "(new: the receipt's docs record, the owner exception and the policy doc caps, kept out of lib/ship-receipt.ts; convergence lane PR 2)" },
{ "file": "lib/spend-ledger.ts", "movedFrom": "(new: the spend cap admission ledger reserve/settle/release and the file lock, B2)" },
{ "file": "lib/outside-voice-runner.ts", "movedFrom": "(new: the outside-voice adapter interface, model families and runner selection, B3)" },
{ "file": "lib/outside-voice-api.ts", "movedFrom": "(new: the direct OpenAI/Anthropic api runner labeled supplied-input review, B3)" },
+78
View File
@@ -0,0 +1,78 @@
/**
* Free contracts of gstack-coauthor-trailers: distinct authors of a range in
* first-appearance order, the checkout's own identity and --exclude left out,
* one Co-authored-by line each, and an idempotent --message append. Commits are
* written through fast-import so their authors are the stream's, whatever
* identity the host forces on `git commit`.
*/
import { afterAll, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { EXIT } from '../lib/headless-artifacts';
const BIN = path.resolve(import.meta.dir, '../bin/gstack-coauthor-trailers');
const temps: string[] = [];
afterAll(() => { for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true }); });
const ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
function git(cwd: string, args: string[], input?: string) {
const r = spawnSync('git', args, { cwd, env: ENV, encoding: 'utf8', timeout: 30_000, input });
if (r.status !== 0) throw new Error(r.stderr);
return r.stdout.trim();
}
/** The committer identity git reports in this checkout (whatever the host forces); the bin must never list it. */
function selfEmail(cwd: string): string { return /<([^>]*)>/.exec(git(cwd, ['var', 'GIT_COMMITTER_IDENT']))![1]!; }
function repo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coauthor-'));
temps.push(dir);
git(dir, ['init', '-q', '-b', 'main']);
const self = selfEmail(dir);
let mark = 0;
const commit = (branch: string, file: string, msg: string, author: string, from?: string) => {
mark++;
const at = `${1700000000 + mark} +0000`;
const parent = from ? git(dir, ['rev-parse', from]) : '';
const stream = `commit refs/heads/${branch}\nmark :${mark}\nauthor ${author} ${at}\ncommitter Porter <${self}> ${at}\ndata ${Buffer.byteLength(msg)}\n${msg}\n`
+ (parent ? `from ${parent}\n` : '') + `M 100644 inline ${file}\ndata ${Buffer.byteLength(msg) + 1}\n${msg}\n\n`;
git(dir, ['fast-import', '--quiet', '--date-format=raw', '--force'], stream);
};
commit('main', 'a.txt', 'seed', `Porter <${self}>`);
git(dir, ['update-ref', 'refs/remotes/origin/main', 'refs/heads/main']);
git(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']);
commit('port/contrib', 'b.txt', 'contributor fix', 'Ada Contributor <Ada@Example.org>', 'refs/heads/main');
commit('port/contrib', 'c.txt', 'second contributor', 'Bo Helper <bo@example.org>', 'refs/heads/port/contrib');
commit('port/contrib', 'd.txt', 'ada again', 'Ada Contributor <ada@example.org>', 'refs/heads/port/contrib');
commit('port/contrib', 'e.txt', 'porter follow-up', `Porter <${self}>`, 'refs/heads/port/contrib');
git(dir, ['checkout', '-q', 'port/contrib']);
return dir;
}
const run = (cwd: string, args: string[]) => spawnSync(BIN, args, { cwd, env: ENV, encoding: 'utf8', timeout: 60_000 });
test('the default range is origin/HEAD..HEAD; authors print once each in first-appearance order without the porter', () => {
const dir = repo();
expect(git(dir, ['log', '--format=%ae', 'origin/main..HEAD']).split('\n')).toHaveLength(4);
const r = run(dir, []);
expect(r.status).toBe(EXIT.ok);
expect(r.stdout).toBe('Co-authored-by: Ada Contributor <Ada@Example.org>\nCo-authored-by: Bo Helper <bo@example.org>\n');
expect(r.stderr.trim()).toBe('COAUTHORS: 2 in origin/main..HEAD');
const json = JSON.parse(run(dir, ['--json', '--exclude', 'BO@example.org']).stdout);
expect(json.trailers).toEqual(['Co-authored-by: Ada Contributor <Ada@Example.org>']);
expect(run(dir, ['HEAD~1..HEAD']).stdout).toBe('');
});
test('--message appends only the missing trailers and is idempotent; a bad range exits 1 and a bad flag 2', () => {
const dir = repo();
const file = path.join(dir, 'MSG');
fs.writeFileSync(file, 'port: contributor fix\n\nCo-authored-by: ada contributor <ADA@example.org>\n');
expect(run(dir, ['--message', file]).status).toBe(EXIT.ok);
const expected = 'port: contributor fix\n\nCo-authored-by: ada contributor <ADA@example.org>\n\nCo-authored-by: Bo Helper <bo@example.org>\n';
expect(fs.readFileSync(file, 'utf8')).toBe(expected);
const again = run(dir, ['--message', file, '--json']);
expect(JSON.parse(again.stdout).appended).toEqual([]);
expect(fs.readFileSync(file, 'utf8')).toBe(expected);
expect(run(dir, ['nope..HEAD']).status).toBe(EXIT.fail);
expect(run(dir, ['--bogus']).status).toBe(EXIT.usage);
});
+197
View File
@@ -0,0 +1,197 @@
/**
* PR 2 of the /ship + /review convergence lane: a repository with no version
* source or CHANGELOG ships with `version: "none"` (E18); the documentation
* audit's owner-accept path and the policy's doc caps (E11); the carry's git
* floor, named patch-id refusal and branch-touched walk; the digit-bounded
* stamp replacement.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { CARRY_NAMED_PATHS, extractReceipt, planCarry, stampOnlyText } from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs, receiptDocs } from '../lib/ship-receipt-docs';
import { DOCS_CAPS_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
import { cleanup, makeFixtureRepo, makePr, runBin, type FixtureRepo } from './helpers/restamp-fixture';
import { gitArgvIn } from './helpers/scratch-repo';
const repos: FixtureRepo[] = [];
const fixture = (opts: Parameters<typeof makeFixtureRepo>[0] = {}) => { const r = makeFixtureRepo({ prefix: 'ship-receipt-2-', ...opts }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
/** A repository with no VERSION, no package.json, no CHANGELOG and no policy: the shape E18 names. */
function bareRepo(): FixtureRepo {
const repo = fixture();
for (const f of ['VERSION', 'package.json', 'package-lock.json', 'CHANGELOG.md', 'gen.js', 'generated', 'docs', '.gstack']) fs.rmSync(path.join(repo.work, f), { recursive: true, force: true });
fs.writeFileSync(path.join(repo.work, 'README.md'), '# fixture\n');
repo.git('add', '-A');
repo.git('commit', '-qm', 'no version source');
repo.git('push', '-q', '-f', 'origin', 'main');
return repo;
}
describe('E18: a repository without a version source or CHANGELOG', () => {
test('write records version "none"; read --body and result --body accept it', () => {
const repo = bareRepo();
const head = repo.git('rev-parse', 'HEAD');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--session-kind', 'unattended', '--body', body]);
expect(w.status).toBe(EXIT.ok);
expect(w.err.trim()).toContain(`head=${head.slice(0, 12)} version=none base=main`);
const parsed = extractReceipt(w.out);
expect(parsed.errors).toEqual([]);
expect(parsed.receipt).toMatchObject({ pr: 5, head, version: 'none' });
const read = runBin(repo, 'gstack-ship-receipt', ['read', '--body', body, '--json']);
expect(read.status).toBe(EXIT.ok);
expect(JSON.parse(read.out).version).toBe('none');
const out = path.join(repo.root, 'run');
const result = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out]);
expect(result.status).toBe(EXIT.ok);
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${out}`);
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
});
test('a repository with a version source keeps its version, and one with only a CHANGELOG records no version rather than "none"', () => {
const repo = fixture();
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
expect(JSON.parse(w.out).version).toBe('1.2.3.0');
for (const f of ['VERSION', 'package.json', 'package-lock.json']) fs.rmSync(path.join(repo.work, f));
repo.git('add', '-A');
repo.git('commit', '-qm', 'drop the version files, keep the changelog');
const only = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']);
expect(only.status).toBe(EXIT.ok);
expect('version' in JSON.parse(only.out)).toBe(false);
});
});
describe('E11: the documentation audit record and the doc caps', () => {
test('parseReceiptDocs: blocked without an exception is DOCS_AUDIT_BLOCKED; malformed input is RECEIPT_INVALID; a full record parses', () => {
expect(parseReceiptDocs({ status: 'blocked', scope: 'docs/' })).toMatchObject({ ok: false, code: 'DOCS_AUDIT_BLOCKED' });
expect(parseReceiptDocs({ status: 'current', scope: 'docs/' })).toEqual({ ok: true, docs: { status: 'current', scope: 'docs/' } });
for (const bad of [null, [], 'current', { status: 'done', scope: 'x' }, { status: 'current' }, { status: 'current', scope: '' }, { status: 'current', scope: 'x', extra: 1 },
{ status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: '2026-10-26', via: 'r' } }, { status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: 'yesterday', via: 'r', reason: 'w' } },
{ status: 'blocked', scope: 'x', exception: { accepted_by: 'o', date: '2026-10-26', via: 'r', reason: 'w', more: 1 } }]) {
expect(parseReceiptDocs(bad)).toMatchObject({ ok: false, code: 'RECEIPT_INVALID' });
}
const full = parseReceiptDocs({ status: 'blocked', scope: 'docs/, README.md', exception: { accepted_by: ' owner ', date: '2026-10-26', via: 'PR #12 comment', reason: 'the removed page is republished next release' } });
expect(full).toEqual({ ok: true, docs: { status: 'blocked', scope: 'docs/, README.md', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #12 comment', reason: 'the removed page is republished next release' } } });
expect(docsExceptionLine((full as any).docs)).toBe('Documentation exception: accepted by owner 2026-10-26 via PR #12 comment: the removed page is republished next release');
expect(docsExceptionLine({ status: 'current', scope: 'x' })).toBeNull();
expect(receiptDocs({ docs: { status: 'updated', scope: 'docs/' } })).toEqual({ status: 'updated', scope: 'docs/' });
expect(receiptDocs({})).toBeUndefined();
});
test('write --docs refuses a blocked audit with no exception and a body without the exception line, and records the exception otherwise', () => {
const repo = fixture();
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Documentation\n\nAudit blocked on docs/removed.md.\n');
const common = ['write', '--pr', '9', '--base', 'main', '--session-kind', 'unattended', '--body', body];
const blocked = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"blocked","scope":"docs/"}']);
expect(blocked.status).toBe(EXIT.refused);
expect(blocked.err).toContain('DOCS_AUDIT_BLOCKED');
expect(blocked.err).toContain('troubleshooting.md#docs-audit-blocked');
expect(fs.readFileSync(body, 'utf8')).not.toContain('gstack-ship-receipt');
const docsFile = path.join(repo.root, 'docs.json');
fs.writeFileSync(docsFile, JSON.stringify({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } }));
const noLine = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '@' + docsFile]);
expect(noLine.status).toBe(EXIT.refused);
expect(noLine.err).toContain('does not carry the exception line verbatim');
expect(noLine.err).toContain('"Documentation exception: accepted by owner 2026-10-26 via PR #9 review: removed page republished in the next release"');
fs.appendFileSync(body, 'Documentation exception: accepted by owner 2026-10-26 via PR #9 review: removed page republished in the next release\n');
const ok = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '@' + docsFile, '--review-loop', '{"passes":1,"blocking_found":0,"deferred":0}']);
expect(ok.status).toBe(EXIT.ok);
const receipt = extractReceipt(fs.readFileSync(body, 'utf8'));
expect(receipt.errors).toEqual([]);
expect((receipt.receipt as any).docs).toEqual({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } });
expect(Object.keys(receipt.receipt!).slice(-2)).toEqual(['docs', 'review_loop']);
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--body', body]).status).toBe(EXIT.ok);
const malformed = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"done","scope":"docs/"}']);
expect(malformed.status).toBe(EXIT.fail);
expect(malformed.err).toContain('RECEIPT_INVALID');
});
test('checkDocsCaps and docs-caps: one line per cap, over or absent exits 1 with DOCS_AUDIT_BLOCKED, no caps prints none declared', () => {
expect(checkDocsCaps([{ path: 'a.md', max_bytes: 10 }, { path: 'b.md', max_bytes: 10 }, { path: 'c.md', max_bytes: 10 }], p => ({ 'a.md': 10, 'b.md': 11 } as Record<string, number>)[p] ?? null).map(docsCapLine))
.toEqual(['DOCS_CAP: a.md 10/10 ok', 'DOCS_CAP: b.md 11/10 over', 'DOCS_CAP: c.md absent/10 over']);
const repo = fixture();
const none = runBin(repo, 'gstack-ship-receipt', ['docs-caps']);
expect(none.status).toBe(EXIT.ok);
expect(none.out.trim()).toBe('DOCS_CAP: none declared');
const policy = path.join(repo.root, 'policy.json');
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: 'docs/notes.md', max_bytes: 4096 }, { path: 'CHANGELOG.md', max_bytes: 16 }, { path: 'docs/missing.md', max_bytes: 10 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }));
const r = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy]);
expect(r.status).toBe(EXIT.fail);
const changelogBytes = fs.statSync(path.join(repo.work, 'CHANGELOG.md')).size;
expect(r.out.trim().split('\n')).toEqual([
`DOCS_CAP: docs/notes.md ${fs.statSync(path.join(repo.work, 'docs/notes.md')).size}/4096 ok`,
`DOCS_CAP: CHANGELOG.md ${changelogBytes}/16 over`,
'DOCS_CAP: docs/missing.md absent/10 over',
]);
expect(r.err).toContain('DOCS_AUDIT_BLOCKED');
expect(r.err).toContain('2 of 3 documentation caps over: CHANGELOG.md, docs/missing.md');
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: 'docs/notes.md', max_bytes: 4096 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION }));
const ok = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy, '--json']);
expect(ok.status).toBe(EXIT.ok);
expect(JSON.parse(ok.out)).toMatchObject({ over: 0, caps: [{ path: 'docs/notes.md', verdict: 'ok' }] });
fs.writeFileSync(policy, JSON.stringify({ docs: { caps: [{ path: '../escape.md', max_bytes: 0 }] } }));
const bad = runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', policy]);
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('POLICY_CONTAINMENT');
expect(bad.err).toContain('docs.caps[0].max_bytes');
expect(runBin(repo, 'gstack-ship-receipt', ['docs-caps', '--policy', path.join(repo.root, 'nope.json')]).status).toBe(EXIT.usage);
});
test('the policy docs key validates its shape and needs min_gstack_version', () => {
const codes = (raw: Record<string, unknown>) => validatePolicy(raw, { gstackVersion: '9.9.9.9' }).map(e => `${e.code}:${e.path}`);
expect(codes({ docs: null })).toEqual([]);
expect(codes({ docs: { caps: [] } })).toEqual(['POLICY_INVALID:docs']);
expect(codes({ docs: { caps: [{ path: 'README.md', max_bytes: 100 }] }, min_gstack_version: DOCS_CAPS_KEY_VERSION })).toEqual([]);
expect(codes({ docs: { caps: 'README.md' }, min_gstack_version: DOCS_CAPS_KEY_VERSION })).toEqual(['POLICY_INVALID:docs.caps']);
expect(codes({ docs: { caps: [{ path: '/etc/x', max_bytes: 1.5, other: 1 }], limit: 1 }, min_gstack_version: DOCS_CAPS_KEY_VERSION }))
.toEqual(['POLICY_INVALID:docs.limit', 'POLICY_INVALID:docs.caps[0].other', 'POLICY_CONTAINMENT:docs.caps[0].path', 'POLICY_INVALID:docs.caps[0].max_bytes']);
});
});
describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit-bounded stamps', () => {
test('stampOnlyText replaces the version only at digit boundaries', () => {
const change = (before: string, after: string) => stampOnlyText({ before, after, oldVersion: '1.2.3', newVersion: '1.2.4' });
expect(change('v1.2.3 and 11.2.3 and 1.2.34\n', 'v1.2.4 and 11.2.3 and 1.2.34\n')).toBe(true);
expect(change('v1.2.3 and 11.2.3\n', 'v1.2.4 and 11.2.4\n')).toBe(false);
expect(change('1.2.34\n', '1.2.44\n')).toBe(false);
});
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
expect(plan.ok).toBe(false);
expect((plan as any).message).toContain(`(+3 more)`);
expect((plan as any).message.split(', ').length).toBe(CARRY_NAMED_PATHS);
});
test('carry refuses git below 2.39 by name (RECEIPT_GIT_TOO_OLD) and names a product path the branch touched', () => {
const repo = fixture();
makePr(repo, 'pr-carry', { expects: '1.2.4.0' });
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]).status).toBe(EXIT.ok);
fs.writeFileSync(path.join(repo.work, 'src.ts'), 'export const x = 1;\n');
repo.git('add', 'src.ts');
repo.git('commit', '-qm', 'product change');
const shim = path.join(repo.root, 'shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'git'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.38.5"; exit 0; fi\nexec /usr/bin/git "$@"\n', { mode: 0o755 });
const realGit = gitArgvIn(repo.work, ['--version']).stdout.toString();
expect(realGit).toMatch(/git version/);
const old = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main'], { PATH: `${shim}:${process.env.PATH}` });
expect(old.status).toBe(EXIT.refused);
expect(old.err).toContain('RECEIPT_GIT_TOO_OLD');
expect(old.err).toContain('git 2.38.5 cannot run patch-id --verbatim; the carry needs git 2.39 or later');
expect(fs.readFileSync(body, 'utf8')).toContain('"pr": 3');
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(changed.status).toBe(EXIT.refused);
expect(changed.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(changed.err).toContain('src.ts');
});
});
+1 -1
View File
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.7150, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated
maxSizeRatio: 1.7400, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
+1 -1
View File
@@ -95,7 +95,7 @@ const CLAUDE_ONLY: Readonly<Record<ClaudeOnlyReason, readonly string[]>> = {
'plan-design-with-ui-scope', 'plan-devex-finding-floor', 'plan-devex-review-plan-mode', 'plan-mode-no-op'],
sdk: ['auq-format-gate', 'investigate-owned-abort', 'investigate-owned-completion', 'investigate-owned-ending-error',
'shared-libs-review-index-flags', 'shared-libs-review-lifecycle', 'shared-libs-review-path-eligibility',
'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-local-hook-preservation',
'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-frozen-scope-convergence', 'ship-local-hook-preservation',
'ship-managed-hook-refresh', 'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent'],
subagents: ['docsync-spawned', 'review-army-perf-n-plus-one'],
'skill-tool': ['router-failure-contradicted-cause', 'router-failure-triage-resolves', 'router-failure-triage-unclear',
+360
View File
@@ -0,0 +1,360 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import type { CanUseTool, HookCallback, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AgentSdkResult, QueryProvider } from './agent-sdk-runner';
import type { EvalTestEntry } from './eval-store';
import { CAPTURE_MS } from './eval-budgets';
import { SHARED_INTERACTIVE_MAX_TURNS } from './shared-libs-eval-fixture';
import { runGeneration } from '../../scripts/gen-skill-docs';
import { gitArgvIn } from './scratch-repo';
/**
* The native /ship frozen-scope convergence case (docs/ship-policy.md#review-scope).
*
* The actor runs the generated Step 9.4 and its repeat decision under
* review_scope "frozen" against a fixture branch with two CRITICAL findings:
* one in the diff (invoice.ts drops its owner check) and one in code the branch
* never touched (payments.ts interpolates a card number into SQL). The contract:
* the in-diff defect is fixed and committed, the pre-existing one is deferred
* and its file left byte-identical, pass 2 reviews only the range since pass 1
* and finds nothing, the loop exits through decision 4, and the review log
* yields REVIEW_LOOP passes=2 blocking_found=2 deferred=1 fixed=1. Every helper
* the model touches (gstack-review-scope, gstack-review-log) is the real binary
* in the fixture's owned state root; the adapters only bind their arguments.
*/
export const SHIP_FROZEN_SCOPE_CASE = 'ship-frozen-scope-convergence';
export const SHIP_FROZEN_SCOPE_SUITE = 'ship-frozen-scope';
export const FROZEN_SCOPE_FIX_IDENTITY = 'invoice.ts:1:authorization';
export const FROZEN_SCOPE_DEFER_IDENTITY = 'payments.ts:2:injection';
const ROOT = path.resolve(import.meta.dir, '../..');
const SAFE_INVOICE = 'export const canReadInvoice = (owner: string, viewer: string) => owner === viewer;\n';
const BROKEN_INVOICE = 'export const canReadInvoice = (_owner: string, _viewer: string) => true;\n';
const PAYMENTS = 'export const chargeCard = (db: { run: (sql: string) => void }, card: string, amount: number) =>\n'
+ " db.run(`INSERT INTO charges (card, amount) VALUES ('${card}', ${amount})`);\n";
const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
const read = (file: string) => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
const json = (file: string) => JSON.parse(fs.readFileSync(file, 'utf8'));
const write = (file: string, value: unknown) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
function command(cwd: string, env: NodeJS.ProcessEnv, executable: string, args: string[], deadline = Infinity) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error('Frozen-scope case deadline exhausted during setup');
const result = spawnSync(executable, args, { cwd, env, encoding: 'utf8', timeout: Math.min(20_000, remaining) });
if (result.status !== 0 || result.error) throw new Error(result.error?.message ?? (result.stderr || result.stdout));
return { stdout: result.stdout, stderr: result.stderr };
}
function section(text: string, first: string, last: string) {
const start = text.indexOf(first);
const end = text.indexOf(last, start + first.length);
if (start < 0 || end < start || text.indexOf(first, start + first.length) >= 0) throw new Error(`Ambiguous workflow boundary: ${first}`);
return text.slice(start, end).trim();
}
/** The generated Step 9.4 (scope, cap, fix, defer, log) and the repeat decision, as /ship renders them for Claude Code. */
export async function shipFrozenScopeWorkflow() {
const rendered = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'sfrz-render-'));
try {
const generated = await runGeneration({ host: 'claude', outputRoot: rendered, contentLinkRoot: null, log: () => {} });
if (generated.exitCode !== 0) throw new Error('Ship fixture generation failed');
const army = read(path.join(rendered, 'ship/sections/review-army.md'));
return section(army, '## Step 9.4: Fix-First and persistence', '**Required-probe parent gate:**');
} finally { fs.rmSync(rendered, { recursive: true, force: true }); }
}
export const FROZEN_SCOPE_FINDINGS = [
{ fingerprint: FROZEN_SCOPE_FIX_IDENTITY, path: 'invoice.ts', line: 1, severity: 'CRITICAL', category: 'authorization', source: 'checklist',
problem: 'canReadInvoice returns true for every viewer, so any caller reads any invoice.', recommended_fix: 'Accept only the invoice owner as the viewer.' },
{ fingerprint: FROZEN_SCOPE_DEFER_IDENTITY, path: 'payments.ts', line: 2, severity: 'CRITICAL', category: 'injection', source: 'specialist:security',
problem: 'chargeCard interpolates the card value into the SQL text, so a crafted card string runs arbitrary SQL.', recommended_fix: 'Bind card and amount as parameters.' },
];
export function createShipFrozenScopeFixture(workflow: string, root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'sfrz-')), deadline = Infinity) {
const routing = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']
.filter(key => process.env[key] !== undefined);
if (routing.length) throw new Error(`Refusing ambient Git routing: ${routing.join(', ')}`);
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
fs.chmodSync(root, 0o700);
const repo = path.join(root, 'project');
const home = path.join(root, 'home');
const state = path.join(root, 'state');
const assets = path.join(root, 'assets');
for (const dir of [repo, home, state, assets]) fs.mkdirSync(dir, { mode: 0o700 });
const env = { HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, CLAUDE_PLUGIN_DATA: '',
CLAUDE_CONFIG_DIR: path.join(root, 'claude-config'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0', GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid',
GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid',
PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? ''}` };
const git = (...args: string[]) => {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error('Frozen-scope case deadline exhausted during setup');
const result = gitArgvIn(repo, args, Math.min(10_000, remaining), env);
if (result.status !== 0 || result.error) throw new Error(result.error?.message ?? result.stderr.toString());
return result.stdout.toString().trim();
};
git('init', '-q', '-b', 'main');
const invoice = path.join(repo, 'invoice.ts');
const payments = path.join(repo, 'payments.ts');
fs.writeFileSync(invoice, SAFE_INVOICE, { mode: 0o644 });
fs.writeFileSync(payments, PAYMENTS, { mode: 0o644 });
fs.writeFileSync(path.join(repo, '.gitignore'), '.gstack/tmp/\n', { mode: 0o644 });
git('add', '.gitignore', 'invoice.ts', 'payments.ts');
git('commit', '-qm', 'Seed invoice authorization and card charges');
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
git('checkout', '-qb', 'fixture/frozen-scope');
fs.writeFileSync(invoice, BROKEN_INVOICE);
git('add', 'invoice.ts');
git('commit', '-qm', 'Let any viewer read an invoice');
const head = git('rev-parse', 'HEAD');
const tmp = path.join(repo, '.gstack', 'tmp');
fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
const findingsPath = path.join(tmp, 'review-findings-p1.json');
write(findingsPath, FROZEN_SCOPE_FINDINGS);
const workflowPath = path.join(assets, 'workflow.md');
fs.writeFileSync(workflowPath, workflow, { mode: 0o600 });
const draft = path.join(root, 'review-record.json');
const receipts = path.join(root, 'receipts.jsonl');
const fullEnv = { ...process.env, ...env };
const token = command(repo, fullEnv, path.join(ROOT, 'bin/gstack-review-log'), ['--start', 'review'], deadline).stdout.trim();
const startFiles = fs.readdirSync(state, { recursive: true }).filter(file => String(file).endsWith(`/${token}.json`));
if (startFiles.length !== 1) throw new Error('Expected one owned real review-start receipt');
const start = json(path.join(state, String(startFiles[0])));
if (start.repo !== repo || start.branch !== 'fixture/frozen-scope') throw new Error('Review-start receipt has foreign ownership');
const commands = Object.fromEntries(['classify', 'defer', 'persist', 'commit', 'repeat', 'continue', 'stop'].map(action =>
[action, `${quote(process.execPath)} ${quote(import.meta.path)} --fixture ${quote(root)} ${action}`])) as Record<string, string>;
write(path.join(root, 'fixture.json'), { repo, env, invoice, payments, head, tokens: [token], draft, receipts, tmp });
const executions: Array<{ tool: string; input: Record<string, unknown>; allowed: boolean; pass: number }> = [];
const refusals: string[] = [];
const rows = () => read(receipts).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { action: string; pass: number; head?: string });
const pass = () => rows().filter(row => row.action === 'persist').length + 1;
const finished = () => rows().some(row => row.action === 'continue' || row.action === 'stop');
const readable = new Set([workflowPath, invoice, payments]);
const invalid = (tool: string, input: Record<string, unknown>) => {
if (finished()) return 'Loop decision already taken';
const target = typeof input.file_path === 'string' ? path.resolve(repo, input.file_path) : '';
if (tool === 'Read') return target && (readable.has(target) || (target.startsWith(tmp + path.sep) && fs.existsSync(target))) ? undefined : 'Read outside the declared files';
if (tool === 'Write') {
if (target === draft) return typeof input.content === 'string' && input.content.length <= 16_384 ? undefined : 'Review record too large';
if (target === invoice) return typeof input.content === 'string' ? undefined : 'Write needs content';
return 'Write outside invoice.ts and the review record';
}
if (tool === 'Edit') return target === invoice && typeof input.old_string === 'string' && typeof input.new_string === 'string' ? undefined : 'Edit outside invoice.ts';
if (tool === 'Bash') return typeof input.command === 'string' && !input.run_in_background && Object.values(commands).includes(input.command.trim()) ? undefined : 'Bash outside the fixture interface';
return 'Undeclared tool';
};
const canUseTool: CanUseTool = async (tool, input) => {
const reason = invalid(tool, input as Record<string, unknown>);
if (reason) { refusals.push(reason); return { behavior: 'deny', message: reason }; }
return { behavior: 'allow', updatedInput: input };
};
const preToolUse: HookCallback = async input => {
if (input.hook_event_name !== 'PreToolUse') throw new Error('Unexpected hook');
const toolInput = input.tool_input as Record<string, unknown>;
const reason = invalid(input.tool_name, toolInput);
executions.push({ tool: input.tool_name, input: toolInput, allowed: !reason, pass: pass() });
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: reason ? 'deny' : 'allow',
...(reason ? { permissionDecisionReason: reason } : {}),
...(input.tool_name === 'Bash' && !reason ? { updatedInput: { command: toolInput.command, timeout: 30000, run_in_background: false } } : {}) } };
};
const prompt = `Load gstack's bounded /ship review-loop path from ${workflowPath}: Step 9.4 and the "Decide whether to repeat Step 9" decisions that follow it. This invocation runs unattended (SESSION_KIND: headless, no owner present) under review_scope policy \`frozen\` with base \`origin/main\`, on branch fixture/frozen-scope in ${repo}. PASS is 1 and CYCLES is 0 on entry. The pass-1 Step 9.3 result is already written at ${findingsPath}; verify each finding against its source (${invoice}, ${payments}) before acting on the classified output. Execute the workflow through its decision, pass by pass, until the decision continues to Step 10 or stops the loop.
Supported interactions, declared before launch:
- Read: ${workflowPath}, ${invoice}, ${payments}, and any file under ${tmp}/ once it exists.
- Edit or Write: ${invoice} only, and only to apply a fix for a finding the classified output scopes \`fix\`. ${payments} is never written in this fixture. Write also accepts ${draft}, the item-6 review record of the current pass: the JSON object the gstack-review-log example takes (skill, timestamp, status, issues_found, critical, informational, quality_score 10.0, specialists {}, findings with fingerprint, severity, action and the copied locus, kind and blocking, commit, via "ship", completed, converged, cycles, pass, review_scope, range_base).
- Bash accepts exactly these single commands, without chaining, redirection or backgrounding. Each is the bound adapter for the generated helper command it names and does not decide which item or decision applies:
${commands.classify} runs gstack-review-scope classify for the current pass against this pass's .gstack/tmp/review-findings-p$PASS.json (--range-base is bound from pass 2 on); it writes .gstack/tmp/review-classified-p$PASS.json and prints the REVIEW_SCOPE line and the classified JSON.
${commands.defer} runs gstack-review-scope defer on the current pass's classified file; it writes .gstack/tmp/review-deferred.json and prints the DEFERRED lines and the rendered PR-body sections (gh is absent here, so a deferred finding stays unfiled with its reason).
${commands.persist} passes ${draft} to the real gstack-review-log with this pass's REVIEW_START token (--finish); the pass-1 token was captured before this prompt, and the repeat adapter captures the next one.
${commands.commit} runs item 4's commit of the fixed file (git add invoice.ts && git commit -m "fix: pre-landing review fixes").
${commands.repeat} takes decision 3: it runs the next pass's Steps 5 through 9.3 on the range since the previous pass, writes that pass's .gstack/tmp/review-findings-p$PASS.json and prints the result with the new REVIEW_START captured.
${commands.continue} takes decision 4 (no edits in this pass): continue to Step 10 and stop this fixture.
${commands.stop} takes decision 1 or 2: STOP the loop and stop this fixture.
No owner can answer here, so AskUserQuestion is not available: a finding the classified output scopes \`fix\` is resolved as AUTO-FIX under the Fix-First heuristic, and no finding is skipped. The gate scan of item 2.5, freshness, memory updates and Step 10 itself are outside this fixture. Stop after the continue or stop adapter completes.`;
const snapshot = () => ({ invoice: read(invoice), payments: read(payments), originalPayments: PAYMENTS, head, start, refusals, executions, receipts: rows(),
workflowSha256: digest(workflow), promptSha256: digest(prompt), stateRoot: state, repo,
status: gitArgvIn(repo, ['status', '--porcelain'], 10_000, env).stdout.toString().trim(),
headNow: gitArgvIn(repo, ['rev-parse', 'HEAD'], 10_000, env).stdout.toString().trim(),
rows: (() => { try { return reviewRows(repo, fullEnv); } catch (error) { return { error: String(error) }; } })(),
loop: (() => { try { return loopSummary(repo, fullEnv, start.started_at); } catch (error) { return { error: String(error) }; } })() });
return { root, repo, env, workflowPath, findingsPath, invoice, payments, draft, commands, prompt, preToolUse, canUseTool, snapshot };
}
function reviewRows(repo: string, env: NodeJS.ProcessEnv) {
const out = command(repo, env, path.join(ROOT, 'bin/gstack-review-read'), []).stdout;
return out.split('---CONFIG---')[0]!.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, any>);
}
function loopSummary(repo: string, env: NodeJS.ProcessEnv, since: string) {
return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--since', since, '--json']).stdout.trim());
}
export async function shipFrozenScopeFailures(fixture: ReturnType<typeof createShipFrozenScopeFixture>, result: Pick<AgentSdkResult, 'exitReason'>) {
const evidence = fixture.snapshot();
const failures: string[] = [];
const check = (ok: boolean, message: string) => { if (!ok) failures.push(message); };
check(result.exitReason === 'success', `actor ended: ${result.exitReason}`);
check(evidence.refusals.length === 0 && !evidence.executions.some(event => !event.allowed), `undeclared interaction: ${evidence.refusals.join('; ')}`);
check(evidence.payments === evidence.originalPayments, 'pre-existing payments.ts was edited: the frozen scope fixes only the diff');
try {
const url = `${pathToFileURL(fixture.invoice).href}?v=${randomUUID()}`;
const mod = await import(url) as { canReadInvoice?: (owner: string, viewer: string) => boolean };
check(typeof mod.canReadInvoice === 'function' && mod.canReadInvoice('owner', 'owner') === true && mod.canReadInvoice('owner', 'other') === false,
'invoice.ts still accepts a viewer other than the owner');
} catch (error) { failures.push(`invoice.ts does not load after the fix: ${String(error).split('\n')[0]}`); }
const actions = evidence.receipts.map(row => row.action);
check(actions.at(-1) === 'continue', `loop did not exit through decision 4 (adapters: ${actions.join(' → ') || 'none'})`);
check(actions.filter(action => action === 'repeat').length === 1, 'expected exactly one repeat pass');
check(actions.filter(action => action === 'persist').length === 2, 'expected one logged row per pass');
const order = (first: string, second: string) => actions.includes(first) && actions.includes(second) && actions.indexOf(first) < actions.indexOf(second);
check(order('classify', 'defer') && order('defer', 'persist') && order('persist', 'commit') && order('commit', 'repeat'), 'pass 1 ran its items out of order');
check(evidence.status === '' && evidence.headNow !== evidence.head, 'the fix was not committed before pass 2');
const rows = Array.isArray(evidence.rows) ? evidence.rows.filter(row => row.skill === 'review' && row.via === 'ship') : [];
const [first, second] = rows;
check(rows.length === 2 && first?.pass === 1 && second?.pass === 2, `expected pass 1 and pass 2 review rows, found ${rows.map(row => row.pass).join(', ') || 'none'}`);
const identity = (f: Record<string, any>) => f?.fingerprint ?? `${f?.path}:${f?.line}`;
const fixed = (first?.findings ?? []).find((f: Record<string, any>) => identity(f) === FROZEN_SCOPE_FIX_IDENTITY);
const deferred = (first?.findings ?? []).find((f: Record<string, any>) => identity(f) === FROZEN_SCOPE_DEFER_IDENTITY);
check(['fixed', 'auto-fixed'].includes(fixed?.action) && fixed?.blocking === true && fixed?.locus === 'diff', 'pass 1 did not record the in-diff defect as fixed');
check(deferred?.action === 'deferred' && deferred?.issue === null && deferred?.blocking === true && deferred?.locus === 'outside', 'pass 1 did not record the pre-existing defect as deferred');
check(first?.review_scope === 'frozen' && second?.review_scope === 'frozen', 'rows do not carry the frozen policy');
check(typeof second?.range_base === 'string' && second.range_base.length >= 7 && evidence.head.startsWith(second.range_base), 'pass 2 did not record the pass-1 head as its range base');
check(!(second?.findings ?? []).some((f: Record<string, any>) => ['fixed', 'auto-fixed'].includes(f?.action)) && Number(second?.issues_found) === 0, 'pass 2 recorded fixes or open defects on an empty range review');
const loop = evidence.loop as Record<string, any>;
check(loop?.passes === 2 && loop?.blocking_found === 2 && loop?.deferred === 1 && loop?.fixed === 1 && loop?.scope === 'frozen',
`REVIEW_LOOP summary ${JSON.stringify(loop)} is not passes=2 blocking_found=2 deferred=1 fixed=1 scope=frozen`);
return { failures, evidence };
}
export async function runShipFrozenScopeActor(record: (entry: EvalTestEntry) => void, injectedQuery?: QueryProvider, artifactDirectory?: string) {
const started = Date.now();
const deadline = started + CAPTURE_MS - 20000;
const artifacts = artifactDirectory ?? process.env.GSTACK_EVAL_DIR;
const evidenceFile = artifacts ? path.join(artifacts, `${SHIP_FROZEN_SCOPE_CASE}-${randomUUID()}.json`) : undefined;
let workflow = '';
let fixture: ReturnType<typeof createShipFrozenScopeFixture> | undefined;
let result: AgentSdkResult | undefined;
let failure: unknown;
let passed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const events: SDKMessage[] = [];
try {
if (!artifacts) throw new Error('Frozen-scope actor requires GSTACK_EVAL_DIR for durable evidence');
fs.mkdirSync(artifacts, { recursive: true, mode: 0o700 });
const { query } = await import('@anthropic-ai/claude-agent-sdk');
const { runAgentSdkTest, resolveClaudeBinary } = await import('./agent-sdk-runner');
workflow = await shipFrozenScopeWorkflow();
fixture = createShipFrozenScopeFixture(workflow, undefined, deadline);
const binary = injectedQuery ? undefined : resolveClaudeBinary();
if (!injectedQuery && !binary) throw new Error('Native frozen-scope actor requires the pinned Claude CLI');
const owned = fixture;
const controller = new AbortController();
const expired = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => { const error = new Error('Frozen-scope case deadline exhausted'); controller.abort(error); reject(error); }, Math.max(1, deadline - Date.now()));
});
const running = runAgentSdkTest({ systemPrompt: { type: 'preset', preset: 'claude_code' }, userPrompt: owned.prompt,
workingDirectory: owned.repo, env: owned.env, maxTurns: SHARED_INTERACTIVE_MAX_TURNS, maxRetries: 0,
signal: controller.signal, allowedTools: ['Read', 'Write', 'Edit', 'Bash'],
settingSources: [], testName: SHIP_FROZEN_SCOPE_CASE, pathToClaudeCodeExecutable: binary ?? undefined,
canUseTool: owned.canUseTool,
queryProvider: options => {
const stream = (injectedQuery ?? query)({ ...options, options: { ...options.options, cwd: owned.repo, allowedTools: [],
canUseTool: owned.canUseTool, hooks: { PreToolUse: [{ hooks: [owned.preToolUse] }] } } });
const iterator = stream[Symbol.asyncIterator]();
return new Proxy(stream, { get(target, property) {
if (property === Symbol.asyncIterator) return async function* () {
for (let next = await iterator.next(); !next.done; next = await iterator.next()) { events.push(next.value); yield next.value; }
};
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
} });
},
});
result = await Promise.race([running, expired]);
const verdict = await shipFrozenScopeFailures(owned, result);
if (verdict.failures.length) throw new Error(verdict.failures.join('; '));
passed = true;
} catch (error) { failure = error; }
finally {
clearTimeout(timer);
try {
const terminal = events.findLast(event => event.type === 'result');
const costUsd = typeof terminal?.total_cost_usd === 'number' && Number.isFinite(terminal.total_cost_usd) && terminal.total_cost_usd >= 0 ? terminal.total_cost_usd : null;
const billing = { knownCostUsd: costUsd ?? 0, costKnown: costUsd !== null, status: !events.length ? 'not_started' : costUsd !== null ? 'complete' : 'incomplete' };
const billingNote = billing.costKnown ? '' : 'Terminal billing is incomplete; actual total cost is unknown. Only known charges are summed; all events are retained.';
const output = JSON.stringify({ error: failure === undefined ? undefined : String(failure), prompt: fixture?.prompt, workflow,
evidence: fixture?.snapshot(), events, output: result?.output, started, deadline, root: fixture?.root, billing });
if (evidenceFile) fs.writeFileSync(evidenceFile, output + '\n', { mode: 0o600 });
record({ name: SHIP_FROZEN_SCOPE_CASE, suite: SHIP_FROZEN_SCOPE_SUITE, tier: 'e2e', passed, duration_ms: Date.now() - started,
cost_usd: billing.knownCostUsd, transcript: [{ type: 'fixture_billing', cost_known: billing.costKnown, billing }, ...events],
prompt: fixture?.prompt, turns_used: result?.turnsUsed, model: result?.model, output,
error: [failure === undefined ? '' : String(failure), billingNote].filter(Boolean).join('\n') || undefined,
exit_reason: passed ? 'success' : result?.exitReason === 'success' ? 'assertion_failed' : result?.exitReason ?? 'runner_error' });
} finally {
if (fixture && passed) fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
if (failure !== undefined) throw failure;
return evidenceFile!;
}
if (import.meta.main && process.argv[2] === '--fixture') {
const root = fs.realpathSync(process.argv[3]!);
const config = json(path.join(root, 'fixture.json')) as { repo: string; env: Record<string, string>; invoice: string; head: string; tokens: string[]; draft: string; receipts: string; tmp: string };
const action = process.argv[4];
const env = { ...process.env, ...config.env };
const bin = (name: string, args: string[]) => command(config.repo, env, path.join(ROOT, 'bin', name), args);
const receipts = read(config.receipts).trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { action: string; pass: number });
const persisted = receipts.filter(row => row.action === 'persist').length;
const pass = persisted + 1;
const file = (name: string) => path.join(config.tmp, name);
const classified = file(`review-classified-p${pass}.json`);
if (receipts.some(row => row.action === 'continue' || row.action === 'stop')) throw new Error('Loop decision already taken');
let output = '';
if (action === 'classify') {
const findings = file(`review-findings-p${pass}.json`);
if (!fs.existsSync(findings)) throw new Error(`No Step 9.3 result for pass ${pass}`);
const args = ['classify', '--base', 'origin/main', '--pass', String(pass), '--policy', 'frozen', '--findings', findings, '--json'];
if (pass > 1) args.push('--range-base', config.head);
const run = bin('gstack-review-scope', args);
fs.writeFileSync(classified, run.stdout, { mode: 0o600 });
output = run.stderr.trim() + '\n' + run.stdout.trim();
} else if (action === 'defer') {
if (!fs.existsSync(classified)) throw new Error(`Classify pass ${pass} first`);
const deferred = file('review-deferred.json');
const run = bin('gstack-review-scope', ['defer', '--findings', classified, '--out', deferred, '--dry-run']);
output = [run.stderr.trim(), run.stdout.trim()].filter(Boolean).join('\n');
} else if (action === 'persist') {
if (!fs.existsSync(config.draft)) throw new Error('Write the review record first');
const token = config.tokens[persisted];
if (!token) throw new Error('No REVIEW_START token for this pass');
const run = bin('gstack-review-log', [JSON.stringify(json(config.draft)), '--finish', token]);
output = [run.stderr.trim(), run.stdout.trim()].filter(Boolean).join('\n') || 'logged';
} else if (action === 'commit') {
if (persisted < 1) throw new Error('Log this pass (items 5–6) before committing');
const add = gitArgvIn(config.repo, ['add', 'invoice.ts'], 10_000, config.env);
if (add.status !== 0) throw new Error(add.stderr.toString());
const commit = gitArgvIn(config.repo, ['commit', '-qm', 'fix: pre-landing review fixes'], 10_000, config.env);
if (commit.status !== 0) throw new Error(commit.stderr.toString() || 'nothing to commit');
output = gitArgvIn(config.repo, ['rev-parse', '--short', 'HEAD'], 10_000, config.env).stdout.toString().trim();
} else if (action === 'repeat') {
if (!receipts.some(row => row.action === 'commit')) throw new Error('Repeat requires the fixes of this pass committed (item 4)');
if (receipts.some(row => row.action === 'repeat')) throw new Error('Only one repeat pass is supported');
const token = bin('gstack-review-log', ['--start', 'review']).stdout.trim();
config.tokens.push(token);
write(path.join(root, 'fixture.json'), config);
write(file(`review-findings-p${pass}.json`), []);
const head = gitArgvIn(config.repo, ['rev-parse', 'HEAD'], 10_000, config.env).stdout.toString().trim();
output = JSON.stringify({ pass, range: `${config.head}..${head}`, tests: 'pass', steps: '5–9.3 complete', findings: [], coverage: 'completed',
step_9_3_result: `.gstack/tmp/review-findings-p${pass}.json`, review_start_captured: true });
} else if (action === 'continue' || action === 'stop') {
if (persisted < 1) throw new Error('Decide only after this pass is logged');
output = JSON.stringify({ decision: action === 'continue' ? 4 : 'stop', next: action === 'continue' ? 'Step 10' : 'STOP' });
} else throw new Error('Unknown fixture action');
fs.appendFileSync(config.receipts, JSON.stringify({ action, pass, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
console.log(output);
}
+18
View File
@@ -35,6 +35,20 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts',
'test/helpers/e2e-gate.ts', 'test/helpers/eval-budgets.ts'
],
// The frozen review scope's two-pass convergence: the generated Step 9.4 + repeat decision, the real
// gstack-review-scope / gstack-review-log binaries and the ship-policy review_scope key it reads.
'ship-frozen-scope-convergence': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'package.json', 'bun.lock', '.github/docker/Dockerfile.ci',
'scripts/resolvers/review-scope.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/index.ts',
'scripts/resolvers/types.ts', 'scripts/gen-skill-docs.ts', 'scripts/host-config.ts', 'scripts/discover-skills.ts', 'hosts/claude.ts', 'hosts/index.ts', 'hosts/define-host.ts',
'ship/sections/review-army.md*', 'ship/sections/manifest.json', 'review/checklist.md',
'bin/gstack-review-scope', 'lib/review-scope.ts', 'lib/ship-policy.ts', 'lib/result-codes-release.ts',
'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh',
'lib/review-evidence.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
'test/helpers/ship-frozen-scope-actor.ts', 'test/helpers/scratch-repo.ts',
'test/skill-e2e-ship-frozen-scope.test.ts', 'test/helpers/shared-libs-eval-fixture.ts',
'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts',
'test/helpers/e2e-gate.ts', 'test/helpers/eval-budgets.ts'
],
'investigate-owned-completion': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-completion.test.ts', 'test/helpers/e2e-gate.ts'],
'investigate-owned-abort': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
'investigate-owned-ending-error': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'investigate/**', 'freeze/**', 'guard/**', 'unfreeze/**', 'careful/bin/hook-extract.sh', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/workflow-boundaries-fixture.ts', 'test/skill-e2e-investigate-owned-termination.test.ts', 'test/helpers/e2e-gate.ts'],
@@ -1138,6 +1152,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
*/
export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
'ship-skipped-queued-finding': 'gate',
'ship-frozen-scope-convergence': 'gate',
'investigate-owned-completion': 'gate',
'investigate-owned-abort': 'gate',
'investigate-owned-ending-error': 'gate',
@@ -1651,6 +1666,7 @@ export const GLOBAL_TOUCHFILES = [
*/
export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
'ship-skipped-queued-finding': 'rule',
'ship-frozen-scope-convergence': 'behavior',
'investigate-owned-completion': 'rule',
'investigate-owned-abort': 'rule',
'investigate-owned-ending-error': 'rule',
@@ -1934,6 +1950,8 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
* E2E_KINDS; values are non-empty.
*/
export const BEHAVIOR_WHY: Record<string, string> = {
'ship-frozen-scope-convergence':
"How the live model words its fix of the in-diff defect and its review rows can vary run to run; never editing the pre-existing file, deferring its blocking finding, reviewing only the range on pass 2 and exiting through decision 4 stay the contract the verdict checks.",
'ship-measure-seeded-flake':
"How the live model words its classification and fix can vary run to run; measuring before fixing, re-measuring at target and running the gate once after stay assertions, and answering only the spend question is a contract.",
'shared-libs-opportunity-judgment':
+8 -2
View File
@@ -532,9 +532,15 @@ describe('trial planner (behavior and quarantined panels)', () => {
const manifest = budgetPlan('gate', { 'review-sql-injection': 'behavior' });
const lines = formatCapacityPreflight(manifest, 16).join('\n');
expect(lines).toContain(`${manifest.sliceCount} slice(s)`);
expect(lines).toContain('3 trial shard(s)');
// Every planned trial shard belongs to a 3-trial panel: the overridden review case plus every registered gate behavior case.
const trials = manifest.entries.filter(e => e.status === 'planned' && e.trial);
const panels = new Set(trials.map(e => e.file.replace(/~t\d+$/, '')));
expect(trials.length).toBe(3 * panels.size);
expect([...panels]).toContain('test/skill-e2e-review.test.ts#review-sql-injection');
expect(lines).toContain(`${trials.length} trial shard(s) in ${panels.size} panel(s)`);
expect(lines).toContain(`wave(s) at max-parallel 16: ${Math.ceil(manifest.sliceCount / 16)}`);
expect(lines).toMatch(/longest indivisible trial ~\d+\.\dm \(test\/skill-e2e-review\.test\.ts#review-sql-injection~t\d\)/);
const longest = [...trials].sort((a, b) => (b.estimatedMs ?? 0) - (a.estimatedMs ?? 0))[0]!;
expect(lines).toMatch(new RegExp(`longest indivisible trial ~\\d+\\.\\dm \\(${longest.file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
});
test('durations: trials record their longest wall under the case key and seed their own estimate', () => {
+124
View File
@@ -0,0 +1,124 @@
/**
* PR 2 of the /ship + /review convergence lane: the review-scope follow-ups
* from PR 1's review notes. A Step 11 late defer is a review-log row that
* counts under deferred and is not a pass; the deferred-issue search names its
* page size and reads an exactly-full page as truncated; `gh repo view` takes
* the repository positionally, so the visibility read works under --repo and a
* MEDIUM finding on a public repository stays unfiled until a human confirms.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { LATE_DEFER_PHASE, renderDeferredSection, reviewLoopLine, summarizeReviewLoop } from '../lib/review-scope';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo({ prefix: 'review-scope-2-' }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
describe('late defers from Step 11', () => {
test('a review row with phase late-defer adds its deferred findings and is not counted as a pass', () => {
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', pass: 1, review_scope: 'frozen', findings: [
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:20:00Z', pass: 2, review_scope: 'frozen', findings: [] },
{ skill: 'review', via: 'ship', phase: LATE_DEFER_PHASE, timestamp: '2026-10-26T10:30:00Z', pass: 2, review_scope: 'frozen', findings: [
{ fingerprint: 'c:9:z', blocking: true, action: 'deferred', issue: null }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }] },
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:25:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true, action: 'deferred' }] },
];
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen' });
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=2 noted=0 fixed=1 scope=frozen');
expect(LATE_DEFER_PHASE).toBe('late-defer');
});
});
describe('deferred-issue search page and the repository argument gh accepts', () => {
const finding = (fingerprint: string, summary: string) => {
const [p, line, category] = fingerprint.split(':');
return { fingerprint, path: p, line: Number(line), severity: 'CRITICAL', category, summary, locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' };
};
const shim = (repo: FixtureRepo, visibility: string, issues: number) => {
const dir = path.join(repo.root, 'gh-shim');
fs.mkdirSync(dir);
const log = path.join(dir, 'calls.log');
const page = JSON.stringify(Array.from({ length: issues }, (_, i) => ({ number: 100 + i, url: `https://example.test/acme/app/issues/${100 + i}`, body: `<!-- gstack-deferred-finding: other:${i}:x -->` })));
fs.writeFileSync(path.join(dir, 'gh'), [
'#!/bin/sh', `echo "$*" >> "${log}"`,
`if [ "$1" = repo ] && [ "$2" = view ]; then [ "$3" = acme/app ] || { echo "gh: --repo not accepted by repo view: $*" >&2; exit 1; }; echo "${visibility}"; exit 0; fi`,
`if [ "$1" = issue ] && [ "$2" = list ]; then cat <<'EOF'\n${page}\nEOF\nexit 0; fi`,
'if [ "$1" = issue ] && [ "$2" = create ]; then echo "https://example.test/acme/app/issues/900"; exit 0; fi',
'echo "unsupported $*" >&2; exit 1', '',
].join('\n'), { mode: 0o755 });
return { dir, log };
};
test('an exactly-full search page is reported truncated and recorded on the issue it files; a short page is not', () => {
const repo = fixture();
const classified = path.join(repo.root, 'classified.json');
fs.writeFileSync(classified, JSON.stringify([finding('lib/legacy.ts:7:data-loss', 'Unbounded delete')]));
const full = shim(repo, 'PRIVATE', 200);
const out = path.join(repo.root, 'deferred.json');
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--out', out, '--json'], { PATH: `${full.dir}:${process.env.PATH}` });
expect(r.status).toBe(EXIT.ok);
expect(r.err).toContain('DEFERRED_SEARCH: truncated at 200 open issues; an existing issue for a finding may not have been found');
const records = JSON.parse(r.out);
expect(records[0]).toMatchObject({ issue: 900, reason: 'filed; the marker search was truncated at 200, so an older issue may duplicate it' });
expect(fs.readFileSync(full.log, 'utf8')).toContain('--limit 200');
expect(renderDeferredSection(records)).toContain('- Deferred: #900 (filed; the marker search was truncated at 200, so an older issue may duplicate it) — CRITICAL `lib/legacy.ts:7` Unbounded delete');
const repo2 = fixture();
const short = shim(repo2, 'PRIVATE', 199);
const r2 = runBin(repo2, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--json'], { PATH: `${short.dir}:${process.env.PATH}` });
expect(r2.status).toBe(EXIT.ok);
expect(r2.err).not.toContain('DEFERRED_SEARCH');
expect(JSON.parse(r2.out)[0]).toEqual({ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: 900, url: 'https://example.test/acme/app/issues/900' });
});
test('with --repo the visibility read passes the repository positionally, so a MEDIUM finding on a public repository waits for a human and stays unfiled', () => {
const repo = fixture();
const classified = path.join(repo.root, 'classified.json');
fs.writeFileSync(classified, JSON.stringify([
finding('lib/mail.ts:3:pii', 'Notification goes to jane.roe@acme-corp.io on every retry'),
finding('lib/legacy.ts:7:data-loss', 'Unbounded delete'),
]));
const pub = shim(repo, 'PUBLIC', 0);
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--json'], { PATH: `${pub.dir}:${process.env.PATH}` });
expect(r.status).toBe(EXIT.ok);
const records = JSON.parse(r.out);
expect(records[0].issue).toBeNull();
expect(records[0].reason).toMatch(/^publication scan: MEDIUM pii\.email needs a human before this text is posted$/);
expect(records[1]).toMatchObject({ issue: 900 });
const calls = fs.readFileSync(pub.log, 'utf8');
expect(calls).toContain('repo view acme/app --json visibility --jq .visibility');
expect(calls).not.toMatch(/^repo view .*--repo/m);
expect(calls.match(/^issue create /gm)?.length).toBe(1);
expect(calls).not.toContain('jane.roe@acme-corp.io');
expect(r.err).toContain('DEFERRED: unfiled (publication scan: MEDIUM pii.email needs a human before this text is posted) CRITICAL lib/mail.ts:3');
});
});
describe('checklist rules with ids (docs/review-policy.md)', () => {
const checklist = fs.readFileSync(path.resolve(import.meta.dir, '../review/checklist.md'), 'utf8');
const policy = fs.readFileSync(path.resolve(import.meta.dir, '../docs/review-policy.md'), 'utf8');
const section = (first: string, last: string) => checklist.slice(checklist.indexOf(first), checklist.indexOf(last));
test('prereg-with-results is a Pass 1 gate-integrity ASK finding about the pairing, repaired by a split', () => {
const gate = section('#### Gate Integrity', '#### Approval Classes and Data Repairs');
expect(gate).toContain('`prereg-with-results`, ASK');
expect(gate).toMatch(/preregistration .*results, scores or verdict/);
expect(gate).toContain('the finding is the pairing, not either half');
expect(gate).toMatch(/split them \(the preregistration lands first/);
expect(policy).toContain('`prereg-with-results` (Pass 1, Gate Integrity, ASK)');
});
test('test-keyboard-count-loop is a Pass 2 informational rule that keeps a counted loop only when the count is the behavior', () => {
const pass2 = section('### Pass 2 — INFORMATIONAL', '## Sideways sweep');
expect(pass2).toContain('#### Keyboard-Count Test Loops');
expect(pass2).toContain('(`test-keyboard-count-loop`)');
expect(pass2).toContain('Locate the target by role, label or test id');
expect(pass2).toContain('a counted loop stays only when the count itself is the behavior under test');
expect(policy).toContain('`test-keyboard-count-loop` (Pass 2, informational)');
expect(policy).toContain('gstack-coauthor-trailers');
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Free contracts of the native frozen-scope fixture (test/helpers/ship-frozen-scope-actor.ts):
* a scripted actor that follows Step 9.4 and the repeat decision passes the
* verdict with the real gstack-review-scope / gstack-review-log binaries, and
* the negative controls (editing the pre-existing file, leaving the diff defect
* in place, exiting after one pass) are named failures. No model call is made.
*/
import { afterAll, beforeAll, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import {
createShipFrozenScopeFixture, FROZEN_SCOPE_DEFER_IDENTITY, FROZEN_SCOPE_FIX_IDENTITY, shipFrozenScopeFailures, shipFrozenScopeWorkflow,
} from './helpers/ship-frozen-scope-actor';
type Fixture = ReturnType<typeof createShipFrozenScopeFixture>;
let workflow = '';
const roots: string[] = [];
beforeAll(async () => { workflow = await shipFrozenScopeWorkflow(); }, 120_000);
afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); });
const SAFE = 'export const canReadInvoice = (owner: string, viewer: string) => owner === viewer;\n';
function fixture(): Fixture {
const f = createShipFrozenScopeFixture(workflow);
roots.push(f.root);
return f;
}
function sh(f: Fixture, command: string): string {
const r = spawnSync('bash', ['-c', command], { cwd: f.repo, encoding: 'utf8', env: { ...process.env, ...f.env }, timeout: 60_000 });
if (r.status !== 0) throw new Error(`${command}\n${r.stderr}${r.stdout}`);
return r.stdout;
}
async function hook(f: Fixture, tool: string, input: Record<string, unknown>): Promise<string | undefined> {
const out: any = await f.preToolUse({ hook_event_name: 'PreToolUse', tool_name: tool, tool_input: input } as any, undefined as any, {} as any);
return out.hookSpecificOutput.permissionDecision === 'allow' ? undefined : out.hookSpecificOutput.permissionDecisionReason;
}
/** A scripted actor: declared reads, classify, fix, defer, log, commit, repeat, log, continue. */
async function act(f: Fixture, options: { fix?: boolean; passes?: 1 | 2 } = {}) {
const { fix = true, passes = 2 } = options;
for (const file of [f.workflowPath, f.findingsPath, f.invoice, f.payments]) expect(await hook(f, 'Read', { file_path: file })).toBeUndefined();
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBeUndefined();
const classify = sh(f, f.commands.classify!);
expect(classify).toContain('REVIEW_SCOPE: policy=frozen pass=1 findings=2 fix=1 defer=1 note=0 blocking=2');
const classified = JSON.parse(fs.readFileSync(`${f.repo}/.gstack/tmp/review-classified-p1.json`, 'utf8'));
if (fix) {
expect(await hook(f, 'Write', { file_path: f.invoice, content: SAFE })).toBeUndefined();
fs.writeFileSync(f.invoice, SAFE);
}
expect(await hook(f, 'Bash', { command: f.commands.defer })).toBeUndefined();
expect(sh(f, f.commands.defer!)).toContain('DEFERRED: unfiled (dry run) CRITICAL payments.ts:2');
const findings = classified.findings.map((x: any) => ({ fingerprint: x.fingerprint, severity: x.severity, action: x.scope === 'fix' ? (fix ? 'auto-fixed' : 'skipped') : 'deferred',
...(x.scope === 'defer' ? { issue: null } : {}), locus: x.locus, kind: x.kind, blocking: x.blocking }));
const row = (extra: Record<string, unknown>) => ({ skill: 'review', timestamp: new Date().toISOString(), status: 'issues_found', issues_found: 0, critical: 0, informational: 0,
quality_score: 10.0, specialists: {}, findings, commit: sh(f, 'git rev-parse --short HEAD').trim(), via: 'ship', completed: true, converged: false, cycles: 1, pass: 1,
review_scope: 'frozen', range_base: null, ...extra });
const log = async (record: Record<string, unknown>) => {
expect(await hook(f, 'Write', { file_path: f.draft, content: JSON.stringify(record) })).toBeUndefined();
fs.writeFileSync(f.draft, JSON.stringify(record));
expect(await hook(f, 'Bash', { command: f.commands.persist })).toBeUndefined();
sh(f, f.commands.persist!);
};
await log(row({}));
if (passes === 1) {
expect(await hook(f, 'Bash', { command: f.commands.continue })).toBeUndefined();
sh(f, f.commands.continue!);
return;
}
expect(await hook(f, 'Bash', { command: f.commands.commit })).toBeUndefined();
sh(f, f.commands.commit!);
expect(await hook(f, 'Bash', { command: f.commands.repeat })).toBeUndefined();
const repeat = JSON.parse(sh(f, f.commands.repeat!));
expect(repeat).toMatchObject({ pass: 2, findings: [], coverage: 'completed' });
expect(await hook(f, 'Read', { file_path: `${f.repo}/.gstack/tmp/review-findings-p2.json` })).toBeUndefined();
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBeUndefined();
expect(sh(f, f.commands.classify!)).toContain('REVIEW_SCOPE: policy=frozen pass=2 findings=0 fix=0 defer=0 note=0 blocking=0');
await log(row({ status: 'clean', findings: [], converged: true, pass: 2, range_base: f.snapshot().head }));
expect(await hook(f, 'Bash', { command: f.commands.continue })).toBeUndefined();
sh(f, f.commands.continue!);
}
test('a scripted actor that fixes the diff, defers the pre-existing defect and converges on pass 2 passes the verdict', async () => {
const f = fixture();
await act(f);
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
expect(verdict.failures).toEqual([]);
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' });
const rows = verdict.evidence.rows as Array<Record<string, any>>;
expect(rows.map(r => r.pass)).toEqual([1, 2]);
expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]);
expect(rows[1]!.range_base).toBe(verdict.evidence.head);
expect(verdict.evidence.payments).toBe(verdict.evidence.originalPayments);
}, 120_000);
test('the fixture interface refuses the undeclared interactions before any decision', async () => {
const f = fixture();
expect(await hook(f, 'Edit', { file_path: f.payments, old_string: 'card', new_string: '?' })).toBe('Edit outside invoice.ts');
expect(await hook(f, 'Write', { file_path: f.payments, content: '' })).toBe('Write outside invoice.ts and the review record');
expect(await hook(f, 'Bash', { command: `${f.commands.classify} && echo done` })).toBe('Bash outside the fixture interface');
expect(await hook(f, 'Bash', { command: 'git status' })).toBe('Bash outside the fixture interface');
expect(await hook(f, 'Read', { file_path: `${f.root}/fixture.json` })).toBe('Read outside the declared files');
expect(await hook(f, 'AskUserQuestion', { questions: [] })).toBe('Undeclared tool');
expect(() => sh(f, f.commands.repeat!)).toThrow(/committed/);
expect(() => sh(f, f.commands.continue!)).toThrow(/logged/);
expect(f.snapshot().executions.filter(e => !e.allowed)).toHaveLength(6);
}, 120_000);
test('leaving the diff defect in place or exiting after one pass are named failures', async () => {
const f = fixture();
await act(f, { fix: false, passes: 1 });
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
expect(verdict.failures).toContain('invoice.ts still accepts a viewer other than the owner');
expect(verdict.failures).toContain('expected exactly one repeat pass');
expect(verdict.failures).toContain('pass 1 did not record the in-diff defect as fixed');
expect(verdict.failures.some(line => line.startsWith('REVIEW_LOOP summary'))).toBe(true);
expect(await hook(f, 'Bash', { command: f.commands.classify })).toBe('Loop decision already taken');
}, 120_000);
+64
View File
@@ -0,0 +1,64 @@
/**
* Free contracts of the receipt-versus-prose number check (lib/ship-receipt-prose.ts,
* gstack-ship-receipt read --check-prose): claims in the three spellings are
* read against review_loop, gate and ci integers, the receipt block itself is
* never a claim, a disagreement is RECEIPT_PROSE_MISMATCH (exit 1), and prose
* without counters passes.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { checkReceiptProse, proseLine, receiptCounters } from '../lib/ship-receipt-prose';
import { renderReceiptBlock } from '../lib/ship-receipt';
import { EXIT } from '../lib/headless-artifacts';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin/gstack-ship-receipt');
const receipt = {
schema_version: 1 as const, pr: 7, head: 'a'.repeat(40), base: 'main', version: '1.2.3.0',
review_loop: { passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' },
gate: { status: 'green', passed: 41, failed: 0, run: '123' }, ci: { status: 'success' },
};
describe('checkReceiptProse', () => {
test('counters are the integer leaves under review_loop, gate and ci; identifiers are never counters', () => {
expect([...receiptCounters(receipt).keys()]).toEqual(['review_loop.passes', 'review_loop.blocking_found', 'review_loop.deferred',
'review_loop.noted', 'review_loop.fixed', 'gate.passed', 'gate.failed']);
});
test('the three spellings are claims, the receipt block is not, and a disagreement names field and both values', () => {
const body = 'Review: 2 passes, 1 deferred finding, blocking found: 2. Gate: passed=41, 0 failed.\n\n' + renderReceiptBlock({ ...receipt, review_loop: { ...receipt.review_loop, passes: 9 } });
const ok = checkReceiptProse(receipt, body);
expect(ok).toEqual({ checked: 5, mismatches: [] });
expect(proseLine(ok)).toBe('PROSE: 5 receipt counter claims in the body, all match');
const bad = checkReceiptProse(receipt, 'The loop took 3 passes; deferred=2; 41 passed.');
expect(bad.mismatches).toEqual([{ field: 'review_loop.passes', prose: 3, receipt: 2 }, { field: 'review_loop.deferred', prose: 2, receipt: 1 }]);
expect(proseLine(bad)).toBe('PROSE: mismatch review_loop.passes body=3 receipt=2; review_loop.deferred body=2 receipt=1');
expect(proseLine(checkReceiptProse(receipt, 'PR #7 adds the docs.'))).toBe('PROSE: no receipt counter claims in the body');
});
});
describe('gstack-ship-receipt read --check-prose', () => {
const run = (body: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));
const file = path.join(dir, 'body.md');
fs.writeFileSync(file, body);
const r = spawnSync(BIN, ['read', '--body', file, '--check-prose', '--json'], { encoding: 'utf8', timeout: 60_000 });
fs.rmSync(dir, { recursive: true, force: true });
return { status: r.status, out: r.stdout, err: r.stderr };
};
test('a matching body exits 0 with the PROSE line and the receipt; a mismatch is RECEIPT_PROSE_MISMATCH exit 1', () => {
const good = run('Two passes: passes=2, deferred: 1.\n\n' + renderReceiptBlock(receipt));
expect(good.status).toBe(EXIT.ok);
expect(good.err).toContain('PROSE: 2 receipt counter claims in the body, all match');
expect(JSON.parse(good.out).prose).toEqual({ checked: 2, mismatches: [] });
const bad = run('Three passes: passes=3.\n\n' + renderReceiptBlock(receipt));
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('PROSE: mismatch review_loop.passes body=3 receipt=2');
expect(bad.err).toMatch(/review_loop\.passes body=3 receipt=2.*\(RECEIPT_PROSE_MISMATCH; .*troubleshooting\.md#receipt-prose-mismatch\)/);
expect(JSON.parse(bad.out).prose.mismatches).toHaveLength(1);
});
});
+7 -6
View File
@@ -23,17 +23,18 @@ test('native-only fixture files do not select quality judges', () => {
expect(selectTests(['qa-only/SKILL.md.tmpl'], E2E_TOUCHFILES).selected).not.toContain(id);
});
test('scratch identity helper selects only queued-Skip and preserves its fast-profile deferral', () => {
test('scratch identity helper selects only the two native ship fixtures and preserves their fast-profile deferral', () => {
const changedFiles = ['test/helpers/scratch-repo.ts'];
expect(selectTests(changedFiles, E2E_TOUCHFILES).selected).toEqual([id]);
const scratchIds = [id, 'ship-frozen-scope-convergence'];
expect(selectTests(changedFiles, E2E_TOUCHFILES).selected.sort()).toEqual([...scratchIds].sort());
const full = computePaidCaseSelection({ profile: 'full', env: {}, changedFiles });
expect(full.selection).toEqual({ e2e: [id], judges: [] });
expect([...(full.selection.e2e ?? [])].sort()).toEqual([...scratchIds].sort());
expect(full.selection.judges).toEqual([]);
const pr = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles });
expect(pr.selection).toEqual({ e2e: [], judges: [] });
expect(pr.coverage?.mode).toBe('pr');
expect(pr.coverage?.deferred).toEqual([{
id, tier: 'gate', reason: 'Broad gate census/release coverage; outside the fast PR profile',
}]);
expect(pr.coverage?.deferred.map(({ id }) => id).sort()).toEqual([...scratchIds].sort());
expect(pr.coverage?.deferred.map(({ tier, reason }) => `${tier}: ${reason}`)).toEqual(scratchIds.map(() => 'gate: Broad gate census/release coverage; outside the fast PR profile'));
});
test.each(['test/helpers/qa-checkpoint-evidence.ts', 'test/fixtures/qa-only-charter-public.json'])
+32
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import { ALL_HOST_CONFIGS } from '../hosts';
import { generateAdversarialStep } from '../scripts/resolvers/outside-voice-steps';
import { generateQAReview } from '../scripts/resolvers/qa';
import { generateReviewArmy } from '../scripts/resolvers/review-army';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { between, compact, expectAbsent, expectMentions, expectOrdered, expectTokens } from './helpers/prompt-structure';
import { readWorkflowExcerpt } from './helpers/workflow-excerpt';
@@ -206,3 +207,34 @@ test('ship resolves branch and installed-asset references without competing inst
expect(row, 'ship section index lost its exploratory QA row').toBeDefined();
expectTokens(row!, ['`sections/review-army.md`'], 'ship section index');
});
test('a repository without a version source or CHANGELOG ships with one skipped line per step and a receipt that says version none (E18)', () => {
const step12 = compact(between(entryTemplate, '## Step 12:', '{{SECTION:changelog}}'));
expectTokens(step12, ['**NO_VERSION**', '`Version bump: skipped (no version source: <path, reason>)`', 'never create VERSION', '`"version":null`'], 'ship Step 12');
const changelog = compact(readTemplate('ship/sections/changelog.md.tmpl'));
expectTokens(changelog, ['**NO_VERSION (Step 12):**', '**No CHANGELOG:**', '`CHANGELOG: skipped (none at <path>)`', 'never create the file', '`"none"`'], 'ship changelog section');
expectOrdered(changelog, ['**NO_VERSION (Step 12):**', '**No CHANGELOG:**'], 'ship changelog section');
});
test('Step 14.5 runs the doc caps before the verification gate and records the audit for the receipt, with the owner exception shape (E11)', () => {
expectOrdered(docsTemplate, ['### Continue or recover', 'gstack-ship-receipt docs-caps --policy .gstack/ship-policy.json', '`DOCS_CAP: <path> <bytes>/<max> ok|over`', '.gstack/tmp/docs-audit.json', '## Blocked recovery'], 'ship documentation section');
expectTokens(docs, ['`DOCS_CAP: none declared`', '"status":"updated|current"', '"status":"blocked"', '"exception":{"accepted_by"', '`DOCS_AUDIT_BLOCKED`', 'An unattended run has no owner to accept: it stops here.'], 'ship documentation section');
const compose = between(prTemplate, 'Handoff record first', '**Existing open PR/MR**');
expectTokens(compose, ['--docs @.gstack/tmp/docs-audit.json', '`DOCS_AUDIT_BLOCKED`'], 'ship pr-body receipt write');
expectTokens(compact(between(prTemplate, '## Documentation', '## Test plan')), ['`Documentation exception: accepted by <accepted_by> <date> via <via>: <reason>`'], 'ship pr-body Documentation');
});
test('Step 9.4 applies the cap before any edit, names its scratch files under .gstack/tmp/, logs Step 11 late defers, and hands reviewers the range from pass 2 (PR 1 review notes)', () => {
const fixFirst = between(reviewTemplate, '## Step 9.4:', '### Decide whether to repeat Step 9');
expectOrdered(fixFirst, ['0. **Scope the findings.**', '.gstack/tmp/review-findings-p$PASS.json', '.gstack/tmp/review-classified-p$PASS.json', '1. **Apply the cap before any edit, then classify.**', '2. **Auto-fix all AUTO-FIX items.**', '**Late defers from Step 11.**', '"phase":"late-defer"', '6. Persist the review result'], 'ship Step 9.4');
expectTokens(compact(fixFirst), ['`.gstack/tmp/review-deferred.json`', '`CYCLES >= 3`', '`PASS >= 3`', 'no `--finish` token'], 'ship Step 9.4');
expectTokens(reviewFlow, ['`DIFF_BASE=${RANGE_BASE:-$(git merge-base origin/<base> HEAD)}`', 'RANGE_BASE..HEAD'], 'ship Step 9 range');
for (const host of ALL_HOST_CONFIGS) {
const army = generateReviewArmy({ host: host.name, skillName: 'ship', tmplPath: '', paths: HOST_PATHS[host.name] });
if (!army) continue;
expect(army.match(/DIFF_BASE=\$\{RANGE_BASE:-\$\(git merge-base origin\/<base> HEAD\)\}/g)?.length, `${host.name} ship review army range`).toBe(2);
expectTokens(army, ['--review-fanout auto|serial|parallel', /Fan-out: N specialists in\s+M batches of K/], `${host.name} ship review army fan-out`);
const review = generateReviewArmy({ host: host.name, skillName: 'review', tmplPath: '', paths: HOST_PATHS[host.name] });
expectAbsent(review, ['RANGE_BASE', '--review-fanout'], `${host.name} /review army`);
}
});
+14
View File
@@ -0,0 +1,14 @@
import { afterAll, test } from 'bun:test';
import { CAPTURE_MS } from './helpers/eval-budgets';
import { describeE2ETier, e2eTierEnabled } from './helpers/e2e-gate';
import { EvalCollector } from './helpers/eval-store';
import { runShipFrozenScopeActor, SHIP_FROZEN_SCOPE_SUITE } from './helpers/ship-frozen-scope-actor';
const describeE2E = describeE2ETier('gate');
const collector = e2eTierEnabled('gate') ? new EvalCollector('e2e', undefined, SHIP_FROZEN_SCOPE_SUITE) : null;
describeE2E('/ship frozen review scope converges in two passes', () => {
test('ship-frozen-scope-convergence', async () => {
await runShipFrozenScopeActor(entry => collector!.addTest(entry));
}, CAPTURE_MS);
});
afterAll(async () => { await collector?.finalize(); });