diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6e40735e..39497de83 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [Unreleased]
+
+**Nine paid ship rule-case flakes from the 2026-10-29 window stop failing correct runs.** Six qa-callers trials and five ship-docsync trials failed on calls the declared interface could not tell apart from a violation, or on a turn budget the actor spent out of order. Each was replayed from its retained capture, and each repair has a free test that fails without it.
+
+### Fixed
+- **qa-callers fixture prompt** (`test/helpers/qa-callers-fixture.ts`): the parent is told that a child starts in the product directory, to write each command exactly as it must run (no "in
" paraphrase, `cd` or `git -C`) and to name each file the child reads as one literal path. Six trials across late-input, plan-checks and small-cli had a child run `git -C diff` or `cd` into the checklist directory after a paraphrased prompt. The ship caller's scheduling contract now lists Step 9's review-plan command and gate-scan block with the first diff discovery: three late-input trials hit 25 of 25 turns after running the gate scan only after the receipt, or never. The detector is unchanged.
+- **ship-docsync detectors** (`test/helpers/docsync-observer.ts`): a declared read command given `--help` counts as a read that reads nothing, with its operands still checked (current, completion and store trials joined `cat --help`). The wrong-path exit-127 excuse covers the docs cap gate (`gstack-ship-receipt docs-caps`) when the declared path then runs (stale-after). A line whose first unpiped `cat` printed the whole phase file still counts as reading it when a later separator failed under zsh (failure).
+- **ship-docsync-completion** (`test/helpers/docsync-contract.ts`): only the accepted docs attempt is held to the strict completion contract; an attempt the parent rejected and re-audited reports only its id, status and edited files.
+
+### Gate integrity
+- `test/helpers/docsync-observer.ts`: three acceptances widen, each pinned to its captured run with negative controls (redirects, pipes into the read, chained mutations, `cd`, wrong subcommands, a binary present at the wrong path, a truncated or errored read).
+- `test/helpers/docsync-contract.ts` and `test/skill-e2e-ship-docsync.test.ts`: an earlier rejected docs attempt is no longer parsed with the accepted attempt's strict contract; a malformed earlier report still throws, and the accepted attempt stays strict.
+
## [1.91.319.0] - 2026-10-29
**`gstack-pregate sealed` no longer prints a line that reads like a push confirmation.** On gstack-evals#29 the lane read `pushed aeb2413` in the `SEALED:` line as the push landing, while the remote stayed at 9036ab1 (#3565). `sealed` never pushes: it checks the commits a push would publish. `pushed ` was its label for the commit whose `sealed` policy it read beside the base branch's.
diff --git a/docs/evals/detector-inventory.md b/docs/evals/detector-inventory.md
index a2667434c..b6c3d4824 100644
--- a/docs/evals/detector-inventory.md
+++ b/docs/evals/detector-inventory.md
@@ -96,7 +96,7 @@ The plan-ceo-review-plan-mode (:89, :108), plan-devex-review-plan-mode (:181-222
| Case | Check (file:line) | Matches | Structured source | User-visible | Disposition |
|---|---|---|---|---|---|
| ship-docsync-store | test/skill-e2e-ship-docsync.test.ts:74, :78 | ship report ~ `Documentation…blocked`; not `Documentation: current` | no receipt, no publish call (:75-77) | yes (report status) | (c) |
-| ship-docsync-completion, ship-docsync-current | test/skill-e2e-ship-docsync.test.ts:81, :97 | parsed docs contract, then report contains `contract.documentation_section` | docs dispatch JSON (ship/sections/documentation.md.tmpl:52-56) | yes | (a) |
+| ship-docsync-completion, ship-docsync-current | test/skill-e2e-ship-docsync.test.ts:85, :105 | parsed docs contract, then report contains `contract.documentation_section` | docs dispatch JSON (ship/sections/documentation.md.tmpl:52-56) | yes | (a) |
| ship-docsync-late-result, -missing-marker, -missing-asset, -launch-failure, -timeout-unsettled, -failure | test/helpers/docsync-fault-eval.ts:28, :30 | non-success report ~ `Documentation…blocked`, no `Documentation: current` outside inline code | actor event log decides dispatch count and publication first (:22-27) | yes | (c) |
| ship-docsync-recovery, -stale-before, -stale-after | test/helpers/docsync-fault-eval.ts:31 | report contains the accepted audit id | actor state `acceptedId` | yes | (a) |
| docsync-spawned | test/skill-e2e-docsync-spawned.test.ts:60 | `contract.blockers` ~ security/sensitive | parsed docs contract | no | (a) |
diff --git a/test/docsync-authority.test.ts b/test/docsync-authority.test.ts
index d8bc0c34f..ba449011e 100644
--- a/test/docsync-authority.test.ts
+++ b/test/docsync-authority.test.ts
@@ -145,6 +145,35 @@ test('completed docs review accepts literal cat but not an unrelated command or
} finally { fixture.clean(); }
});
+// Screen 40828580338 slice 8 (ship-docsync-failure, red on its phase read, green on the in-run rerun): the parent's first line
+// printed phase.md in full, then zsh read the separator `echo ======` as an = command lookup and abandoned the line (exit 1).
+test.skipIf(process.platform === 'win32')('captured run 40828580338: a line that opens with the full phase still reads it after a later command failed', () => {
+ const fixture = fixtureDocs('legacy');
+ try {
+ const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/docsync-phase-read-aborted-public.json'), 'utf8')) as
+ { calls: SkillTestResult['toolCalls'] };
+ const fill = (text: string) => text.replaceAll('{home}', fixture.home);
+ const phase = path.join(fixture.home, 'phase.md');
+ const command = fill(captured.calls[0].input.command);
+ const shape = /^Exit code 1\n([\s\S]+)\n\(eval\):1: ===== not found$/.exec(captured.calls[0].output);
+ expect(shape).not.toBeNull();
+ const body = shape![1];
+ fs.writeFileSync(phase, `${body}\n`);
+ const read = (line: string, output: string) => docsCompletedRead(calls({ tool: 'Bash', input: { command: line }, output }), phase, fixture);
+ expect(command.startsWith(`cat ${phase}; echo ======; `)).toBe(true);
+ expect(docsToolFailures(calls(...captured.calls.map(call => ({ ...call, input: { command: fill(call.input.command) } }))), fixture)).toEqual([]);
+ expect(read(command, captured.calls[0].output)).toBe(true);
+ expect(read(command, body)).toBe(true);
+ expect(read(`ls ${fixture.home}; ${command}`, captured.calls[0].output)).toBe(false);
+ expect(read(command.replace(`cat ${phase};`, `cat ${phase} | head -400;`), captured.calls[0].output)).toBe(false);
+ expect(read(command.replace(`cat ${phase};`, `cat ${fixture.home}/ship-invocation.md ${phase};`), captured.calls[0].output)).toBe(false);
+ expect(read(command, `Exit code 1\n(eval):1: boom\n${body}`)).toBe(false);
+ expect(read(command, `Exit code 1\n${body.slice(0, 200)}`)).toBe(false);
+ expect(read(command, `Error: ${captured.calls[0].output}`)).toBe(false);
+ expect(read(command, `${captured.calls[0].output}`)).toBe(false);
+ } finally { fixture.clean(); }
+});
+
test.skipIf(process.platform === 'win32')('replay of Linux run 37237194905 late-result: only the write to a mistyped shard root is outside authority', () => {
const fixture = fixtureDocs('current');
try {
diff --git a/test/docsync-command-grammar.test.ts b/test/docsync-command-grammar.test.ts
index c11cc4823..4df0b7ce8 100644
--- a/test/docsync-command-grammar.test.ts
+++ b/test/docsync-command-grammar.test.ts
@@ -643,3 +643,35 @@ test.skipIf(process.platform === 'win32')('the captured store orientations with
expect(docsNativeInterface(store, [publish], false, true)).toContain('branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains)');
} finally { store.clean(); }
});
+
+// Screens 40822033640 and 40815828320 and gate 40809051510 (ship-docsync-current, -completion and -store, each green on the
+// in-run rerun): each parent joined `cat --help 2>&1 | head -N` to a line of reads. GNU cat
+// printed its own usage and read nothing; a BSD cat would read the operand, which stays checked as a read either way.
+test.skipIf(process.platform === 'win32')('the captured lines joining a read command\'s --help replay clean, and --help grants nothing else', () => {
+ const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/docsync-read-help-public.json'), 'utf8')) as
+ { runs: Array<{ sourceRun: string; scenario: 'current' | 'updated' | 'store'; calls: SkillTestResult['toolCalls'] }> };
+ expect(captured.runs.map(run => run.scenario)).toEqual(['current', 'updated', 'store']);
+ for (const run of captured.runs) {
+ const fixture = fixtureDocs(run.scenario);
+ try {
+ const publish = path.join(fixture.home, 'fixture-publish.ts');
+ const fill = (text: string) => text.replaceAll('{home}', fixture.home).replaceAll('{shard}', path.dirname(path.dirname(fixture.home)));
+ const replayed = run.calls.map(call => ({ ...call, input: Object.fromEntries(Object.entries(call.input).map(([key, value]) =>
+ [key, typeof value === 'string' ? fill(value) : value])), output: fill(call.output) }));
+ const joined = replayed.filter(call => /; cat \S+\/bin\/gstack-docs-candidate --help 2>&1 \| head -\d+(?:;|$)/.test(String(call.input?.command ?? '')));
+ expect(joined, run.sourceRun).toHaveLength(1);
+ expect(docsToolFailures({ toolCalls: replayed, exitReason: 'success' } as SkillTestResult, fixture, [publish], run.scenario !== 'updated'), run.sourceRun).toEqual([]);
+ } finally { fixture.clean(); }
+ }
+ const fixture = fixtureDocs('current');
+ try {
+ const publish = path.join(fixture.home, 'fixture-publish.ts');
+ const candidate = path.join(fixture.skills, 'bin/gstack-docs-candidate');
+ for (const line of [`git status; cat ${candidate} --help 2>&1 | head -5`, 'cat README.md --help', 'git status; cat README.md --help', 'ls handbook --help; git status', 'git status; cat --help 2>&1 | head -5'])
+ expect(docsCommandAllowed(line, fixture, [publish]), line).toBe(true);
+ for (const line of ['git status; cat /etc/hostname --help | head -5', 'git status; cat README.md --help > out.txt', 'git status; echo x | cat README.md --help',
+ `git status; cat ${path.join(fixture.home, 'actor-state.json')} --help`, 'cd handbook; git status; cat README.md --help', 'git status; cat README.md --help; rm -rf handbook',
+ 'git status; cat README.md --help $(pwd)', 'git status; tee out.txt --help', 'git status; find . --help -delete'])
+ expect(docsCommandAllowed(line, fixture, [publish]), line).toBe(false);
+ } finally { fixture.clean(); }
+});
diff --git a/test/docsync-lifecycle-interface.test.ts b/test/docsync-lifecycle-interface.test.ts
index d2535558c..821e6131e 100644
--- a/test/docsync-lifecycle-interface.test.ts
+++ b/test/docsync-lifecycle-interface.test.ts
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { fixtureDocs } from './helpers/docsync-fixture';
import { docsCommandAllowed, docsNativeInterface, docsPreambleCommands, docsToolFailures } from './helpers/docsync-observer';
+import { docsCapsCommand } from './helpers/docsync-interface';
import { parseNDJSON, type SkillTestResult } from './helpers/session-runner';
let fixture: ReturnType;
@@ -256,3 +257,56 @@ describe.skipIf(process.platform === 'win32')('candidate call at a missing binar
{ tool: 'Bash', input: { command: `${declared}/bin/gstack-docs-candidate --help` }, output: 'usage' }]))).toContain('command outside declared docs observation interface');
});
});
+
+// Screen 40749421675 slice 13 (ship-docsync-stale-after, red on its call 16, green on the in-run rerun): the parent typed the
+// cap gate at the shard root ({shard}/.claude/..., exit 127), then ran it at the declared path, the slip the candidate excuses.
+describe.skipIf(process.platform === 'win32')('captured run 40749421675 ship-docsync-stale-after: the cap gate typed at the shard root', () => {
+ const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/docsync-ship-receipt-not-found-public.json'), 'utf8')) as
+ { calls: SkillTestResult['toolCalls'] };
+ const actor = path.join(import.meta.dir, 'helpers/docsync-fault-actor.ts');
+ let current: ReturnType;
+ let missing: string;
+ beforeAll(() => {
+ current = fixtureDocs('current');
+ missing = path.join(current.home, 'missing-shard');
+ });
+ afterAll(() => current?.clean());
+ const fill = (text: string) => text.replaceAll('{shard}', missing).replaceAll('{home}', current.home).replaceAll('{actor}', actor);
+ const failures = (calls: SkillTestResult['toolCalls']) => docsToolFailures({ toolCalls: calls.map(call => ({ tool: call.tool, output: fill(call.output),
+ input: Object.fromEntries(Object.entries(call.input).map(([key, value]) => [key, typeof value === 'string' ? fill(value) : value])) })) } as SkillTestResult,
+ current, [actor]);
+ const slip = () => captured.calls.findIndex(call => call.output.startsWith('Exit code 127'));
+ const declared = () => captured.calls.findIndex(call => call.input.command === '{home}/.claude/skills/gstack/bin/gstack-ship-receipt docs-caps --base main');
+ const notFound = (binary: string) => `Exit code 127\n(eval):1: no such file or directory: ${binary}`;
+ const outside = 'command outside declared docs observation interface';
+
+ test('the capture passes; the mistyped cap gate alone is outside the interface', () => {
+ expect(declared()).toBe(slip() + 1);
+ expect(fill(captured.calls[slip()].input.command)).toBe(`${missing}/.claude/skills/gstack/bin/gstack-ship-receipt docs-caps --base main`);
+ expect(fill(captured.calls[declared()].input.command)).toBe(docsCapsCommand(current.skills.split(path.sep).join('/')));
+ expect(docsCommandAllowed(fill(captured.calls[slip()].input.command), current, [actor])).toBe(false);
+ expect(failures(captured.calls)).toEqual([]);
+ });
+
+ test('without a later successful declared cap gate the slip still fails', () => {
+ const rest = captured.calls.filter((_, index) => index !== declared());
+ expect(failures(rest)).toContain(outside);
+ expect(failures([captured.calls[declared()], ...rest])).toContain(outside);
+ expect(failures(captured.calls.map((call, index) => index === declared() ? { ...call, output: 'Exit code 1\nboom' } : call))).toContain(outside);
+ });
+
+ test('another subcommand, another shell message or a binary that exists at the wrong path still fails', () => {
+ const wrong = captured.calls[slip()];
+ const json = (call: SkillTestResult['toolCalls'][number]) => ({ ...call, input: { command: `${call.input.command} --json` } });
+ expect(failures(captured.calls.map((call, index) => index === slip() || index === declared() ? json(call) : call))).toEqual([outside, outside]);
+ for (const output of [`Exit code 127\n(eval):1: command not found: {shard}/.claude/skills/gstack/bin/gstack-ship-receipt`, 'Exit code 1\nerror',
+ notFound('{shard}/.claude/skills/gstack/bin/gstack-docs-candidate'), `${wrong.output}\nextra`])
+ expect(failures(captured.calls.map((call, index) => index === slip() ? { ...call, output } : call)), output).toContain(outside);
+ const binary = fill(wrong.input.command.split(' ')[0]);
+ fs.mkdirSync(path.dirname(binary), { recursive: true });
+ fs.writeFileSync(binary, 'cap gate stand-in\n');
+ try {
+ expect(failures(captured.calls)).toContain(outside);
+ } finally { fs.rmSync(missing, { recursive: true, force: true }); }
+ });
+});
diff --git a/test/fixtures/docsync-invalid-first-attempt-public.json b/test/fixtures/docsync-invalid-first-attempt-public.json
new file mode 100644
index 000000000..dfb594afe
--- /dev/null
+++ b/test/fixtures/docsync-invalid-first-attempt-public.json
@@ -0,0 +1,9 @@
+{
+ "sourceRun": "40805543956 slice 3 ship-docsync-completion (PR #3567 screen @ 621baec9, tested sha 3b90122e)",
+ "sourceFileSha256": "ffac038f75a8f36ce487ff6756f38cf45a5169fe6c30552c99b01867fb03c62b",
+ "note": "The final output line (the completion JSON) of each of the two /document-release dispatches of the captured parent (calls 13 and 35), red on parseDocsCompletion \"completion fields\", green on the in-run rerun. Attempt 1 (status updated, one edit) omitted documentation_section; the parent saved it as rejected, re-snapshotted and re-audited; attempt 2 (status current, no edit) met the contract and its section went into the report.",
+ "completions": [
+ "{\"schema_version\":1,\"audit_id\":\"ship145-a1\",\"status\":\"updated\",\"files_updated\":[\"handbook/reference/commands/widget.md.tmpl\"],\"files_reviewed\":[\"AGENTS.md\",\"README.md\",\"handbook/reference/commands/widget.md.tmpl\",\"CHANGELOG.md\",\"TODOS.md\",\"VERSION\",\"package.json\",\"app.ts\",\"options.ts\"],\"blockers\":[],\"decisions\":[\"handbook/reference/commands/widget.md.tmpl: factual auto-update 'Default format: text.' -> 'Default format: json.' to match staged app.ts; 'KEEP THIS EXACTLY' line preserved.\",\"No generated page or generator for handbook/reference/commands/widget.md.tmpl exists in the repo; parent owns any regeneration.\",\"gstack-banned-terms check not run: outside the fixture's closed command interface.\",\"Metadata observation: package.json version 0.1.0 differs from VERSION 0.1.0.0; left untouched (parent owns metadata).\",\"CHANGELOG.md has no entry for the default format change to json; CHANGELOG not mutated (parent owns).\",\"CHANGELOG.md, TODOS.md, VERSION and package.json read for Step 6 consistency only; not edited.\",\"personal-note.txt and .qa-state/ excluded from the audit and not touched.\",\"Steps 5, 7, 8, cross-model review and 9 skipped per ship-owned mode.\"]}",
+ "{\"schema_version\":1,\"audit_id\":\"ship145-a2\",\"status\":\"current\",\"files_updated\":[],\"files_reviewed\":[\"AGENTS.md\",\"README.md\",\"handbook/reference/commands/widget.md.tmpl\",\"app.ts\",\"options.ts\",\"CHANGELOG.md\",\"TODOS.md\",\"VERSION\",\"package.json\"],\"blockers\":[],\"decisions\":[\"Confirmed SESSION_KIND: spawned from the gstack-skill-start echo (SESSION_ID 833-1793240595-854450a3); gstack-skill-end recorded outcome=success.\",\"The current hashes of app.ts, README.md, options.ts and handbook/reference/commands/widget.md.tmpl match candidate audit-ship145-a2.json.\",\"Kept the existing unstaged edit to handbook/reference/commands/widget.md.tmpl (Default format: json.) as current content; the KEEP THIS EXACTLY line is unchanged.\",\"Did not document options.ts export pretty: its meaning and whether users can set it are unclear, and coverage gaps are reported, not filled.\",\"Metadata: package.json version 0.1.0 differs from VERSION and CHANGELOG 0.1.0.0; left untouched for the parent.\",\"Metadata: CHANGELOG.md has no entry for the default format change from text to json; the parent owns CHANGELOG.\",\"Did not run gstack-banned-terms check because the fixture's closed command interface does not allow it.\",\"No regeneration needed: the only authored handbook doc is a .md.tmpl source and no generated page exists.\",\"Did not touch personal-note.txt or .qa-state/.\"],\"documentation_section\":\"**Status:** current. The docs already match the release; no edits were needed.\\n\\n**Scope:** branch feature/docs vs base c74d835 (no commits since base). Staged app.ts (default format changed from text to json), unstaged README.md and handbook/reference/commands/widget.md.tmpl, and the new file options.ts (export pretty = true). Docs audited: README.md, AGENTS.md, handbook/reference/commands/widget.md.tmpl, plus CHANGELOG.md and TODOS.md read for consistency.\\n\\n```\\nDocumentation health:\\n README.md Current (already says the default output format is JSON)\\n handbook/reference/commands/widget.md.tmpl Current (says Default format: json.; earlier edit kept)\\n AGENTS.md Current (the handbook/ and .md.tmpl rule was followed)\\n CHANGELOG.md Not edited (parent-owned; no entry for the format change)\\n TODOS.md Current (not edited)\\n```\\n\\n### Documentation Debt\\n\\n- Critical gap: `pretty` in options.ts has no reference, how-to, tutorial or explanation coverage. A reference line in handbook/reference/commands/widget.md.tmpl would fill it once its meaning is confirmed.\\n- Common gap: the JSON default format has reference coverage only (README.md, widget.md.tmpl) and no how-to example.\\n- Diagram drift: none; the repo has no diagrams.\\n\\nSuggest adding a docs-debt label and running /document-generate to fill the gaps.\"}"
+ ]
+}
diff --git a/test/fixtures/docsync-phase-read-aborted-public.json b/test/fixtures/docsync-phase-read-aborted-public.json
new file mode 100644
index 000000000..281f97556
--- /dev/null
+++ b/test/fixtures/docsync-phase-read-aborted-public.json
@@ -0,0 +1,21 @@
+{
+ "note": "The first two tool calls of the captured ship-docsync-failure parent (run 40828580338 paid-slice-8-a1, PR #3572 @ 14c4c5e2, tested merge cbba43c2 onto main ff2f191b; red on docsCompletedRead(phase) toBe(true), passed on the in-run rerun). {home} is the fixture home. Call 0 printed phase.md in full, then zsh read echo ====== as an = command lookup, printed \"===== not found\" and abandoned the rest of the line (exit 1); call 1 reran the abandoned reads. Its output is kept verbatim; call 1 output is dropped.",
+ "sourceRun": "40828580338 slice 8 ship-docsync-failure (PR #3572 screen, tested sha cbba43c2)",
+ "sourceFileSha256": "59d411c8e4c07cfc46408ae6f753541721180dbbbe6032dfe6abf0778c6b5afb",
+ "calls": [
+ {
+ "tool": "Bash",
+ "input": {
+ "command": "cat {home}/phase.md; echo ======; cat {home}/ship-invocation.md; echo =====; ls {home} {home}/.claude/skills/gstack"
+ },
+ "output": "Exit code 1\n## Step 14.5: Documentation audit (every ship)\n\n**Doc-sync invariant:** Every ship dispatches the /document-release subagent before final\ncommit/verification/publication, including reruns, already-pushed branches, existing PRs and docs-only changes.\nNo edits means an executed audit, not a skip; report the section's verified outcome.\n\n> **STOP.** Before auditing docs before final commit/verification (Step 14.5), on every ship, Read `~/.claude/skills/gstack/ship/sections/documentation.md` and execute it\n> in full. Do not work from memory — that section is the source of truth for this step.\n\n\n\n## Documentation\n