From 890ba7bda86f2ffba66ff0a878cb7e3f0182f720 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 30 Oct 2026 00:42:04 +0000 Subject: [PATCH] feat(review-packet): Step 14.5 docs audit as one read-only packet (GSTA-167) gstack-review-packet build --audit docs --candidate writes one packet: the auditor prompt from the documentation section, the candidate's doc surfaces, doc lines naming a changed code path, and the docs-plus-code diff. The reader proposes edits as findings; gstack-docs-candidate apply makes each unique-match edit outside protected and generated files and prints the audit result line the section already validates. On Capy the section dispatches the packet reader; other hosts keep the /document-release subagent. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-docs-candidate | 19 ++- bin/gstack-review-packet | 15 ++- docs/review-policy.md | 6 +- lib/docs-audit-apply.ts | 77 ++++++++++++ lib/review-packet-docs.ts | 99 ++++++++++++++++ lib/review-packet.ts | 8 +- ship/sections/documentation.md | 37 +++++- ship/sections/documentation.md.tmpl | 37 +++++- test/review-packet-docs.test.ts | 174 ++++++++++++++++++++++++++++ 9 files changed, 455 insertions(+), 17 deletions(-) create mode 100644 lib/docs-audit-apply.ts create mode 100644 lib/review-packet-docs.ts create mode 100644 test/review-packet-docs.test.ts diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate index 7208f13a4..2cc0df7fb 100755 --- a/bin/gstack-docs-candidate +++ b/bin/gstack-docs-candidate @@ -6,6 +6,7 @@ * gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only * --base [--select ]... [--docs ]... * gstack-docs-candidate compare + * gstack-docs-candidate apply --findings * gstack-docs-candidate [snapshot|compare] --help * * snapshot saves, NUL-safely and without writing to the repository: base SHA, @@ -20,10 +21,14 @@ * the index changed, which recorded paths' content changed or disappeared, and * which paths are newly dirty or untracked. It never decides whether a change * was permitted; the caller checks those paths against the child's report. + * + * apply makes a packet reader's proposed doc edits (lib/docs-audit-apply.ts) and + * prints the audit result a /document-release child would return, LAST line. */ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { applyDocsFindings } from '../lib/docs-audit-apply'; function git(args: string[]): string { const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 }); @@ -83,7 +88,8 @@ function one(args: string[], name: string): string { } const USAGE = `usage: gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only --base [--select ]... [--docs ]... - gstack-docs-candidate compare `; + gstack-docs-candidate compare + gstack-docs-candidate apply --findings `; /** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */ function recordProblems(record: any): string[] { @@ -98,7 +104,7 @@ function recordProblems(record: any): string[] { function main(argv: string[]): void { const [command, ...args] = argv; const topHelp = (command === '--help' || command === '-h') && !args.length; - const commandHelp = (command === 'snapshot' || command === 'compare') && args.length === 1 && args[0] === '--help'; + const commandHelp = (command === 'snapshot' || command === 'compare' || command === 'apply') && args.length === 1 && args[0] === '--help'; if (topHelp || commandHelp) { console.log(USAGE); return; @@ -160,6 +166,15 @@ function main(argv: string[]): void { })); return; } + if (command === 'apply') { + if (args.length !== 3 || args[1] !== '--findings') throw new Error('apply takes --findings '); + const record = JSON.parse(fs.readFileSync(args[0]!, 'utf8')); + if (typeof record?.audit_id !== 'string' || !Array.isArray(record.docs)) throw new Error(`${args[0]} is not a gstack-docs-candidate record (audit_id, docs)`); + const out = applyDocsFindings(fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()), record, fs.existsSync(args[2]!) ? fs.readFileSync(args[2]!, 'utf8') : ''); + console.log(out.lines.join('\n')); + process.exitCode = out.exit; + return; + } throw new Error(USAGE); } diff --git a/bin/gstack-review-packet b/bin/gstack-review-packet index b80d7aae1..3915e0d9a 100755 --- a/bin/gstack-review-packet +++ b/bin/gstack-review-packet @@ -7,6 +7,7 @@ * [--findings ] [--fix-list --tests [--callers ]] [--fanout batched|parallel] [plan flags] * gstack-review-packet build --audit test-coverage|plan-completion --base --out-dir [--plan-file ] * [--context ] [--passes-used ] + * gstack-review-packet build --audit docs --candidate --base --out-dir * gstack-review-packet collect --dir * * build runs the plan gstack-review-plan prints (lib/review-plan-live.ts, same flags) and writes @@ -22,6 +23,7 @@ import { reviewPlanLine } from '../lib/review-depth'; import { flagReader, livePlan, parsePlanFlags, planPass, PLAN_FLAGS_HELP } from '../lib/review-plan-live'; import { AUDITS, auditCodePaths, auditDraft, budgetRole, contentKey, draftFor, packetLine, packSpecialists, PACKET_CAP_BYTES, PACKINGS, READ_CHUNK_BYTES, readManifest, SPECIALIST_PACKING, writeManifest, writePackets, type AuditName, type BuildContext, type PacketEntry, type Packing } from '../lib/review-packet'; import { resolveBaseRef } from '../lib/review-collect'; +import { docsAuditDraft, readCandidate } from '../lib/review-packet-docs'; import { collect } from '../lib/review-packet-collect'; const BIN = 'gstack-review-packet'; @@ -39,6 +41,9 @@ const HELP = `${BIN} — one packet file per /ship reviewer; collect reads their --audit test-coverage|plan-completion the Step 7/8 audit packet instead of the plan's reviewers; a diff with no code path prints AUDIT_SKIPPED: test-coverage and builds nothing --context --passes-used the audit's parent-supplied inputs (the coverage packet is audit-only) + --audit docs --candidate the Step 14.5 documentation audit: one read-only packet over the gstack-docs-candidate + snapshot (doc surfaces, doc lines naming changed code, that diff); the reader + proposes edits as findings and gstack-docs-candidate apply makes them plan flags (same as gstack-review-plan): ${PLAN_FLAGS_HELP} collect --dir read every packet's output: one REVIEWER: line each, every CRITICAL on one @@ -81,8 +86,10 @@ function build(): number { const live = livePlan(flags, binDir, BIN); if ('errors' in live) { process.stderr.write(live.errors.map(e => e + '\n').join('')); return EXIT.fail; } const { plan, collected, head, root } = live; - const range = flag('--range'); - if (range !== undefined && spawnSync('git', ['rev-parse', '--verify', '-q', `${range}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 15_000 }).status !== 0) return fail(`--range ${range} is not a commit`); + const candidate = audit === 'docs' ? readCandidate(flag('--candidate')) : null; + if (candidate && 'error' in candidate) return fail(candidate.error); + const range = candidate?.base ?? flag('--range'); + if (range !== undefined && spawnSync('git', ['rev-parse', '--verify', '-q', `${range}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 15_000 }).status !== 0) return fail(`${candidate ? "the candidate's base" : '--range'} ${range} is not a commit`); const diffBase = range ?? collected.mergeBase!; fs.mkdirSync(absOut, { recursive: true, mode: 0o700 }); const ctx: BuildContext = { root, skillRoot: path.resolve(binDir, '..'), binDir, outDir: absOut, head, diffBase, baseRef: resolveBaseRef(root, flags.base), fingerprint: plan.numbers.fingerprint, learnings: learningsFor(binDir), @@ -98,7 +105,9 @@ function build(): number { if (context === null) return fail(`--context ${flag('--context')} is not readable`); if (audit === 'plan-completion' && planText === undefined) return fail('--audit plan-completion needs --plan-file '); const codePaths = audit === 'test-coverage' ? auditCodePaths(collected.paths, root) : null; - if (codePaths?.length === 0) { + if (candidate) { + entries = writePackets(docsAuditDraft(ctx, flags.base, live.auditEffort, candidate), ctx); + } else if (codePaths?.length === 0) { const shown = collected.paths.slice(0, 5).join(',') + (collected.paths.length > 5 ? `,+${collected.paths.length - 5} more` : ''); out.push(`AUDIT_SKIPPED: test-coverage code_paths=0 (docs, data and tests only: ${shown || 'empty diff'}); no audit dispatched, the coverage gate records zero paths`); } else { diff --git a/docs/review-policy.md b/docs/review-policy.md index 97a0956d5..7df192539 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -384,7 +384,11 @@ The Red Team's packet waits for the specialists (`--only red-team --findings test-coverage|plan-completion` writes a Step 7/8 audit packet from the generated section's prompt. The coverage audit packet is read-only (three calls: read, write, reply; the parent writes and runs the tests it proposes), and a diff with no code path (docs, -data and tests only) prints `AUDIT_SKIPPED:` and builds none. An outside backend gets the outside-voice packet as its prompt file. +data and tests only) prints `AUDIT_SKIPPED:` and builds none. `--audit docs --candidate ` +writes Step 14.5's documentation audit on Capy: one read-only packet holding the documentation +section's auditor prompt, the candidate's doc surfaces, the doc lines that name a changed code +path and the diff of the changed docs plus that code. The reader proposes edits as findings; +`gstack-docs-candidate apply` makes the unique, permitted ones and prints the audit result. An outside backend gets the outside-voice packet as its prompt file. `gstack-review-packet collect --dir ` reads every output file and prints at most 60 lines: one `REVIEWER:` line per packet (`clean`, `findings`, `missing`, `invalid` diff --git a/lib/docs-audit-apply.ts b/lib/docs-audit-apply.ts new file mode 100644 index 000000000..d73528ea5 --- /dev/null +++ b/lib/docs-audit-apply.ts @@ -0,0 +1,77 @@ +/** + * docs-audit-apply — gstack-docs-candidate apply: the parent's mechanical half of the + * Step 14.5 packet audit (lib/review-packet-docs.ts). The read-only reader writes one + * JSON finding per line and a LAST-line object; apply makes each \`edit\` whose \`old\` + * text occurs exactly once in a doc the candidate lists, refuses protected, generated, + * out-of-tree and read-only-mode edits as blockers, turns \`block\` findings into + * blockers and \`note\` findings into decisions, and prints the audit result in the + * schema a /document-release child returns (ship/sections/documentation.md). + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Files a documentation edit never touches: release metadata, manifests and lockfiles the parent owns. */ +const PROTECTED = [/^CHANGELOG\.md$/, /^TODOS\.md$/, /^VERSION$/, /(^|\/)package(-lock)?\.json$/, /(^|\/)(bun\.lockb?|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gemfile\.lock)$/, /(^|\/)sections\/manifest\.json$/, /^agents-digest\//]; +const GENERATED = /^(?:[^\n]*\n){0,2}\nold\n', 'docs/twice.md': 'same\nsame\n' }, { 'bin/tool-run': '#!/bin/sh\necho quick\n' }); + const cand = f.candidate('edit', ['README.md', 'docs', 'CHANGELOG.md']); + const out = path.join(f.home, 'docs.out'); + fs.writeFileSync(out, findings([ + { path: 'README.md', line: 3, action: 'edit', kind: 'stale', summary: 'flag renamed', old: '--fast', new: '--quick' }, + { path: 'docs/twice.md', action: 'edit', kind: 'stale', summary: 'ambiguous', old: 'same', new: 'x' }, + { path: 'CHANGELOG.md', action: 'edit', kind: 'stale', summary: 'protected', old: '# Changelog', new: 'x' }, + { path: 'docs/gen.md', action: 'edit', kind: 'stale', summary: 'generated', old: 'old', new: 'new' }, + { path: 'src/parser.ts', action: 'edit', kind: 'stale', summary: 'not a doc', old: 'parse', new: 'x' }, + { path: 'docs/other.md', action: 'block', kind: 'contradiction', summary: 'narrative rewrite needed' }, + { path: 'docs/guide.md', action: 'note', kind: 'coverage', summary: '--quick has no how-to' }, + ])); + const r = f.run(CANDIDATE, ['apply', cand, '--findings', out]); + expect(r.status).toBe(3); + const res = lastJson(String(r.stdout)); + expect(res).toMatchObject({ schema_version: 1, audit_id: 'audit-7', status: 'blocked', files_updated: ['README.md'], files_reviewed: ['README.md', 'docs/guide.md'], decisions: ['docs/guide.md: --quick has no how-to'] }); + expect(res.blockers.join('\n')).toMatch(/twice\.md.*more than once[\s\S]*CHANGELOG\.md.*protected[\s\S]*gen\.md.*generated[\s\S]*parser\.ts.*not a doc[\s\S]*other\.md: narrative/); + expect(res.documentation_section.startsWith('**Status:** blocked')).toBe(true); + expect(fs.readFileSync(path.join(f.dir, 'README.md'), 'utf8')).toContain('bin/tool-run --quick'); + expect(fs.readFileSync(path.join(f.dir, 'README.md'), 'utf8')).toContain('User-maintained note: KEEP.'); + for (const p of ['docs/twice.md', 'CHANGELOG.md', 'docs/gen.md', 'src/parser.ts']) expect(spawnSync('git', ['diff', '--quiet', '--', p], { cwd: f.dir, timeout: 10_000 }).status).toBe(0); + const cmp = JSON.parse(String(f.run(CANDIDATE, ['compare', cand]).stdout)); + expect(cmp).toMatchObject({ head_changed: false, index_changed: false, content_changed: ['README.md'], newly_dirty: [] }); + }); + + // Value: protects=a clean audit is current or updated with exit 0, read-only mode never writes, and an unusable last line or another audit id is exit 1; fails_when=read-only edits apply, or a stale reader output is accepted; + // why_new=apply is new; seam=none; no_claim=the parent's recovery + test('current and updated exit 0; read-only mode blocks every edit; a wrong audit id or prose last line is unusable', () => { + const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 5;\n' }); + const out = path.join(f.home, 'docs.out'); + fs.writeFileSync(out, findings([])); + const cur = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]); + expect(cur.status).toBe(0); + expect(lastJson(String(cur.stdout))).toMatchObject({ status: 'current', files_updated: [], blockers: [] }); + fs.writeFileSync(out, findings([{ path: 'docs/guide.md', action: 'edit', kind: 'stale', summary: 'path moved', old: 'src/parser.ts', new: 'src/parse.ts' }])); + const ro = f.run(CANDIDATE, ['apply', f.candidate('read-only'), '--findings', out]); + expect(ro.status).toBe(3); + expect(lastJson(String(ro.stdout)).blockers[0]).toContain('read-only mode'); + expect(fs.readFileSync(path.join(f.dir, 'docs/guide.md'), 'utf8')).toContain('src/parser.ts'); + const up = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]); + expect(up.status).toBe(0); + expect(lastJson(String(up.stdout))).toMatchObject({ status: 'updated', files_updated: ['docs/guide.md'] }); + fs.writeFileSync(out, findings([], { schema_version: 1, audit_id: 'audit-other', files_reviewed: [], documentation_section: 'x' })); + expect(f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]).status).toBe(1); + fs.writeFileSync(out, 'All docs are current.\n'); + const prose = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]); + expect(prose.status).toBe(1); + expect(String(prose.stdout)).toContain('DOCS_AUDIT_UNUSABLE'); + }); + + // Value: protects=a doc symlinked outside the repository is never written through; fails_when=apply follows the link; + // why_new=apply is new; seam=none; no_claim=symlinks inside the repository + test('a candidate doc that resolves outside the repository is refused, never written through', () => { + const outside = tmp('docs-outside-'); + fs.writeFileSync(path.join(outside, 'target.md'), 'secret target\n'); + const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 6;\n' }); + fs.symlinkSync(path.join(outside, 'target.md'), path.join(f.dir, 'docs', 'link.md')); + const out = path.join(f.home, 'docs.out'); + fs.writeFileSync(out, findings([{ path: 'docs/link.md', action: 'edit', kind: 'stale', summary: 'x', old: 'secret', new: 'changed' }])); + const r = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]); + expect(lastJson(String(r.stdout)).blockers[0]).toContain('outside the repository'); + expect(fs.readFileSync(path.join(outside, 'target.md'), 'utf8')).toBe('secret target\n'); + }); +});