From a59b5e4097ef8459c97d60bd3bcf7a20687de885 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 22 Oct 2026 13:10:23 -0700 Subject: [PATCH] v1.91.179.0 test(docsync): cut per-case spawn cost in the fault interface and split its outer run (flaky pass 2026-10-22) (#3342) * test(docsync): cut per-case spawn cost in the fault interface and split its outer run (GSTA-63) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: docsync-fault-interface per-case cost (GSTA-63) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.179.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-docs-candidate | 11 ++--- package.json | 2 +- test/docsync-fault-interface.test.ts | 13 +++-- test/helpers/docsync-fault-actor.ts | 34 +++++++------ test/helpers/docsync-fixture.ts | 64 ++---------------------- test/helpers/docsync-interface.ts | 27 ++++++++++ test/helpers/docsync-observer.ts | 30 ++--------- test/helpers/docsync-repo.ts | 74 ++++++++++++++++++++++++++++ 11 files changed, 156 insertions(+), 113 deletions(-) create mode 100644 test/helpers/docsync-interface.ts create mode 100644 test/helpers/docsync-repo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2041bb9..14385ad28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.179.0] - 2026-10-22 + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + ## [1.91.178.0] - 2026-10-22 **A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. diff --git a/VERSION b/VERSION index 6f962c98e..33ab59825 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.178.0 +1.91.179.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index de69107ff..ef23ba62c 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.178.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.179.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, 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/package.json b/package.json index 13c65d39c..c004c412e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.178", + "version": "1.91.179", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", 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'); +}