feat(review-packet): Step 14.5 docs audit as one read-only packet (GSTA-167)

gstack-review-packet build --audit docs --candidate <snapshot> 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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-30 00:42:04 +00:00
1 parent dbe700417c
commit 890ba7bda8
9 files changed
+455 -17

No files matched your search

+17 -2
View File
@@ -6,6 +6,7 @@
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
* --base <rev> [--select <path>]... [--docs <path>]...
* gstack-docs-candidate compare <candidate.json>
* gstack-docs-candidate apply <candidate.json> --findings <reader out file>
* 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 <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]...
gstack-docs-candidate compare <candidate.json>`;
gstack-docs-candidate compare <candidate.json>
gstack-docs-candidate apply <candidate.json> --findings <reader out file>`;
/** 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 <candidate.json> --findings <file>');
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);
}
+12 -3
View File
@@ -7,6 +7,7 @@
* [--findings <merged.jsonl>] [--fix-list <f> --tests <f> [--callers <f>]] [--fanout batched|parallel] [plan flags]
* gstack-review-packet build --audit test-coverage|plan-completion --base <ref> --out-dir <dir> [--plan-file <path>]
* [--context <file>] [--passes-used <n>]
* gstack-review-packet build --audit docs --candidate <snapshot.json> --base <ref> --out-dir <dir>
* gstack-review-packet collect --dir <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 <file> --passes-used <n> the audit's parent-supplied inputs (the coverage packet is audit-only)
--audit docs --candidate <f> 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 <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 <the bound plan>');
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 {
+5 -1
View File
@@ -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 <snapshot>`
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 <dir>` reads every output file and prints at most
60 lines: one `REVIEWER:` line per packet (`clean`, `findings`, `missing`, `invalid`
+77
View File
@@ -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}<!-- AUTO-GENERATED/;
export interface DocsAuditResult {
schema_version: 1; audit_id: string; status: 'updated' | 'current' | 'blocked';
files_updated: string[]; files_reviewed: string[]; blockers: string[]; decisions: string[]; documentation_section: string;
}
const strings = (v: unknown) => Array.isArray(v) ? [...new Set(v.filter((s): s is string => typeof s === 'string' && s.length > 0))] : [];
/** Apply the reader's findings in root; exit 0 updated/current, 3 blocked, 1 when the output is not a usable audit result. */
export function applyDocsFindings(root: string, candidate: { audit_id: string; mode: string; docs: string[] }, text: string): { lines: string[]; result?: DocsAuditResult; exit: number } {
const rows = text.split('\n').map(l => l.trim()).filter(Boolean);
let last: any;
try { last = JSON.parse(rows.at(-1) ?? ''); } catch { last = undefined; }
if (!last || typeof last !== 'object' || last.schema_version !== 1 || last.audit_id !== candidate.audit_id || typeof last.documentation_section !== 'string' || !last.documentation_section.trim()) {
return { lines: [`DOCS_AUDIT_UNUSABLE: the LAST line is not {"schema_version":1,"audit_id":"${candidate.audit_id}",…,"documentation_section":"…"}; the audit is blocked (Blocked recovery)`], exit: 1 };
}
const realRoot = fs.realpathSync(root);
const blockers: string[] = [];
const decisions: string[] = [];
const edited = new Map<string, { before: string; now: string; edits: number }>();
rows.slice(0, -1).forEach((row, i) => {
let f: any;
try { f = JSON.parse(row); } catch { f = undefined; }
if (!f || typeof f !== 'object' || typeof f.path !== 'string' || typeof f.summary !== 'string' || !['edit', 'block', 'note'].includes(f.action)) {
blockers.push(`finding line ${i + 1} is not a finding object (path, action edit|block|note, summary)`);
return;
}
const where = `${f.path}${Number.isInteger(f.line) ? `:${f.line}` : ''}`;
if (f.action === 'note') { decisions.push(`${where}: ${f.summary}`); return; }
if (f.action === 'block') { blockers.push(`${where}: ${f.summary}`); return; }
const refuse = (why: string) => blockers.push(`${where}: ${f.summary} (edit not applied: ${why})`);
if (candidate.mode === 'read-only') return refuse('read-only mode');
if (typeof f.old !== 'string' || !f.old || typeof f.new !== 'string') return refuse('an edit needs nonempty old and a new string');
if (!candidate.docs.includes(f.path)) return refuse('not a doc in the candidate');
if (PROTECTED.some(r => r.test(f.path))) return refuse('protected file; the parent owns it');
let file: string;
try { file = fs.realpathSync(path.join(realRoot, f.path)); } catch { return refuse('file not found'); }
if (!file.startsWith(realRoot + path.sep)) return refuse('resolves outside the repository');
const entry = edited.get(f.path) ?? (() => { const before = fs.readFileSync(file, 'utf8'); return { before, now: before, edits: 0 }; })();
if (GENERATED.test(entry.before)) return refuse('generated file; edit its template');
const at = entry.now.indexOf(f.old);
if (at < 0 || entry.now.indexOf(f.old, at + 1) >= 0) return refuse(at < 0 ? 'old text not found' : 'old text occurs more than once');
entry.now = entry.now.slice(0, at) + f.new + entry.now.slice(at + f.old.length);
entry.edits++;
edited.set(f.path, entry);
});
const files_updated: string[] = [];
const lines: string[] = [];
for (const [p, e] of [...edited].sort(([a], [b]) => a.localeCompare(b))) {
if (e.now === e.before) continue;
fs.writeFileSync(path.join(realRoot, p), e.now);
files_updated.push(p);
lines.push(`APPLIED: ${p} edits=${e.edits}`);
}
for (const b of blockers) lines.push(`BLOCKER: ${b}`);
const status = blockers.length ? 'blocked' : files_updated.length ? 'updated' : 'current';
const summary = status === 'updated' ? `updated ${files_updated.join(', ')}` : status === 'current' ? 'current, no edits needed' : `blocked: ${blockers.length} blocker(s)${files_updated.length ? `; edited ${files_updated.join(', ')}` : ''}`;
const section = `**Status:** ${status} (${summary})\n\n${String(last.documentation_section).replace(/^\*\*Status:\*\*[^\n]*\n+/, '').trim()}`;
const result: DocsAuditResult = { schema_version: 1, audit_id: candidate.audit_id, status, files_updated, files_reviewed: strings(last.files_reviewed), blockers, decisions, documentation_section: section };
lines.push(`DOCS_AUDIT: status=${status} applied=${files_updated.length} blockers=${blockers.length} notes=${decisions.length}`, JSON.stringify(result));
return { lines, result, exit: status === 'blocked' ? 3 : 0 };
}
+99
View File
@@ -0,0 +1,99 @@
/**
* review-packet-docs — the Step 14.5 documentation audit as one read-only packet
* (gstack-review-packet build --audit docs; docs/review-policy.md#reviewer-packets).
* The packet holds the auditor prompt (the generated documentation section's text
* fence), the doc surfaces of the gstack-docs-candidate snapshot, the doc lines that
* name a changed code path, and the diff of the changed docs plus the code they
* describe. The reader proposes edits as findings; gstack-docs-candidate apply
* makes them (lib/docs-audit-apply.ts).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyPath } from './review-depth';
import { auditPrompt, detectStack, reviewerDiff, type BuildContext, type PacketDraft } from './review-packet';
/** Doc lines naming a changed code path the packet quotes, at most. */
export const DOCS_NAMED_LINES = 120;
/** Diff bytes of code no doc names that the packet still shows; past this the stat lists them. */
export const DOCS_CODE_DIFF_BYTES = 80_000;
const DOCS_LIST_CAP = 300;
const DOC_SCAN_BYTES = 512 * 1024;
export interface DocsCandidate { audit_id: string; mode: 'edit' | 'read-only'; base: string; docs: string[] }
/** The candidate record gstack-docs-candidate snapshot wrote, or why it cannot be used. */
export function readCandidate(file: string | undefined): DocsCandidate | { error: string } {
if (!file) return { error: '--audit docs needs --candidate <the Prepare snapshot record>' };
let r: any;
try { r = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { error: `--candidate ${file} is not a readable gstack-docs-candidate record` }; }
if (r?.schema_version !== 1 || typeof r.audit_id !== 'string' || !['edit', 'read-only'].includes(r.mode) || typeof r.base !== 'string' || !Array.isArray(r.docs)) {
return { error: `--candidate ${file} lacks schema_version 1, audit_id, mode, base or docs: rerun the Prepare snapshot` };
}
return { audit_id: r.audit_id, mode: r.mode, base: r.base, docs: r.docs.filter((d: unknown): d is string => typeof d === 'string') };
}
const runArtifact = (ctx: BuildContext) => (p: string) => /^\.gstack\/[^/]+\//.test(p) || path.resolve(ctx.root, p).startsWith(path.resolve(ctx.outDir) + path.sep);
/** Tokens a doc line names a code path by: the path, its file name, and the name without its extension when distinctive. */
function namesOf(p: string): string[] {
const file = path.basename(p);
const stem = file.replace(/\.[^.]+$/, '');
return [...new Set([p, file, ...(stem.length >= 5 && stem !== file ? [stem] : [])])];
}
/** Each doc line that names a changed code path, and which code paths some doc names. */
export function namedLines(root: string, docs: readonly string[], code: readonly string[]): { lines: string[]; total: number; named: Set<string> } {
const lines: string[] = [];
const named = new Set<string>();
let total = 0;
const tokens = code.map(p => ({ p, names: namesOf(p) }));
for (const doc of docs) {
const file = path.join(root, doc);
let text: string;
try {
const real = fs.realpathSync(file);
if (!real.startsWith(fs.realpathSync(root) + path.sep) || fs.statSync(real).size > DOC_SCAN_BYTES) continue;
text = fs.readFileSync(real, 'utf8');
} catch { continue; }
text.split('\n').forEach((line, i) => {
const hit = tokens.filter(t => t.names.some(n => line.includes(n)));
if (!hit.length) return;
hit.forEach(t => named.add(t.p));
total++;
if (lines.length < DOCS_NAMED_LINES) lines.push(`${doc}:${i + 1}: ${line.trim().slice(0, 200)}`);
});
}
return { lines, total, named };
}
const fenced = (s: string) => { const f = '`'.repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map(m => m[0].length + 1))); return `${f}\n${s.replace(/\n?$/, '\n')}${f}`; };
export function docsAuditDraft(ctx: BuildContext, base: string, effort: string, cand: DocsCandidate): PacketDraft {
const { stack } = detectStack(ctx.root);
const diff = reviewerDiff(ctx.root, ctx.diffBase, [], false, runArtifact(ctx));
const isDoc = (p: string) => cand.docs.includes(p) || classifyPath(p, ctx.root) === 'docs';
const code = diff.paths.filter(p => !isDoc(p) && classifyPath(p, ctx.root) === 'code');
const found = namedLines(ctx.root, cand.docs, code);
let budget = DOCS_CODE_DIFF_BYTES;
const shown = diff.chunks.filter((c, i) => {
const p = diff.paths[i]!;
if (isDoc(p) || found.named.has(p)) return true;
if (!code.includes(p) || Buffer.byteLength(c) > budget) return false;
budget -= Buffer.byteLength(c);
return true;
});
const left = diff.chunks.length - shown.length;
const changed = new Set(diff.paths);
const surfaces = cand.docs.slice(0, DOCS_LIST_CAP).map(d => `- ${d}${changed.has(d) ? ' (changed)' : ''}`);
if (cand.docs.length > DOCS_LIST_CAP) surfaces.push(`- … ${cand.docs.length - DOCS_LIST_CAP} more in the candidate record`);
const prompt = auditPrompt(ctx.skillRoot, 'docs', base, ctx.baseRef).replaceAll('<audit-id>', cand.audit_id).replaceAll('<mode>', cand.mode);
const head = [`Role: docs audit (effort=${effort}). Audit id: ${cand.audit_id}. Mode: ${cand.mode}.`, '',
`## Instructions\n\n${prompt}`, '',
`## Output\n\nWrite your findings and the LAST-line JSON object the instructions name to the output path below in one write. Then reply with exactly one line, \`DONE <NAME>\`.`, '',
`## Context\n\nStack: ${stack}. Diff base: \`${ctx.diffBase}\` (the candidate's base); head \`${ctx.head}\`.`, '',
`## Doc surfaces (${cand.docs.length}, from the candidate)\n\n${surfaces.join('\n') || '- none: no documentation file is in scope; describe that scope in documentation_section'}`, '',
`## Doc lines that name a changed code path (${found.total}${found.total > found.lines.length ? `, first ${found.lines.length}` : ''})\n\n${fenced(found.lines.join('\n') || '(none)')}`, '',
...(left ? [`${left} changed file(s) are in the stat but not the diff below (tests, data, or code no doc names past ${DOCS_CODE_DIFF_BYTES} bytes); read a named range of one only if a doc line above depends on it.`, ''] : [])];
return { role: 'audit', name: 'docs', effort, checklists: [], summary: false, head, tail: [], diff: { ...diff, chunks: shown },
diffLabel: 'Diff of the changed docs and the code they describe', diffCmd: `git diff -U8 ${ctx.diffBase}` };
}
+5 -3
View File
@@ -24,7 +24,9 @@ export const READ_CHUNK_BYTES = 30_000;
/** A data-format file (.json/.jsonl/.ndjson/.csv/.tsv, not a package or config manifest) past this many changed lines reaches reviewers as a summary. */
export const DATA_SUMMARY_LINES = 400;
export const MANIFEST = 'manifest.json';
export const AUDITS = ['test-coverage', 'plan-completion'] as const;
export const AUDITS = ['test-coverage', 'plan-completion', 'docs'] as const;
/** The ship section whose text fence is an audit's prompt. */
const AUDIT_SECTION: Record<AuditName, string> = { 'test-coverage': 'test-coverage', 'plan-completion': 'plan-completion', docs: 'documentation' };
export type AuditName = (typeof AUDITS)[number];
export type PacketRole = Role | 'audit';
@@ -299,9 +301,9 @@ export function draftFor(d: Dispatch, ctx: BuildContext): PacketDraft {
/** An audit's prompt is the generated section's text fence: the packet and the SKILL.md prose are one text. */
export function auditPrompt(skillRoot: string, audit: AuditName, base: string, baseRef = `origin/${base}`): string {
const section = fs.readFileSync(path.join(skillRoot, 'ship', 'sections', `${audit}.md`), 'utf8');
const section = fs.readFileSync(path.join(skillRoot, 'ship', 'sections', `${AUDIT_SECTION[audit]}.md`), 'utf8');
const m = /^(`{4,})text\n([\s\S]*?)\n\1$/m.exec(section);
if (!m) throw new Error(`ship/sections/${audit}.md has no prompt fence`);
if (!m) throw new Error(`ship/sections/${AUDIT_SECTION[audit]}.md has no prompt fence`);
return m[2]!.replaceAll('origin/<base>', baseRef).replaceAll('<base>', base);
}
+33 -4
View File
@@ -20,7 +20,8 @@ Reentry never resets the count or authorizes a launch.
## Prepare the candidate
1. Read the installed document-release SKILL.md and its full audit-scope/release-body
1. **On Capy (`GSTACK_CAPY=1`) the audit is one packet** and needs no document-release
read: skip to item 2. Elsewhere, read the installed document-release SKILL.md and its full audit-scope/release-body
content (sections, or inlined on external hosts). A missing section
or old `Ship-owned documentation mode` blocks before launch; never substitute.
2. Select the base SHA. Store-only audits compare source/build content to a known
@@ -35,7 +36,31 @@ Reentry never resets the count or authorizes a launch.
## Launch the audit
**Dispatch /document-release as a subagent** with the Agent tool (never Skill),
**On Capy, by packet:** `~/.claude/skills/gstack/bin/gstack-review-packet build --audit docs --candidate <private.json> --base <base> --out-dir .gstack/tmp/review-packets-docs`
writes one read-only packet: the auditor prompt below, the candidate's doc surfaces, the doc
lines that name a changed code path and the diff of the changed docs plus that code. A refused
build blocks before launch. Dispatch one subagent whose whole brief is Step 7's packet brief
with this packet's `read=` ranges; retain the child id. It proposes edits as findings and
never writes a doc.
````text
You are the /ship documentation auditor (Step 14.5). Read-only: edit no file, run no command, never commit, push or start another workflow. The parent applies your proposed edits mechanically and owns every decision you return. Audit id: <audit-id>. Mode: <mode>.
Checklist, against the diff below and the docs it touches or names:
1. Coverage map: each new, changed, renamed or removed public surface (commands, flags, config keys, environment variables, endpoints, skills, exported names) and where the docs cover it (reference, how-to, tutorial, explanation). Zero coverage is a critical gap, reference-only a common gap; report gaps as debt, never write new pages.
2. Per file: README (features, install, examples, troubleshooting); ARCHITECTURE (diagrams and components match the code; be conservative); CONTRIBUTING (would each setup command still succeed for a new contributor); CLAUDE.md/AGENTS.md (structure, commands, build and test instructions); every other listed doc: does a line contradict the diff?
3. Cross-doc: README and project instructions agree; every doc is reachable from README.md or CLAUDE.md/AGENTS.md; diagram entities the diff renamed, split, moved or removed.
4. Classify each needed change: `edit` for a factual correction the diff clearly warrants (a table row, a path, a count, a flag name, a structure tree); `block` for narrative or positioning text, a removal, security-model text, more than ~10 lines in one section, a new section, uncertain relevance, a generated file (name its template), CHANGELOG, TODOS, VERSION, package or lock files, or any correction in read-only mode; `note` for debt and observations.
Reads: this packet in its read= ranges, then at most one turn of named line ranges (200 lines or fewer) of the listed docs and the code they describe. No repository search, no other packet, no SKILL.md.
Write to the output path in one write: one JSON object per finding, one per line:
{"path":"<repo-relative doc>","line":<n>,"action":"edit|block|note","kind":"stale|missing|contradiction|discoverability|coverage|diagram","summary":"<one line>","old":"<exact current text, once in that file>","new":"<replacement>"}
`old` and `new` only on `edit`. Then the LAST line, one JSON object:
{"schema_version":1,"audit_id":"<audit-id>","files_reviewed":["<docs you actually read>"],"documentation_section":"<Markdown: audited scope, per-file status in Documentation health form, coverage debt, diagram drift; no ## Documentation heading>"}
````
**Elsewhere:** Dispatch /document-release as a subagent with the Agent tool (never Skill),
`subagent_type: "general-purpose"`.
**Foreground required:** pass `run_in_background: false` when available on the Agent call — subagents run in the background by default since Claude Code v2.1.198, so omitting an available flag gives a background run. A launch receipt means it went background: await its completion notice. Dispatch through the Agent tool only: invoking the target as a Skill, or executing its workflow inline in your own context, forfeits the fresh-context isolation this dispatch exists for, even though the skill may appear in your available-skills list. (Where a step defines an inline fallback, it applies only after a dispatched subagent has failed.) Retain the child id.
@@ -66,9 +91,13 @@ Reentry never resets the count or authorizes a launch.
1. **Collect.** Inspect the child handle for terminal completion and final output
within ~10 minutes; launch metadata is not completion. On failure/deadline,
use recovery before another writer.
use recovery before another writer. A packet reader's result is its `out=` file:
run `~/.claude/skills/gstack/bin/gstack-docs-candidate apply <candidate> --findings <out>`.
It makes each `edit` whose `old` text occurs once in a candidate doc (never a protected or
generated file, none in read-only mode), turns `block` findings and unappliable edits into
blockers, and prints the audit result on its LAST line; that line is the output below.
2. **Check output.** Parse only the LAST nonempty line. Require every field/type,
exact audit id, schema, status invariant and actual spawned marker above, as
exact audit id, schema, status invariant and, from a /document-release child, the actual spawned marker above, as
echoed in the child output; state files are not evidence of it.
Never default or reconstruct missing values.
3. **Check ownership.** Run `gstack-docs-candidate compare <candidate>`.
+33 -4
View File
@@ -18,7 +18,8 @@ Reentry never resets the count or authorizes a launch.
## Prepare the candidate
1. Read the installed document-release SKILL.md and its full audit-scope/release-body
1. **On Capy (`GSTACK_CAPY=1`) the audit is one packet** and needs no document-release
read: skip to item 2. Elsewhere, read the installed document-release SKILL.md and its full audit-scope/release-body
content (sections, or inlined on external hosts). A missing section
or old `Ship-owned documentation mode` blocks before launch; never substitute.
2. Select the base SHA. Store-only audits compare source/build content to a known
@@ -33,7 +34,31 @@ Reentry never resets the count or authorizes a launch.
## Launch the audit
**Dispatch /document-release as a subagent** with the Agent tool (never Skill),
**On Capy, by packet:** `~/.claude/skills/gstack/bin/gstack-review-packet build --audit docs --candidate <private.json> --base <base> --out-dir .gstack/tmp/review-packets-docs`
writes one read-only packet: the auditor prompt below, the candidate's doc surfaces, the doc
lines that name a changed code path and the diff of the changed docs plus that code. A refused
build blocks before launch. Dispatch one subagent whose whole brief is Step 7's packet brief
with this packet's `read=` ranges; retain the child id. It proposes edits as findings and
never writes a doc.
````text
You are the /ship documentation auditor (Step 14.5). Read-only: edit no file, run no command, never commit, push or start another workflow. The parent applies your proposed edits mechanically and owns every decision you return. Audit id: <audit-id>. Mode: <mode>.
Checklist, against the diff below and the docs it touches or names:
1. Coverage map: each new, changed, renamed or removed public surface (commands, flags, config keys, environment variables, endpoints, skills, exported names) and where the docs cover it (reference, how-to, tutorial, explanation). Zero coverage is a critical gap, reference-only a common gap; report gaps as debt, never write new pages.
2. Per file: README (features, install, examples, troubleshooting); ARCHITECTURE (diagrams and components match the code; be conservative); CONTRIBUTING (would each setup command still succeed for a new contributor); CLAUDE.md/AGENTS.md (structure, commands, build and test instructions); every other listed doc: does a line contradict the diff?
3. Cross-doc: README and project instructions agree; every doc is reachable from README.md or CLAUDE.md/AGENTS.md; diagram entities the diff renamed, split, moved or removed.
4. Classify each needed change: `edit` for a factual correction the diff clearly warrants (a table row, a path, a count, a flag name, a structure tree); `block` for narrative or positioning text, a removal, security-model text, more than ~10 lines in one section, a new section, uncertain relevance, a generated file (name its template), CHANGELOG, TODOS, VERSION, package or lock files, or any correction in read-only mode; `note` for debt and observations.
Reads: this packet in its read= ranges, then at most one turn of named line ranges (200 lines or fewer) of the listed docs and the code they describe. No repository search, no other packet, no SKILL.md.
Write to the output path in one write: one JSON object per finding, one per line:
{"path":"<repo-relative doc>","line":<n>,"action":"edit|block|note","kind":"stale|missing|contradiction|discoverability|coverage|diagram","summary":"<one line>","old":"<exact current text, once in that file>","new":"<replacement>"}
`old` and `new` only on `edit`. Then the LAST line, one JSON object:
{"schema_version":1,"audit_id":"<audit-id>","files_reviewed":["<docs you actually read>"],"documentation_section":"<Markdown: audited scope, per-file status in Documentation health form, coverage debt, diagram drift; no ## Documentation heading>"}
````
**Elsewhere:** Dispatch /document-release as a subagent with the Agent tool (never Skill),
`subagent_type: "general-purpose"`.
{{FOREGROUND_DISPATCH_NOTE}} Retain the child id.
@@ -64,9 +89,13 @@ Reentry never resets the count or authorizes a launch.
1. **Collect.** Inspect the child handle for terminal completion and final output
within ~10 minutes; launch metadata is not completion. On failure/deadline,
use recovery before another writer.
use recovery before another writer. A packet reader's result is its `out=` file:
run `~/.claude/skills/gstack/bin/gstack-docs-candidate apply <candidate> --findings <out>`.
It makes each `edit` whose `old` text occurs once in a candidate doc (never a protected or
generated file, none in read-only mode), turns `block` findings and unappliable edits into
blockers, and prints the audit result on its LAST line; that line is the output below.
2. **Check output.** Parse only the LAST nonempty line. Require every field/type,
exact audit id, schema, status invariant and actual spawned marker above, as
exact audit id, schema, status invariant and, from a /document-release child, the actual spawned marker above, as
echoed in the child output; state files are not evidence of it.
Never default or reconstruct missing values.
3. **Check ownership.** Run `gstack-docs-candidate compare <candidate>`.
+174
View File
@@ -0,0 +1,174 @@
/**
* Step 14.5 by packet (GSTA-167 PR 4): gstack-review-packet build --audit docs writes one
* read-only auditor packet over a gstack-docs-candidate snapshot, and
* gstack-docs-candidate apply makes the reader's proposed edits. Fixture repositories
* live under the OS temp directory, outside this checkout, with an explicit seed identity.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { auditPrompt } from '../lib/review-packet';
import { DOCS_NAMED_LINES } from '../lib/review-packet-docs';
const ROOT = path.join(import.meta.dir, '..');
const PACKET = path.join(ROOT, 'bin', 'gstack-review-packet');
const CANDIDATE = path.join(ROOT, 'bin', 'gstack-docs-candidate');
const dirs: string[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
const tmp = (p: string) => { const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), p))); dirs.push(d); return d; };
interface Fx { dir: string; home: string; out: string; run(bin: string, args: string[]): ReturnType<typeof spawnSync>; candidate(mode?: string, docs?: string[]): string }
function fixture(base: Record<string, string>, feat: Record<string, string>): Fx {
const dir = tmp('docs-packet-');
expect(path.relative(ROOT, dir).startsWith('..')).toBe(true);
const home = tmp('docs-packet-home-');
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 });
const write = (files: Record<string, string>) => { for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); } };
git('init', '-q', '-b', 'main'); git('config', 'user.email', 'fixture@example.com'); git('config', 'user.name', 'Fixture'); git('config', 'commit.gpgsign', 'false');
write(base); git('add', '.');
expect(git('commit', '-qm', 'base').status).toBe(0);
git('checkout', '-qb', 'feat'); write(feat); git('add', '.');
expect(git('commit', '-qm', 'feat').status).toBe(0);
const run = (bin: string, args: string[]) => spawnSync(bin, args, { cwd: dir, encoding: 'utf8', timeout: 120_000, env: { ...process.env, GSTACK_STATE_ROOT: path.join(home, 'state') } });
return { dir, home, out: path.join(home, 'packets'), run, candidate: (mode = 'edit', docs = ['README.md', 'docs']) => {
const file = path.join(home, `cand-${mode}.json`);
const r = run(CANDIDATE, ['snapshot', '--out', file, '--audit-id', 'audit-7', '--mode', mode, '--base', 'main', ...docs.flatMap(d => ['--docs', d])]);
expect(r.status).toBe(0);
return file;
} };
}
const packetOf = (stdout: string) => { const m = /^PACKET: audit docs .* read=(\S+) file=(\S+) out=(\S+)$/m.exec(stdout); return m ? { read: m[1]!, file: m[2]!, out: m[3]!, text: fs.readFileSync(m[2]!, 'utf8') } : null; };
const BASE = {
'README.md': '# Tool\n\nRun `bin/tool-run --fast` to start.\n\nUser-maintained note: KEEP.\n',
'docs/guide.md': '# Guide\n\nThe parser lives in src/parser.ts.\n',
'docs/other.md': '# Other\n\nUnrelated page.\n',
'src/parser.ts': 'export const parse = 1;\n',
'src/unrelated.ts': 'export const UNRELATED = 1;\n',
'bin/tool-run': '#!/bin/sh\necho fast\n',
};
describe('build --audit docs', () => {
// Value: protects=the auditor reads one packet holding its prompt, the doc surfaces, the doc lines naming changed code and only that code's diff; fails_when=the packet drops the prompt or a doc line, or ships the diff of code no doc names past the cap, or the test diff;
// why_new=the Step 14.5 packet is new; seam=gstack-docs-candidate snapshot; no_claim=what a model does with the packet
test('one read-only packet: prompt with the audit id and mode, doc surfaces, doc lines naming changed code, and that diff', () => {
const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 2;\nexport const PARSER_CHANGE = 1;\n', 'docs/guide.md': '# Guide\n\nThe parser lives in src/parser.ts. GUIDE_EDIT\n', 'test/parser.test.ts': 'TEST_PAYLOAD\n' });
const b = f.run(PACKET, ['build', '--audit', 'docs', '--candidate', f.candidate(), '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]);
expect(b.status).toBe(0);
const p = packetOf(b.stdout)!;
expect(b.stdout).toMatch(/^BUDGET_NEXT: dispatch:specialist$/m);
expect(p.text).toContain('You are the /ship documentation auditor (Step 14.5). Read-only');
expect(p.text).toContain('Audit id: audit-7. Mode: edit.');
expect(p.text).not.toContain('<audit-id>');
expect(p.text).toContain('- docs/guide.md (changed)');
expect(p.text).toContain('- docs/other.md\n');
expect(p.text).toContain('docs/guide.md:3: The parser lives in src/parser.ts.');
expect(p.text).toContain('+export const PARSER_CHANGE = 1;');
expect(p.text).toContain('GUIDE_EDIT');
expect(p.text).not.toContain('+TEST_PAYLOAD');
expect(p.text).toContain(`Output path: \`${p.out}\``);
expect(p.text.trimEnd().endsWith('END OF PACKET docs')).toBe(true);
});
// Value: protects=the build refuses without a usable candidate and the audit prompt comes only from the section fence; fails_when=a build without --candidate writes a packet, or a section without its fence yields an empty prompt;
// why_new=--candidate is new; seam=none; no_claim=the parent's recovery prose
test('a missing or unusable candidate is a usage refusal with no packet; a section without its prompt fence throws', () => {
const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 3;\n' });
const none = f.run(PACKET, ['build', '--audit', 'docs', '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]);
expect(none.status).toBe(2);
expect(none.stderr).toContain('--audit docs needs --candidate');
const bad = path.join(f.home, 'bad.json');
fs.writeFileSync(bad, '{"schema_version":1}');
expect(f.run(PACKET, ['build', '--audit', 'docs', '--candidate', bad, '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]).status).toBe(2);
expect(fs.existsSync(path.join(f.out, 'docs.packet.md'))).toBe(false);
const skill = tmp('docs-skill-');
fs.mkdirSync(path.join(skill, 'ship', 'sections'), { recursive: true });
fs.writeFileSync(path.join(skill, 'ship', 'sections', 'documentation.md'), '# no fence\n');
expect(() => auditPrompt(skill, 'docs', 'main')).toThrow('ship/sections/documentation.md has no prompt fence');
expect(auditPrompt(ROOT, 'docs', 'main')).toContain('"action":"edit|block|note"');
});
// Value: protects=the quoted doc lines stay bounded however many lines name a changed path; fails_when=the cap is dropped and a large doc set floods the packet;
// why_new=the cap is new; seam=none; no_claim=which lines are most useful
test('doc lines naming changed code are capped and counted', () => {
const many = Array.from({ length: DOCS_NAMED_LINES + 30 }, (_, i) => `line ${i} uses src/parser.ts`).join('\n') + '\n';
const f = fixture({ ...BASE, 'docs/big.md': many }, { 'src/parser.ts': 'export const parse = 4;\n' });
const p = packetOf(f.run(PACKET, ['build', '--audit', 'docs', '--candidate', f.candidate(), '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]).stdout)!;
expect(p.text).toContain(`(${DOCS_NAMED_LINES + 31}, first ${DOCS_NAMED_LINES})`);
});
});
describe('gstack-docs-candidate apply', () => {
const findings = (rows: object[], last: object = { schema_version: 1, audit_id: 'audit-7', files_reviewed: ['README.md', 'docs/guide.md'], documentation_section: 'Scope: README and docs/.' }) =>
[...rows, last].map(r => JSON.stringify(r)).join('\n') + '\n';
const lastJson = (stdout: string) => JSON.parse(stdout.trimEnd().split('\n').at(-1)!);
// Value: protects=apply makes exactly the unique edits in candidate docs and reports the rest as blockers in the child result schema, and the existing ownership check sees only those paths; fails_when=an ambiguous, protected, generated, outside or uncandidated edit is written, or a block finding is dropped;
// why_new=apply is new; seam=gstack-docs-candidate compare; no_claim=whether a proposed edit is true
test('applies unique edits to candidate docs; protected, generated, ambiguous, uncandidated and block findings become blockers', () => {
const f = fixture({ ...BASE, 'CHANGELOG.md': '# Changelog\n', 'docs/gen.md': '<!-- AUTO-GENERATED from gen.md.tmpl -->\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');
});
});