diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate index 43d0cb90c..7208f13a4 100755 --- a/bin/gstack-docs-candidate +++ b/bin/gstack-docs-candidate @@ -22,7 +22,6 @@ * was permitted; the caller checks those paths against the child's report. */ import { spawnSync } from 'child_process'; -import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -46,8 +45,7 @@ function state(base: string) { }; } -function expand(paths: string[]): string[] { - const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'])); +function expand(listed: string[], paths: string[]): string[] { const out = new Set(); for (const p of paths) { const rel = p.replace(/^\.\//, '').replace(/\/+$/, ''); @@ -63,7 +61,7 @@ function hashes(paths: string[]): Record { for (const p of paths) { if (!fs.existsSync(p) || !fs.statSync(p).isFile()) { out[p] = null; continue; } const content = fs.readFileSync(p); - out[p] = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); + out[p] = new Bun.CryptoHasher('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); } return out; } @@ -116,9 +114,10 @@ function main(argv: string[]): void { const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim(); process.chdir(top); const now = state(base); - const selected = flag(args, '--select').length ? expand(flag(args, '--select')) + const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'])); + const selected = flag(args, '--select').length ? expand(listed, flag(args, '--select')) : [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort(); - const docs = expand(flag(args, '--docs')); + const docs = expand(listed, flag(args, '--docs')); const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort(); const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked) }; fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 }); diff --git a/test/docsync-fault-interface.test.ts b/test/docsync-fault-interface.test.ts index 78c1c8123..525df1500 100644 --- a/test/docsync-fault-interface.test.ts +++ b/test/docsync-fault-interface.test.ts @@ -468,7 +468,13 @@ test('legacy verdict permits early blocking or one evidenced changed-input re-au } finally { fixture.clean(); } }); -test('registered fault callbacks execute actual transport and hook commands, consume results, and reject controls without model calls', () => { +const CALLBACK_SPLITS = [ + ['adapter results', 'consumes actual adapter results|accepts its remaining changed-input audit', 10], + ['legacy controls', '^ship-docsync-failure rejects ', 12], + ['fault controls', '^ship-docsync-(?!failure )[a-z-]+ rejects ', 14], +] as const; + +for (const [split, pattern, count] of CALLBACK_SPLITS) test(`registered fault callbacks execute actual transport and hook commands, consume results, and reject controls without model calls (${split})`, () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-fault-callback-')); const root = path.resolve(import.meta.dir, '..'); try { @@ -851,7 +857,7 @@ for (const [name, mutation] of [ `); const home = path.join(dir, 'home'); fs.mkdirSync(home); - const result = Bun.spawnSync([process.execPath, 'test', script], { + const result = Bun.spawnSync([process.execPath, 'test', script, '--test-name-pattern', pattern], { env: { ...process.env, EVALS: '', EVALS_TIER: '', EVALS_ALL: '', EVALS_RUN_ID: 'free-docsync-faults', HOME: home, USERPROFILE: home, GSTACK_EVAL_DIR: path.join(dir, 'evidence'), GSTACK_HOME: path.join(dir, 'state') }, @@ -859,7 +865,8 @@ for (const [name, mutation] of [ }); const output = result.stdout.toString() + result.stderr.toString(); expect(result.exitCode, output).toBe(0); - expect(output).toContain('36 pass'); + expect(output).toContain(`${count} pass`); + expect(output).toContain(`${36 - count} filtered out`); expect(output).toContain('0 fail'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 120_000); diff --git a/test/helpers/docsync-fault-actor.ts b/test/helpers/docsync-fault-actor.ts index ed0c32b52..c3161fdb7 100644 --- a/test/helpers/docsync-fault-actor.ts +++ b/test/helpers/docsync-fault-actor.ts @@ -1,12 +1,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; -import { DOC_PATH, docsSnapshotArgv, gitAt, gitBlobId, fixtureDocs, repoSnapshot, type DocsCandidateRecord } from './docsync-fixture'; +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-observer'; -import { redactFindingSpans } from '../../lib/redact-engine'; +import { docsNativeInterface } from './docsync-interface'; export const DOCS_CHECKPOINT_MARKER = ''; @@ -65,8 +64,12 @@ 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 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; @@ -90,7 +93,7 @@ function installedSnapshot(s: DocsActorState, auditId: string, out: string): { r 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 !== gitAt(repo, 'rev-parse', 'main') + 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(' ') }; } @@ -172,8 +175,8 @@ export function docsActorCommand(file: string, action: string, args: Record 64) throw Error('inspect inventory exceeds the bound'); - const snapshot = repoSnapshot(repo); - const base = gitAt(repo, 'rev-parse', 'main'); + 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 = snapshot.contents[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: snapshot.head, + 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: createHash('sha256').update(text).digest('hex') }); + 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; @@ -333,7 +335,7 @@ export function seedDocsFirstAttempt(fixture: ReturnType, fi 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: createHash('sha256').update(fs.readFileSync(asset)).digest('hex') }; + 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}`); diff --git a/test/helpers/docsync-fixture.ts b/test/helpers/docsync-fixture.ts index c258ba1fb..fe653ccb7 100644 --- a/test/helpers/docsync-fixture.ts +++ b/test/helpers/docsync-fixture.ts @@ -2,79 +2,23 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; import { buildSeedConfig } from './hermetic-env'; import { getProjectEvalDir } from './eval-store'; import type { SkillTestResult } from './session-runner'; +import { DOC_PATH, docsConfigDir, gitAt, repoSnapshot } from './docsync-repo'; + +export { DOC_PATH, docsConfigDir, gitAt, repoSnapshot, changedFiles, docsSnapshotArgv, gitBlobId, type DocsCandidateRecord } from './docsync-repo'; export const DOCSYNC_ROOT = path.resolve(import.meta.dir, '../..'); -export const DOC_PATH = 'handbook/reference/commands/widget.md.tmpl'; export type DocsScenario = 'updated' | 'current' | 'risky' | 'store' | 'legacy'; -/** The fixture's CLAUDE_CONFIG_DIR: credentials and session transcripts, unreadable to the actor except settings.json. */ -export function docsConfigDir(home: string): string { - return path.join(home, 'cc'); -} - -export function gitAt(repo: string, ...args: string[]): string { - const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 15000, - env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); - if (result.status !== 0) throw new Error(`fixture git ${args.join(' ')}: ${result.stderr}`); - return result.stdout.trimEnd(); -} - -export function repoSnapshot(repo: string) { - const files = gitAt(repo, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0').filter(Boolean); - const contents: Record = {}; - for (const name of new Set(files)) { - const file = path.join(repo, name); - if (!fs.existsSync(file)) continue; - const resolved = fs.realpathSync(file); - if (!resolved.startsWith(fs.realpathSync(repo) + path.sep)) throw new Error(`fixture path escaped: ${name}`); - if (fs.statSync(file).isFile()) contents[name] = fs.readFileSync(file).toString('base64'); - } - return { head: gitAt(repo, 'rev-parse', 'HEAD'), index: gitAt(repo, 'ls-files', '--stage'), contents }; -} - -export function changedFiles(before: ReturnType, after: ReturnType): string[] { - return [...new Set([...Object.keys(before.contents), ...Object.keys(after.contents)])] - .filter(p => before.contents[p] !== after.contents[p]).sort(); -} - -/** The record `gstack-docs-candidate snapshot` writes and `compare` reads (bin/gstack-docs-candidate). */ -export interface DocsCandidateRecord { - schema_version: 1; audit_id: string; mode: 'edit' | 'read-only'; base: string; head: string; branch: string; - index: string[]; committed: string[]; staged: string[]; unstaged: string[]; untracked: string[]; - selected: string[]; docs: string[]; hashes: Record; -} - -/** - * The installed snapshot command for the fixture's audit selection: every tracked or new path except the - * user's personal note, with the handbook docs root, against main's commit. Returned as argv; the first - * element is the installed helper, so the command is the literal form the observation interface admits. - */ -export function docsSnapshotArgv(fixture: { repo: string; skills: string }, auditId: string, out: string): string[] { - const inventory = [...new Set(gitAt(fixture.repo, 'ls-files', '-z', '--cached', '--others', '--exclude-standard') - .split('\0').filter(Boolean))].sort(); - return [path.join(fixture.skills, 'bin/gstack-docs-candidate'), 'snapshot', '--out', out, '--audit-id', auditId, - '--mode', 'edit', '--base', gitAt(fixture.repo, 'rev-parse', 'main'), - ...inventory.filter(p => p !== 'personal-note.txt').flatMap(p => ['--select', p]), '--docs', 'handbook']; -} - -/** Git blob id of content, the hash representation of `DocsCandidateRecord.hashes`. */ -export function gitBlobId(content: Buffer): string { - return createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); -} - export function fixtureDocs(scenario: DocsScenario, generatedRoot = process.env.DOCSYNC_GENERATED_ROOT || DOCSYNC_ROOT) { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ds-')); const repo = path.join(home, 'repo'); const skills = path.join(home, '.claude/skills/gstack'); fs.mkdirSync(repo); gitAt(repo, 'init', '-b', 'main'); - gitAt(repo, 'config', 'user.email', 'test@test.com'); - gitAt(repo, 'config', 'user.name', 'Test'); - gitAt(repo, 'config', 'commit.gpgsign', 'false'); + fs.appendFileSync(path.join(repo, '.git/config'), '[user]\n\temail = test@test.com\n\tname = Test\n[commit]\n\tgpgsign = false\n'); fs.mkdirSync(path.join(repo, '.qa-state')); fs.appendFileSync(path.join(repo, '.git/info/exclude'), '\n.qa-state/\n'); const write = (file: string, text: string) => { diff --git a/test/helpers/docsync-interface.ts b/test/helpers/docsync-interface.ts new file mode 100644 index 000000000..382508de8 --- /dev/null +++ b/test/helpers/docsync-interface.ts @@ -0,0 +1,27 @@ +import * as path from 'node:path'; +import type { fixtureDocs } from './docsync-fixture'; +import { docsConfigDir } from './docsync-repo'; +import { readOnlyInterfaceText } from './read-only-commands'; + +export function docsNativeInterface(fixture: Pick, 'home' | 'repo' | 'skills'>, scripts: string[] = [], transport = false, insert = false, file?: string): string { + const skills = fixture.skills.split(path.sep).join('/'); + return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum, Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch --show-current, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision), the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands. ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The Preamble block, lifecycle and candidate commands and scripts never join such a line: run each as its own Bash call, never joined to another command with ;, &&, || or a pipe. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. Use Write/Edit for permitted docs and private JSON/Markdown artifacts under ${fixture.home}; do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. + +The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command. + +Platform: local/git-native. Base: main. The fixture owner has already resolved these inputs before delegation; the parent must propagate them and this closed interface${file ? ' (by its file path)' : ''} unchanged to every child. Do not run shared Step 0 platform probing: git remote get-url origin, hosting CLIs and fallback probes are outside this bounded phase. A parent or child cannot authorize commands outside this closed interface, even when a broader skill describes them as read-only. Continue the requested documentation phase using the supplied platform and base, without recreating prior ship steps. Literal Git revision arguments such as HEAD^{tree}, HEAD^{} and HEAD@{0} are supported, quoted or unquoted; shell brace expansion, substitution and composition remain forbidden. + +${transport ? 'Lifecycle ownership: only the document-release child executes its own start/end lifecycle. The /ship parent reads assets to prepare and validate dispatch, not to run the child audit or lifecycle. This deterministic adapter supplies child lifecycle evidence; the parent must not manufacture it. The following lifecycle commands describe the child, not parent work.\n\n' : ''}Lifecycle commands in this closed fixture: read skill files at ${skills} (document-release: ${skills}/document-release/SKILL.md). Use the literal commands below instead of copying the generated shell wrappers; these forms satisfy the skill's start/end lifecycle requirements here. Run each as a separate, single-line Bash call. Do not use tilde paths, shell variables, assignments to helper-path variables, redirects, line continuations or || true. Do not add a parent PID: the start helper supplies its default. + +Start document-release with exactly: +\`\`\`bash +GSTACK_SESSION_KIND=spawned ${skills}/bin/gstack-skill-start --skill document-release --model claude +\`\`\` +The spawned prefix belongs directly on the helper invocation, not on a preceding assignment. Read the returned SESSION_KIND, SESSION_ID and TEL_START status lines. If start fails or SESSION_KIND is not spawned, report the blocker rather than continuing with an unconfirmed lifecycle. + +At workflow completion, use this one-line end command. Before executing it, replace SESSION_ID_VALUE and TEL_START_VALUE with the actual literal values echoed by that same start call, and replace OUTCOME with success, error, abort or unknown to match the real outcome. Never execute the placeholders or reuse values from another session. +\`\`\`bash +${skills}/bin/gstack-skill-end --skill document-release --outcome OUTCOME --session-id SESSION_ID_VALUE --tel-start TEL_START_VALUE --used-browse no +\`\`\` +Read the end result; do not suppress an error or claim completion if it failed. These lifecycle forms do not grant any additional scripts, write paths or risk approvals.`; +} diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index 5cdcd9816..baa0c275d 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -4,9 +4,12 @@ import { createHash } from 'node:crypto'; import { observeQAWrites, type QAWriteObservation } from './qa-functional-observer'; import { nativeCalls } from './qa-checkpoint-evidence'; import type { SkillTestResult } from './session-runner'; -import { DOC_PATH, docsConfigDir, type DocsScenario, type fixtureDocs } from './docsync-fixture'; +import { DOC_PATH, type DocsScenario, type fixtureDocs } from './docsync-fixture'; +import { docsNativeInterface } from './docsync-interface'; import { sliceBetween } from './skill-fixture'; -import { readOnlyCommandAllowed, readOnlyInterfaceText } from './read-only-commands'; +import { readOnlyCommandAllowed } from './read-only-commands'; + +export { docsNativeInterface }; export async function observeDocsWrites(fixture: ReturnType) { return observeQAWrites(fixture.repo, { atomicTargets: [DOC_PATH] }); @@ -384,29 +387,6 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType, 'home' | 'repo' | 'skills'>, scripts: string[] = [], transport = false, insert = false, file?: string): string { - const skills = fixture.skills.split(path.sep).join('/'); - return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum, Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch --show-current, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision), the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands. ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The Preamble block, lifecycle and candidate commands and scripts never join such a line: run each as its own Bash call, never joined to another command with ;, &&, || or a pipe. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. Use Write/Edit for permitted docs and private JSON/Markdown artifacts under ${fixture.home}; do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. - -The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command. - -Platform: local/git-native. Base: main. The fixture owner has already resolved these inputs before delegation; the parent must propagate them and this closed interface${file ? ' (by its file path)' : ''} unchanged to every child. Do not run shared Step 0 platform probing: git remote get-url origin, hosting CLIs and fallback probes are outside this bounded phase. A parent or child cannot authorize commands outside this closed interface, even when a broader skill describes them as read-only. Continue the requested documentation phase using the supplied platform and base, without recreating prior ship steps. Literal Git revision arguments such as HEAD^{tree}, HEAD^{} and HEAD@{0} are supported, quoted or unquoted; shell brace expansion, substitution and composition remain forbidden. - -${transport ? 'Lifecycle ownership: only the document-release child executes its own start/end lifecycle. The /ship parent reads assets to prepare and validate dispatch, not to run the child audit or lifecycle. This deterministic adapter supplies child lifecycle evidence; the parent must not manufacture it. The following lifecycle commands describe the child, not parent work.\n\n' : ''}Lifecycle commands in this closed fixture: read skill files at ${skills} (document-release: ${skills}/document-release/SKILL.md). Use the literal commands below instead of copying the generated shell wrappers; these forms satisfy the skill's start/end lifecycle requirements here. Run each as a separate, single-line Bash call. Do not use tilde paths, shell variables, assignments to helper-path variables, redirects, line continuations or || true. Do not add a parent PID: the start helper supplies its default. - -Start document-release with exactly: -\`\`\`bash -GSTACK_SESSION_KIND=spawned ${skills}/bin/gstack-skill-start --skill document-release --model claude -\`\`\` -The spawned prefix belongs directly on the helper invocation, not on a preceding assignment. Read the returned SESSION_KIND, SESSION_ID and TEL_START status lines. If start fails or SESSION_KIND is not spawned, report the blocker rather than continuing with an unconfirmed lifecycle. - -At workflow completion, use this one-line end command. Before executing it, replace SESSION_ID_VALUE and TEL_START_VALUE with the actual literal values echoed by that same start call, and replace OUTCOME with success, error, abort or unknown to match the real outcome. Never execute the placeholders or reuse values from another session. -\`\`\`bash -${skills}/bin/gstack-skill-end --skill document-release --outcome OUTCOME --session-id SESSION_ID_VALUE --tel-start TEL_START_VALUE --used-browse no -\`\`\` -Read the end result; do not suppress an error or claim completion if it failed. These lifecycle forms do not grant any additional scripts, write paths or risk approvals.`; -} - function within(file: string, root: string): boolean { return file === root || file.startsWith(root + path.sep); } diff --git a/test/helpers/docsync-repo.ts b/test/helpers/docsync-repo.ts new file mode 100644 index 000000000..b98713fa1 --- /dev/null +++ b/test/helpers/docsync-repo.ts @@ -0,0 +1,74 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +// Git and file reads of a docs fixture repository. Kept free of heavy imports: the fault actor's CLI and +// PreToolUse hook load this module on every spawn. + +export const DOC_PATH = 'handbook/reference/commands/widget.md.tmpl'; + +/** The fixture's CLAUDE_CONFIG_DIR: credentials and session transcripts, unreadable to the actor except settings.json. */ +export function docsConfigDir(home: string): string { + return path.join(home, 'cc'); +} + +export function gitAt(repo: string, ...args: string[]): string { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 15000, + env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); + if (result.status !== 0) throw new Error(`fixture git ${args.join(' ')}: ${result.stderr}`); + return result.stdout.trimEnd(); +} + +/** Every tracked or new, non-ignored path, deduplicated and sorted. */ +export function repoInventory(repo: string): string[] { + return [...new Set(gitAt(repo, 'ls-files', '-z', '--cached', '--others', '--exclude-standard') + .split('\0').filter(Boolean))].sort(); +} + +/** Base64 content of each listed regular file that exists; throws when a path resolves outside the repo. */ +export function repoContents(repo: string, files: Iterable): Record { + const contents: Record = {}; + for (const name of files) { + const file = path.join(repo, name); + if (!fs.existsSync(file)) continue; + const resolved = fs.realpathSync(file); + if (!resolved.startsWith(fs.realpathSync(repo) + path.sep)) throw new Error(`fixture path escaped: ${name}`); + if (fs.statSync(file).isFile()) contents[name] = fs.readFileSync(file).toString('base64'); + } + return contents; +} + +export function repoSnapshot(repo: string) { + const files = gitAt(repo, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0').filter(Boolean); + const contents = repoContents(repo, new Set(files)); + return { head: gitAt(repo, 'rev-parse', 'HEAD'), index: gitAt(repo, 'ls-files', '--stage'), contents }; +} + +export function changedFiles(before: ReturnType, after: ReturnType): string[] { + return [...new Set([...Object.keys(before.contents), ...Object.keys(after.contents)])] + .filter(p => before.contents[p] !== after.contents[p]).sort(); +} + +/** The record `gstack-docs-candidate snapshot` writes and `compare` reads (bin/gstack-docs-candidate). */ +export interface DocsCandidateRecord { + schema_version: 1; audit_id: string; mode: 'edit' | 'read-only'; base: string; head: string; branch: string; + index: string[]; committed: string[]; staged: string[]; unstaged: string[]; untracked: string[]; + selected: string[]; docs: string[]; hashes: Record; +} + +/** + * The installed snapshot command for the fixture's audit selection: every tracked or new path except the + * user's personal note, with the handbook docs root, against main's commit. Returned as argv; the first + * element is the installed helper, so the command is the literal form the observation interface admits. + */ +export function docsSnapshotArgv(fixture: { repo: string; skills: string }, auditId: string, out: string): string[] { + const inventory = repoInventory(fixture.repo); + return [path.join(fixture.skills, 'bin/gstack-docs-candidate'), 'snapshot', '--out', out, '--audit-id', auditId, + '--mode', 'edit', '--base', gitAt(fixture.repo, 'rev-parse', 'main'), + ...inventory.filter(p => p !== 'personal-note.txt').flatMap(p => ['--select', p]), '--docs', 'handbook']; +} + +/** Git blob id of content, the hash representation of `DocsCandidateRecord.hashes`. */ +export function gitBlobId(content: Buffer): string { + return new Bun.CryptoHasher('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); +}