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 type { fixtureDocs } from './docsync-fixture'; import { DOC_PATH, docsSnapshotArgv, gitAt, gitBlobId, repoContents, repoInventory, type DocsCandidateRecord } from './docsync-repo'; import { extractDocsDispatch } from './docsync-contract'; import { docsNativeInterface } from './docsync-interface'; export const DOCS_CHECKPOINT_MARKER = ''; export type DocsFault = 'missing-marker' | 'missing-asset' | 'launch-failure' | 'timeout-unsettled' | 'late-result' | 'stale-before' | 'stale-after' | 'recovery' | 'legacy-completion'; export interface ActorEvent { action: string; audit_id?: string; task_id?: string; detail?: string; } export interface DocsActorState { root: string; scenario: DocsFault; events: ActorEvent[]; tasks: Array<{ id: string | null; audit_id: string; settled: boolean; stopRequested: boolean; elapsed_ms: number; prompt: string; candidate: string; prompt_sha256: string; candidate_sha256: string; observed_candidate: DocsCandidateRecord }>; repaired: boolean; armed: boolean; lateChanged: boolean; acceptedId: string | null; } export function docsActorCanRepair(scenario: DocsFault): boolean { return scenario === 'recovery' || scenario === 'late-result'; } function owned(root: string, file: string): string { const absolute = path.resolve(file); if (!absolute.startsWith(fs.realpathSync(root) + path.sep)) throw Error('actor path outside fixture'); const existing = fs.existsSync(absolute) ? absolute : path.dirname(absolute); if (fs.realpathSync(existing) !== fs.realpathSync(root) && !fs.realpathSync(existing).startsWith(fs.realpathSync(root) + path.sep)) throw Error('actor symlink escape'); return absolute; } function load(file: string): DocsActorState { const s = JSON.parse(fs.readFileSync(file, 'utf8')) as DocsActorState; owned(s.root, file); return s; } function save(file: string, state: DocsActorState) { fs.writeFileSync(file, JSON.stringify(state), { mode: 0o600 }); } function locked(file: string, body: () => T): T { const lock = owned(load(file).root, file + '.lock'); const fd = fs.openSync(lock, 'wx', 0o600); try { return body(); } finally { fs.closeSync(fd); fs.unlinkSync(lock); } } const SNAPSHOT_TIMEOUT_MS = 30_000; const shellWord = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`; export const SNAPSHOT_STDERR_LIMIT = 800; export type SnapshotFailureReason = 'snapshot_timeout' | 'snapshot_spawn_error' | 'snapshot_exit' | 'snapshot_invalid_record'; class SnapshotFailure extends Error { constructor(readonly reason: SnapshotFailureReason, readonly status: string, readonly stderr: string) { super(reason); } } /** * The tail of the helper's stderr with secrets redacted and the fixture root replaced by , at most SNAPSHOT_STDERR_LIMIT characters. * The redaction engine loads only here, on a failed snapshot, so hook and CLI spawns skip its import. */ function stderrExcerpt(root: string, stderr: string): string { const { redactFindingSpans } = require('../../lib/redact-engine') as typeof import('../../lib/redact-engine'); const redacted = redactFindingSpans(stderr.replaceAll(root, '').trim()); if (redacted === null) return '[stderr omitted: unredactable secret]'; return redacted.length > SNAPSHOT_STDERR_LIMIT ? '…' + redacted.slice(-(SNAPSHOT_STDERR_LIMIT - 1)) : redacted; } /** * Runs the installed `gstack-docs-candidate snapshot` (the section's Prepare helper) for the fixture selection * and returns the record it wrote, or throws a SnapshotFailure with a stable reason. `out` must not exist yet. */ function installedSnapshot(s: DocsActorState, auditId: string, out: string): { record: DocsCandidateRecord; command: string } { const repo = path.join(s.root, 'repo'); const argv = docsSnapshotArgv({ repo, skills: path.join(s.root, '.claude/skills/gstack') }, auditId, out); const run = spawnSync(argv[0]!, argv.slice(1), { cwd: repo, encoding: 'utf8', timeout: SNAPSHOT_TIMEOUT_MS, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); const stderr = run.stderr || ''; if (run.error) { const timedOut = (run.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'; throw new SnapshotFailure(timedOut ? 'snapshot_timeout' : 'snapshot_spawn_error', run.error.message, stderr); } if (run.status !== 0) throw new SnapshotFailure('snapshot_exit', `exit ${run.status ?? run.signal}`, stderr); let record: DocsCandidateRecord; try { record = JSON.parse(fs.readFileSync(out, 'utf8')) as DocsCandidateRecord; } catch (error) { throw new SnapshotFailure('snapshot_invalid_record', `exit 0, unreadable record: ${(error as Error).message}`, stderr); } if (record.schema_version !== 1 || record.audit_id !== auditId || record.mode !== 'edit' || record.base !== argv[argv.indexOf('--base') + 1] || !Array.isArray(record.index) || !record.hashes || typeof record.hashes !== 'object') throw new SnapshotFailure('snapshot_invalid_record', 'exit 0, invalid record', stderr); return { record, command: argv.map(shellWord).join(' ') }; } /** The candidate prepare would save now, observed without touching any saved artifact. */ function observeCandidate(s: DocsActorState, auditId: string): DocsCandidateRecord { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-actor-observe-')); try { return installedSnapshot(s, auditId, path.join(dir, 'observed.json')).record; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } function changeCandidate(s: DocsActorState) { fs.writeFileSync(path.join(s.root, 'repo/app.ts'), 'export const format = "json";\n'); s.lateChanged = true; s.armed = false; s.events.push({ action: 'scheduled-input-edit', detail: 'app.ts' }); } export function docsActorCommand(file: string, action: string, args: Record = {}): { text: string; exit: number } { return locked(file, () => { const s = load(file); let text = ''; let exit = 0; const last = () => { const task = s.tasks.find(t => t.id === args.task_id); if (!task || !args.task_id) throw Error('unknown fixture child'); return task; }; const complete = (auditId: string, status: 'current' | 'updated' | 'blocked', blockers: string[] = [], updated: string[] = []) => { return `SESSION_KIND: ${blockers.includes('Missing spawned marker') ? 'interactive' : 'spawned'}\n` + JSON.stringify({ schema_version: 1, audit_id: auditId, status, files_updated: updated, files_reviewed: blockers.length ? [] : [DOC_PATH], documentation_section: `${status} — fixture child audit ${auditId}; ${blockers.length ? blockers.join('; ') : 'reviewed the selected command reference'}.`, blockers, decisions: [], }); }; try { if (action === 'prepare') { if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(args.audit_id ?? '')) throw Error('prepare requires a fresh literal audit id'); if (s.tasks.some(t => !t.settled)) throw Error('attempted snapshot with unsettled writer'); const failedPrepares = s.events.filter(e => e.action === 'prepare-failed'); if (failedPrepares.some(e => e.audit_id === args.audit_id)) throw Error(`reason=snapshot_retry_needs_fresh_id: prepare ${args.audit_id} failed; retry once with a fresh audit_id`); if (failedPrepares.length >= 2) throw Error('reason=snapshot_retry_exhausted: prepare already failed and was retried once; report the documentation phase blocked'); const repo = path.join(s.root, 'repo'); const skills = path.join(s.root, '.claude/skills/gstack'); const candidateFile = owned(s.root, path.join(s.root, `candidate-${args.audit_id}.json`)); const promptFile = owned(s.root, path.join(s.root, `prompt-${args.audit_id}.md`)); const saved = (file: string) => fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined; if (saved(candidateFile) || saved(promptFile)) throw Error('prepare cannot overwrite a saved snapshot or prompt'); const source = extractDocsDispatch(fs.readFileSync(path.join(skills, 'ship/sections/documentation.md'), 'utf8')); let snapshot: ReturnType; try { snapshot = installedSnapshot(s, args.audit_id, candidateFile); } catch (error) { if (!(error instanceof SnapshotFailure)) throw error; const failureFile = owned(s.root, path.join(s.root, `prepare-${args.audit_id}-failure.json`)); const kept = [...(saved(candidateFile) ? [candidateFile] : []), failureFile]; const failure = { reason: error.reason, audit_id: args.audit_id, status: error.status, stderr_excerpt: stderrExcerpt(s.root, error.stderr), kept, retry: failedPrepares.length ? 'none left' : 'at most once, with a fresh audit_id' }; fs.writeFileSync(failureFile, JSON.stringify(failure, null, 2), { flag: 'wx', mode: 0o600 }); s.events.push({ action: 'prepare-failed', audit_id: args.audit_id, detail: JSON.stringify(failure) }); save(file, s); return { text: `prepare failed: reason=${failure.reason} (${failure.status}); retry ${failure.retry}; kept for inspection: ${kept.join(', ')}\nstderr (redacted, last ${SNAPSHOT_STDERR_LIMIT} chars):\n${failure.stderr_excerpt}`, exit: 25 }; } const candidate = snapshot.record; const prompt = source.replaceAll('${HOME}', s.root).replaceAll('', candidate.branch) .replaceAll('', 'main').replaceAll('', candidateFile) .replaceAll('', args.audit_id).replaceAll('', candidate.mode) + '\n\n' + docsNativeInterface({ home: s.root, repo, skills }); fs.writeFileSync(promptFile, prompt, { flag: 'wx', mode: 0o600 }); s.events.push({ action, audit_id: args.audit_id, detail: JSON.stringify({ candidate, prompt, snapshot_command: snapshot.command }) }); text = JSON.stringify({ audit_id: args.audit_id, candidate: candidateFile, prompt: promptFile, snapshot_command: snapshot.command }); } else if (action === 'dispatch') { if (!args.audit_id || args.run_in_background !== 'false') throw Error('dispatch requires identity and explicit foreground flag'); if (s.tasks.some(t => !t.settled)) throw Error('attempted writer overlap'); if (s.events.some(e => e.action === 'dispatch' && e.audit_id === args.audit_id)) throw Error('reused audit identity'); if (s.events.some(e => e.action === 'prepare-failed' && e.audit_id === args.audit_id)) throw Error('dispatch of a failed prepare'); const prompt = fs.readFileSync(owned(s.root, args.prompt), 'utf8'); const candidate = fs.readFileSync(owned(s.root, args.candidate)); if (!prompt.includes('document-release') || !prompt.includes('files_updated') || !prompt.includes(args.audit_id)) throw Error('dispatch did not carry the actual workflow prompt'); const task = { id: s.scenario === 'launch-failure' ? null : `fixture-child-${s.tasks.length + 1}`, audit_id: args.audit_id, settled: true, stopRequested: false, elapsed_ms: 0, prompt, candidate: candidate.toString('utf8'), prompt_sha256: new Bun.CryptoHasher('sha256').update(prompt).digest('hex'), candidate_sha256: new Bun.CryptoHasher('sha256').update(candidate).digest('hex'), observed_candidate: observeCandidate(s, args.audit_id) }; s.events.push({ action, audit_id: args.audit_id, ...(task.id === null ? {} : { task_id: task.id }) }); if (s.scenario === 'missing-asset') throw Error('missing installed asset must block before dispatch'); s.tasks.push(task); if (s.scenario === 'launch-failure') { exit = 23; text = 'Child launch failed: injected unavailable worker. No child was started.'; save(file, s); return { text, exit }; } if (s.scenario === 'legacy-completion') { const doc = owned(s.root, path.join(s.root, 'repo', DOC_PATH)); fs.writeFileSync(doc, fs.readFileSync(doc, 'utf8').replace('Default format: text.', 'Default format: JSON.')); s.events.push({ action: 'partial-doc-edit', audit_id: args.audit_id, detail: DOC_PATH }); text = 'SESSION_KIND: spawned\n' + JSON.stringify({ files_updated: [], commit_sha: null, pushed: false, documentation_section: null }); } else if (s.scenario === 'missing-marker' || s.scenario === 'recovery' && !s.repaired) { text = complete(args.audit_id, 'blocked', ['Missing spawned marker']); } else if (s.scenario === 'timeout-unsettled' || s.scenario === 'late-result' && s.tasks.length === 1) { task.settled = false; text = JSON.stringify({ task_id: task.id, status: 'running', elapsed_ms: 0, virtual_clock: true }); } else if (s.scenario === 'late-result') { if (!s.repaired) throw Error('transport must be repaired before retry'); text = complete(s.tasks[0].audit_id, 'current'); s.events.push({ action: 'late-callback', audit_id: s.tasks[0].audit_id }); } else if (s.scenario.startsWith('stale-') && s.tasks.length === 1) { if (s.scenario === 'stale-before') changeCandidate(s); else s.armed = true; text = complete(args.audit_id, 'current'); s.events.push({ action: 'completion', audit_id: args.audit_id }); } else { const updated: string[] = []; if (s.lateChanged) { const doc = path.join(s.root, 'repo', DOC_PATH); fs.writeFileSync(doc, fs.readFileSync(doc, 'utf8').replace('Default format: text.', 'Default format: JSON.')); updated.push(DOC_PATH); s.events.push({ action: 'factual-doc-edit', audit_id: args.audit_id, detail: DOC_PATH }); } s.acceptedId = args.audit_id; text = complete(args.audit_id, updated.length ? 'updated' : 'current', [], updated); s.events.push({ action: 'completion', audit_id: args.audit_id }); } } else if (action === 'inspect') { if (Object.keys(args).length) throw Error('inspect takes no arguments'); if (s.armed) changeCandidate(s); const repo = path.join(s.root, 'repo'); const inventory = repoInventory(repo); if (inventory.length > 64) throw Error('inspect inventory exceeds the bound'); const contents = repoContents(repo, inventory); const [head, base] = gitAt(repo, 'rev-parse', 'HEAD', 'main').split('\n'); const files = Object.fromEntries(inventory.map(rel => { const bytes = contents[rel]; if (bytes === undefined) return [rel, { exists: false }]; const buffer = Buffer.from(bytes, 'base64'); return [rel, { exists: true, hash: gitBlobId(buffer), content: buffer.toString('utf8') }]; })); text = JSON.stringify({ operation: 'inspect', base, head, branch: gitAt(repo, 'branch', '--show-current'), index: gitAt(repo, 'ls-files', '-s', '-z').split('\0').filter(Boolean), pre_existing_dirty: gitAt(repo, 'status', '--porcelain', '-z'), diff_committed: gitAt(repo, 'diff', base, 'HEAD'), diff_cached: gitAt(repo, 'diff', '--cached'), diff_worktree: gitAt(repo, 'diff'), inventory, files, }); s.events.push({ action, detail: new Bun.CryptoHasher('sha256').update(text).digest('hex') }); } else if (action === 'status') { const task = last(); task.elapsed_ms += task.stopRequested ? 300_001 : 600_001; s.events.push({ action, task_id: args.task_id, detail: task.settled ? 'settled' : 'running' }); text = JSON.stringify({ task_id: task.id, status: task.settled ? 'stopped' : 'running', settled: task.settled, elapsed_ms: task.elapsed_ms, virtual_clock: true }); } else if (action === 'stop') { const task = last(); task.stopRequested = true; if (s.scenario !== 'timeout-unsettled') task.settled = true; s.events.push({ action, task_id: args.task_id, detail: task.settled ? 'settled' : 'unsettled' }); text = JSON.stringify({ task_id: task.id, stop_requested: true, settled: task.settled }); } else if (action === 'repair') { if (!docsActorCanRepair(s.scenario) || s.repaired || !s.tasks.length || s.tasks.some(t => !t.settled)) throw Error('repair not available'); s.repaired = true; s.events.push({ action, detail: 'fixture transport/marking repaired' }); text = 'Fixture transport/marking repaired; future dispatches use the corrected launcher.'; } else if (action === 'publish') { s.events.push({ action, audit_id: args.audit_id }); if (!s.acceptedId || args.audit_id !== s.acceptedId || s.tasks.some(t => !t.settled)) throw Error('publication attempted without a current settled audit'); const report = fs.readFileSync(owned(s.root, args.report), 'utf8'); if (!report.includes(s.acceptedId)) throw Error('publication did not consume actual audit result'); fs.writeFileSync(path.join(s.root, 'publication.json'), JSON.stringify({ audit_id: s.acceptedId, report }), { mode: 0o600 }); text = 'Mock publication recorded.'; } else throw Error('unsupported fixture action'); } catch (error) { s.events.push({ action: 'rejected', audit_id: args.audit_id, detail: String(error) }); text = String(error); exit = 24; } save(file, s); return { text, exit }; }); } export function docsActorHook(file: string, input: string) { return locked(file, () => { const s = load(file); const e = JSON.parse(input); if (s.armed && e.hook_event_name === 'PreToolUse' && e.cwd === path.join(s.root, 'repo') && !JSON.stringify(e.tool_input ?? {}).includes('docsync-fault-actor.ts')) { changeCandidate(s); save(file, s); } }); } export function installDocsActor(fixture: ReturnType, scenario: DocsFault): string { fs.writeFileSync(fixture.invocation, `# Bounded ship fixture invocation This is synthetic prior-stage state supplied by the test, not evidence that real reviews or checks ran. Steps 0–14 are complete only within this isolated documentation-phase fixture. Do not reconstruct or rerun them. ## Release Base main (${gitAt(fixture.repo, 'rev-parse', 'main')}); HEAD ${fixture.before.head}; VERSION 0.1.0.0; package version 0.1.0; BUMP_LEVEL not applicable to this bounded phase. Existing metadata is intentional fixture input, not a request to repair release preparation. ## Decisions No risk exceptions or risky edits approved. Preserve unrelated and partial content. No real publication or later ship steps authorized. ## Reviews Earlier review stages are synthetic and outside this fixture. No live review handles or tokens are asserted. ## Checks Earlier check stages are synthetic and outside this fixture. No test receipts are asserted. ## Initial documentation state Attempts used: 0. No accepted audit, hashes, exception or child handle. The supplied candidate.json is initial fixture input, not an accepted audit; prepare saves the same selection with current hashes, so it needs no separate Read. ## Initial next steps 1. CURRENT: documentation phase (Step 14.5, or store documentation preflight). 2. Save the result and optionally execute the authorized local publication stand-in if the actual documentation gate permits it. 3. STOP before Step 15 or any store action. ## Documentation checkpoint journal Append changes in order. The latest stated value is current; earlier entries and the initial state remain evidence, not instructions to repeat completed work. ${DOCS_CHECKPOINT_MARKER} `, { mode: 0o600 }); const file = path.join(fixture.home, 'actor-state.json'); save(file, { root: fixture.home, scenario, events: [], tasks: [], repaired: false, armed: false, lateChanged: false, acceptedId: null }); const configFile = path.join(fixture.env.CLAUDE_CONFIG_DIR, 'settings.json'); const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); const quote = (p: string) => `'${p.replaceAll("'", "'\\''")}'`; config.hooks.PreToolUse.push({ matcher: '^(Bash|Read|Write|Edit|Glob|Grep)$', hooks: [{ type: 'command', command: `${quote(process.execPath)} ${quote(import.meta.path)} hook ${quote(file)}`, timeout: 5 }] }); fs.writeFileSync(configFile, JSON.stringify(config)); if (scenario === 'missing-asset') fs.unlinkSync(path.join(fixture.skills, 'document-release/sections/audit-scope.md')); return file; } export const DOCS_SEEDED_AUDIT_ID = 'ship-docs-a1'; const DOCS_CHILD_ASSETS = ['document-release/SKILL.md', 'document-release/sections/audit-scope.md', 'document-release/sections/release-body.md']; export function docsActorSeeded(scenario: DocsFault): boolean { return scenario !== 'missing-asset' && scenario !== 'legacy-completion'; } export function seedDocsFirstAttempt(fixture: ReturnType, file: string) { const assets = DOCS_CHILD_ASSETS.map(relative => { const asset = path.join(fixture.skills, relative); if (!fs.existsSync(asset)) throw Error(`seeded attempt requires installed ${relative}`); return { asset, sha256: new Bun.CryptoHasher('sha256').update(fs.readFileSync(asset)).digest('hex') }; }); const prepared = docsActorCommand(file, 'prepare', { audit_id: DOCS_SEEDED_AUDIT_ID }); if (prepared.exit) throw Error(`seeded prepare failed: ${prepared.text}`); const { candidate, prompt, snapshot_command } = JSON.parse(prepared.text) as { candidate: string; prompt: string; snapshot_command: string }; const dispatched = docsActorCommand(file, 'dispatch', { audit_id: DOCS_SEEDED_AUDIT_ID, candidate, prompt, run_in_background: 'false' }); if (dispatched.exit === 24) throw Error(`seeded dispatch rejected: ${dispatched.text}`); const completion = path.join(fixture.home, `completion-${DOCS_SEEDED_AUDIT_ID}.md`); fs.writeFileSync(completion, dispatched.text, { flag: 'wx', mode: 0o600 }); let handle = ''; try { handle = JSON.parse(dispatched.text).task_id ?? ''; } catch {} const record = fs.readFileSync(fixture.invocation, 'utf8'); if (record.split(DOCS_CHECKPOINT_MARKER).length !== 2) throw Error('invocation journal marker must occur exactly once'); fs.writeFileSync(fixture.invocation, record.replace(DOCS_CHECKPOINT_MARKER, () => `### Checkpoint 1 — attempt 1 (fixture-owned prior state) Attempts used: 1. Prepare step 1: installed child assets present with sha256 ${assets.map(a => `${a.asset} ${a.sha256}`).join('; ')}. Prepare steps 2–3: audit ${DOCS_SEEDED_AUDIT_ID}; candidate ${candidate}, written by ${snapshot_command}; prompt ${prompt}. Launched with dispatch run_in_background=false; dispatch exit code ${dispatched.exit}; its verbatim output is saved once at ${completion}.${handle ? ` Returned child handle: ${handle}.` : ''} Next: Parent processing for attempt 1, starting at Collect. A repeated Prepare may confirm step 1 with one literal sha256sum of these three asset paths instead of re-reading them; a missing asset or changed hash blocks before launch. ${DOCS_CHECKPOINT_MARKER}`)); return { auditId: DOCS_SEEDED_AUDIT_ID, candidate, prompt, snapshotCommand: snapshot_command, completion, exit: dispatched.exit, text: dispatched.text, events: load(file).events.length }; } if (import.meta.main) { const [action, file, ...rest] = process.argv.slice(2); if (action === 'hook') docsActorHook(file, fs.readFileSync(0, 'utf8')); else { const args = Object.fromEntries(rest.map(arg => { const at = arg.indexOf('='); if (at < 1) throw Error('fixture arguments use key=value'); return [arg.slice(0, at), arg.slice(at + 1)]; })); const result = docsActorCommand(file, action, args); console.log(result.text); process.exitCode = result.exit; } }