Files
gstack/lib/autoplan-public.ts
T
Garry Tanandcapy-ai[bot] c71d764ba4 v1.91.115.0 feat: field list 2 wave A: autoplan round cap and public export, eval-plan diagnostics and model rules, --for review-voice (#3234)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(lease): name the double-crash limit of breaking a dead lock

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave A

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.115.0

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>
2026-10-17 02:40:45 +00:00

176 lines
12 KiB
TypeScript

/**
* autoplan-public — the machine-path-free public copy of a run (field list 2,
* W6). `gstack-autoplan export --out <run> --public <dir>` copies the run's
* listed artifacts and run.json into <dir> with only the run's recorded
* machine roots rewritten (the run directory, the home directory, the plan
* input's directory, the checkpoint directory, the worktree root): a path in
* the worktree becomes repo-relative, any other becomes
* `<local notes (not committed)>`. Inline code and fences in plan.md are
* never rewritten. The private run is only read, so resume and its bindings
* are unchanged. Every copied file gets its own sha256, and run.json's
* `provenance` names the private digest it was derived from.
* `validatePublic` is the check behind `gstack-artifact validate --public
* <dir> [--private <run.json>]`: with the private manifest (on the exporting
* machine) any recorded root is an ARTIFACT_MACHINE_PATH error with
* file:line; without it the provenance digests are checked; both WARN with
* file:line on generic home-path patterns. Export runs the private check on a
* staging copy before anything reaches <dir>.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { repoRoot } from './autoplan-plan-text';
import { checkpointRoot } from './autoplan-checkpoint';
import { FILES, RunError, type RunState } from './autoplan-state';
import { sha256File, validateRun, type ValidationError } from './headless-artifacts';
export const LOCAL_NOTES = '<local notes (not committed)>';
const GENERIC_HOME = /(?:\/home\/[A-Za-z0-9._-]+\/|\/Users\/[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\[^\\\s]+\\)/;
export interface RecordedRoot { label: string; root: string }
interface Provenance { private_run: string; private_manifest_sha256: string; exported_at: string; roots: string[]; files: Record<string, { private_sha256: string; public_sha256: string }> }
/** The machine roots a run recorded on this machine, longest first; the worktree root is labelled for the repo-relative rewrite. */
export function recordedRoots(state: Pick<RunState, 'out' | 'plan_input' | 'plan_source'>, env: NodeJS.ProcessEnv = process.env): RecordedRoot[] {
const input = state.plan_input ?? state.plan_source;
const worktree = input ? repoRoot(path.dirname(input)) : undefined;
const candidates: RecordedRoot[] = [
{ label: 'run', root: state.out }, { label: 'home', root: os.homedir() },
...(input ? [{ label: 'plan_input', root: path.dirname(input) }] : []),
...(checkpointRoot(env) ? [{ label: 'checkpoint', root: checkpointRoot(env)! }] : []),
...(worktree ? [{ label: 'worktree', root: worktree }] : []),
];
const seen = new Set<string>();
return candidates.map(c => ({ ...c, root: path.resolve(c.root) })).filter(c => c.root !== path.parse(c.root).root && !seen.has(c.root) && seen.add(c.root))
.sort((a, b) => b.root.length - a.root.length);
}
/** Rewrite every recorded root in `text`: worktree paths become repo-relative, the rest `<local notes (not committed)>`. */
export function rewriteRoots(text: string, roots: RecordedRoot[]): string {
const worktree = roots.find(r => r.label === 'worktree')?.root;
const inWorktree = (p: string) => !!worktree && (p === worktree || p.startsWith(worktree + path.sep));
let out = text;
for (const r of roots) {
if (r.label !== 'worktree' && inWorktree(r.root)) continue;
const local = r.label !== 'worktree';
out = out.split(r.root + path.sep).join(local ? `${LOCAL_NOTES}/` : '').split(r.root).join(local ? LOCAL_NOTES : '.');
}
return out;
}
/** Per line of a Markdown text: inside a fenced block (or a fence marker itself), or not. */
function fencedLines(text: string): boolean[] {
let fence: string | null = null;
return text.split('\n').map(line => {
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
if (marker && (fence === null || marker.startsWith(fence))) { fence = fence === null ? marker[0]!.repeat(marker.length) : null; return true; }
return fence !== null;
});
}
/** plan.md: rewrite prose only; fenced blocks and inline code spans keep their bytes. */
function rewriteProse(text: string, roots: RecordedRoot[]): string {
const fenced = fencedLines(text);
return text.split('\n').map((line, i) => (fenced[i] ? line
: line.split(/(`[^`]*`)/).map(part => (part.startsWith('`') && part.endsWith('`') && part.length > 1 ? part : rewriteRoots(part, roots))).join(''))).join('\n');
}
function rewriteJson(value: unknown, roots: RecordedRoot[]): unknown {
if (typeof value === 'string') return rewriteRoots(value, roots);
if (Array.isArray(value)) return value.map(v => rewriteJson(v, roots));
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [rewriteRoots(k, roots), rewriteJson(v, roots)]));
return value;
}
/** Every recorded-root hit (error) and generic home-path hit (warning) in the files under `dir`, with file:line. */
function scan(dir: string, roots: RecordedRoot[]): { errors: ValidationError[]; warnings: string[] } {
const errors: ValidationError[] = [], warnings: string[] = [];
const walk = (rel: string): string[] => fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => (e.isDirectory() ? walk(path.join(rel, e.name)) : e.isFile() ? [path.join(rel, e.name)] : []));
for (const rel of walk('')) {
const text = fs.readFileSync(path.join(dir, rel), 'utf8');
const fenced = rel === 'plan.md' ? fencedLines(text) : [];
text.split('\n').forEach((line, i) => {
const hit = roots.find(r => line.includes(r.root));
const code = rel === 'plan.md' && (fenced[i] || /`/.test(line));
if (hit) errors.push({ code: 'ARTIFACT_MACHINE_PATH', path: `${rel}:${i + 1}`, message: `the recorded ${hit.label} root ${hit.root} appears in the public copy${code ? ' (inside code, which export never rewrites: edit the plan)' : ''}` });
else if (GENERIC_HOME.test(line)) warnings.push(`WARN: ${rel}:${i + 1} has a home-directory path (${GENERIC_HOME.exec(line)![0]}); check it is not a machine path`);
});
}
return { errors, warnings };
}
export interface PublicValidation { valid: boolean; errors: ValidationError[]; warnings: string[]; run?: string }
/** `gstack-artifact validate --public <dir> [--private <run.json>]`. */
export function validatePublic(dir: string, privateRunJson?: string, env: NodeJS.ProcessEnv = process.env): PublicValidation {
const runJson = path.join(dir, FILES.run);
const structural = validateRun(runJson);
const errors = [...structural.errors];
const manifest = structural.manifest as (RunState & { provenance?: Provenance }) | undefined;
const prov = manifest?.provenance;
if (manifest && !prov) errors.push({ code: 'ARTIFACT_SCHEMA', path: 'run.json.provenance', message: 'a public copy names the private digests it came from; this run.json has no provenance (write it with gstack-autoplan export --public)' });
for (const [name, d] of Object.entries(prov?.files ?? {})) {
const file = path.join(dir, name);
if (!fs.existsSync(file)) errors.push({ code: 'ARTIFACT_MISSING', path: name, message: 'listed in provenance, absent from the public copy' });
else if (sha256File(file) !== d.public_sha256) errors.push({ code: 'ARTIFACT_STALE', path: name, message: `sha256 differs from provenance public_sha256 ${d.public_sha256.slice(0, 12)}` });
}
let roots: RecordedRoot[] = [];
if (privateRunJson) {
const priv = JSON.parse(fs.readFileSync(privateRunJson, 'utf8')) as RunState;
priv.out = path.dirname(path.resolve(privateRunJson));
roots = recordedRoots(priv, env);
if (prov && prov.private_run !== priv.run) errors.push({ code: 'ARTIFACT_UNBOUND_ID', path: 'run.json.provenance.private_run', message: `${prov.private_run} is not the private run ${priv.run}` });
for (const [name, d] of Object.entries(prov?.files ?? {})) {
const listed = name === FILES.run ? sha256File(privateRunJson) : priv.artifacts?.[name]?.sha256;
if (listed !== d.private_sha256) errors.push({ code: 'ARTIFACT_STALE', path: name, message: `provenance private_sha256 ${d.private_sha256.slice(0, 12)} is not the private run's ${(listed ?? 'missing').slice(0, 12)}` });
}
}
const s = scan(dir, roots);
errors.push(...s.errors);
return { valid: errors.length === 0, errors, warnings: s.warnings, run: manifest?.run };
}
/** Write the public copy of `state` to `dir` without touching the private run; refuses on any recorded root left behind. */
export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEnv = process.env): { files: string[]; warnings: string[] } {
const target = path.resolve(dir);
const out = path.resolve(state.out);
if (target === out || target.startsWith(out + path.sep) || out.startsWith(target + path.sep)) throw new RunError('ARTIFACT_PATH_ESCAPE', `--public ${target} overlaps the run directory ${out}`, 3);
if (fs.existsSync(target) && fs.readdirSync(target).length) {
let previous: string | undefined;
try { previous = JSON.parse(fs.readFileSync(path.join(target, FILES.run), 'utf8')).provenance?.private_run; } catch { /* not a public copy */ }
if (previous !== state.run) throw new RunError('ARTIFACT_PUBLIC_TARGET_EXISTS', `${target} is not empty and holds no earlier public copy of ${state.run}`, 3);
}
const roots = recordedRoots(state, env);
const privateRun = path.join(out, FILES.run);
const staging = `${target}.public-${process.pid}`;
fs.rmSync(staging, { recursive: true, force: true });
try {
const files: Provenance['files'] = {};
for (const [name, entry] of Object.entries(state.artifacts)) {
const src = path.resolve(out, entry.path);
const dest = path.resolve(staging, entry.path);
if (!src.startsWith(out + path.sep) || !dest.startsWith(staging + path.sep)) throw new RunError('ARTIFACT_PATH_ESCAPE', `artifact ${name} path ${JSON.stringify(entry.path)} leaves the run directory; nothing was written`, 3);
if (!fs.existsSync(src) || sha256File(src) !== entry.sha256) throw new RunError('ARTIFACT_STALE', `${entry.path} does not match run.json; run gstack-autoplan export --out ${out} first`, 3);
const text = fs.readFileSync(src, 'utf8');
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, name === 'plan.md' ? rewriteProse(text, roots) : entry.path.endsWith('.json') ? JSON.stringify(rewriteJson(JSON.parse(text), roots), null, 2) + '\n' : rewriteRoots(text, roots));
files[name] = { private_sha256: entry.sha256, public_sha256: sha256File(dest) };
}
const manifest = rewriteJson(JSON.parse(fs.readFileSync(privateRun, 'utf8')), roots) as RunState & { provenance?: Provenance };
for (const [name, d] of Object.entries(files)) manifest.artifacts[name] = { path: state.artifacts[name]!.path, sha256: d.public_sha256 };
manifest.provenance = { private_run: state.run, private_manifest_sha256: sha256File(privateRun), exported_at: new Date().toISOString(), roots: roots.map(r => r.label), files };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify(manifest, null, 2) + '\n');
const check = validatePublic(staging, privateRun, env);
const escapes = check.errors.filter(e => e.code === 'ARTIFACT_PATH_ESCAPE');
if (escapes.length) throw new RunError('ARTIFACT_PATH_ESCAPE', escapes.map(e => `${e.path}: ${e.message}`).join('; '), 3);
const leaks = check.errors.filter(e => e.code === 'ARTIFACT_MACHINE_PATH');
if (leaks.length) throw new RunError('ARTIFACT_MACHINE_PATH', leaks.slice(0, 5).map(e => `${e.path}: ${e.message}`).join('; ') + (leaks.length > 5 ? ` (+${leaks.length - 5} more)` : ''), 3);
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.renameSync(staging, target);
return { files: [...Object.values(state.artifacts).map(e => path.join(target, e.path)), path.join(target, FILES.run)], warnings: check.warnings };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}