From ee7b341e5da53ecd4784b6301e0596f60da97028 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 13 Oct 2026 01:10:13 +0000 Subject: [PATCH] autoplan: run-state fields, plan-text hashing, version migration and admission modules RunState gains gstack_version, plan_input/heading, scope_flags, plan_revision, amendments and plan_reality; voices record receipt kind and reviewed hash. New pure modules: Implementation-plan review hash, scaffold and drift lines (B1c); gstack_version stamp and legacy decision-id migration (B1a); install-verdict and Bun-minimum admission with a local verdict shim until lane A's lib/install-verdict.ts lands. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> bin/gstack-autoplan-snapshot.ts exports its section scanner and review projection, and initializes only a plan with no section headings by wrapping it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-autoplan-snapshot.ts | 14 ++++-- lib/autoplan-admission.ts | 70 ++++++++++++++++++++++++++++ lib/autoplan-migrate.ts | 59 ++++++++++++++++++++++++ lib/autoplan-plan-text.ts | 81 +++++++++++++++++++++++++++++++++ lib/autoplan-state.ts | 21 ++++++++- 5 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 lib/autoplan-admission.ts create mode 100644 lib/autoplan-migrate.ts create mode 100644 lib/autoplan-plan-text.ts diff --git a/bin/gstack-autoplan-snapshot.ts b/bin/gstack-autoplan-snapshot.ts index 9de6d55b9..61f7d7050 100644 --- a/bin/gstack-autoplan-snapshot.ts +++ b/bin/gstack-autoplan-snapshot.ts @@ -66,7 +66,8 @@ reviewing this plan. You have NOT seen any prior review. Evaluate: For each finding: what's wrong, severity, and the fix.` }; -function implementationBounds(plan: string) { +/** The section headings outside code fences, in order (the runner's PLAN_SECTIONS_INVALID diagnosis reads these). */ +export function sectionHeadings(plan: string) { const boundaries: Array<{ name: string; start: number; end: number }> = []; let offset = 0; let fence: { char: string; length: number } | null = null; @@ -86,6 +87,11 @@ function implementationBounds(plan: string) { } offset += raw.length; } + return boundaries; +} + +function implementationBounds(plan: string) { + const boundaries = sectionHeadings(plan); if (boundaries.length !== 2 || boundaries[0]!.name !== 'Implementation plan' || boundaries[1]!.name !== 'Review record') { throw new Error('Expected one Implementation plan section followed by one Review record section outside Markdown code/quotes'); } @@ -149,7 +155,7 @@ export function acceptedBlocks(text: string): Map { // Marker lines belong to the author's retention record, not blind review data. // Keep every requirement-body byte, including line endings and literal examples. -function implementationForReview(source: string): string { +export function implementationForReview(source: string): string { let result = ''; let offset = 0; for (const block of acceptedBlocks(source).values()) { if (block.none) throw new Error('No-change record does not belong in Implementation plan'); @@ -411,7 +417,9 @@ export function initializePlan(sourcePlan: string, activePlan: string, restorePa if (!text.trim() || !Buffer.from(text).equals(original)) throw new Error('Initialization source must be nonempty UTF-8 text'); let plan = text; try { extractImplementationPlan(plan); } - catch { + catch (error) { + // Only a plan with no section headings is wrapped; a present-but-wrong or empty section reports its own error. + if (sectionHeadings(text).length) throw error; plan = `## Implementation plan\n${text}${text.endsWith('\n') ? '' : '\n'}## Review record\n`; // Partial/duplicate boundaries and unclosed fences remain errors, not raw-plan fallbacks. extractImplementationPlan(plan); diff --git a/lib/autoplan-admission.ts b/lib/autoplan-admission.ts new file mode 100644 index 000000000..746260a19 --- /dev/null +++ b/lib/autoplan-admission.ts @@ -0,0 +1,70 @@ +/** + * autoplan-admission — who may run `gstack-autoplan`'s mutating verbs (plan + * Lane A amendments as corrected by the amendment verification). On a Capy + * host (GSTACK_CAPY=1) the install verdict the Capy wrapper writes must say + * `gstack: ok` for this checkout and HEAD; any unattended or Capy session + * also needs the PATH Bun at gstack's supported minimum (package.json + * engines.bun). GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits anyway and the + * caller records the returned deviation. Interactive sessions are not gated. + * + * Integration seam: `readInstallVerdict` below is a local shim of lane A's + * lib/install-verdict.ts. Replace its body with an import of A's reader; the + * rest of this module only needs { trailer, head, checkout } back. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { RunError } from './autoplan-state'; +import { satisfies } from './runtime-pins'; +import { resolveStateRoot } from './state-root'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +export interface InstallVerdict { trailer: string; head?: string; checkout?: string } + +/** Shim of lane A's reader: JSON, or `key: value` / `key=value` lines. Undefined when the file is absent or unreadable. */ +export function readInstallVerdict(file: string): InstallVerdict | undefined { + let text: string; + try { text = fs.readFileSync(file, 'utf8'); } catch { return undefined; } + let fields: Record = {}; + try { fields = JSON.parse(text); } catch { + for (const line of text.split('\n')) { const m = /^\s*([a-z_]+)\s*[:=]\s*(.*)$/.exec(line); if (m) fields[m[1]!] = m[2]!.trim(); } + } + return typeof fields.trailer === 'string' ? { trailer: fields.trailer, head: fields.head, checkout: fields.checkout } : undefined; +} + +function verdictProblem(env: NodeJS.ProcessEnv): string | undefined { + const file = env.GSTACK_INSTALL_VERDICT || path.join(resolveStateRoot(env), 'install-verdict'); + const v = readInstallVerdict(file); + if (!v) return `install verdict ${file} not found or unreadable`; + if (!/^gstack: ok\b/.test(v.trailer)) return `install verdict says ${JSON.stringify(v.trailer)}`; + const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim(); + if (v.head && head && v.head !== head) return `install verdict is for another HEAD (${v.head.slice(0, 12)}, this checkout is at ${head.slice(0, 12)})`; + if (v.checkout && v.checkout !== fs.realpathSync(ROOT)) return `install verdict is for another checkout (${v.checkout})`; + return undefined; +} + +function bunProblem(env: NodeJS.ProcessEnv): string | undefined { + const minimum = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).engines?.bun as string | undefined; + if (!minimum) return undefined; + const r = spawnSync('bun', ['--version'], { encoding: 'utf8', timeout: 30_000, env }); + const version = r.stdout?.trim(); + if (!version) return 'no bun on PATH'; + return satisfies(version, minimum) === false ? `PATH runs bun ${version}, below gstack's supported minimum ${minimum}` : undefined; +} + +export interface Admission { lines: string[]; deviation?: string } + +/** Throws RunError (exit 3) unless the session may write; an override returns the line to print and the deviation to record. */ +export function admit(env: NodeJS.ProcessEnv = process.env): Admission { + const capy = env.GSTACK_CAPY === '1'; + if (!capy && env.GSTACK_SESSION_KIND !== 'unattended') return { lines: [] }; + const verdict = capy ? verdictProblem(env) : undefined; + const bun = bunProblem(env); + const problem = verdict ?? bun; + if (!problem) return { lines: [] }; + if (env.GSTACK_ALLOW_UNSUPPORTED_RUNTIME === '1') { + return { lines: [`ADMISSION: override GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 (${problem})`], deviation: `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1: admitted despite ${problem}` }; + } + throw new RunError(verdict ? 'INSTALL_VERDICT_FAILED' : 'RUNTIME_BELOW_MINIMUM', problem, 3); +} diff --git a/lib/autoplan-migrate.ts b/lib/autoplan-migrate.ts new file mode 100644 index 000000000..a01cb34d0 --- /dev/null +++ b/lib/autoplan-migrate.ts @@ -0,0 +1,59 @@ +/** + * autoplan-migrate — forward migrations a run directory gets when a newer + * gstack opens it (plan B1a amendment). Called under the run lock before + * every mutating verb: `run.json.gstack_version` older than this gstack (or + * absent, a run written before 1.91.96) prints GSTACK_VERSION_CHANGED once + * and journals `gstack_version_changed`; a run written by a newer gstack + * refuses. Decision ids minted before 1.91.95 (`-