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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-13 01:10:13 +00:00
1 parent 4128ba751b
commit ee7b341e5d
5 files changed
+241 -4

No files matched your search

+11 -3
View File
@@ -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<string, AcceptedBlock> {
// 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);
+70
View File
@@ -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<string, string> = {};
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);
}
+59
View File
@@ -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 (`<run>-<label>` at gate_rev > 1,
* which collide with gate_rev 1) are rewritten through `decisionId` once,
* journaled as `decision_ids_migrated`. Safe: such runs never validated and
* `gstack-artifact ack` refuses invalid runs, so no consumer holds the old ids.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { decisionId } from './gate-list';
import { RunError, journal, readDecisions, writeDecisions, type RunState } from './autoplan-state';
export const GSTACK_VERSION = fs.readFileSync(path.join(import.meta.dir, '..', 'VERSION'), 'utf8').trim();
/** Numeric compare of dotted versions (1.91.95.0); missing parts are 0. */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d) return Math.sign(d);
}
return 0;
}
/** Rewrite `<run>-<label>` ids at gate_rev > 1 to `<run>-r<rev>-<label>`; returns how many rows changed. Idempotent. */
export function migrateLegacyDecisionIds(state: RunState): number {
const rows = readDecisions(state.out);
let n = 0;
const next = rows.map(row => {
if (row.gate_rev <= 1 || !row.label || row.id !== `${state.run}-${row.label.toLowerCase()}`) return row;
n++;
return { ...row, id: decisionId(state.run, row.gate_rev, row.label.toLowerCase()) };
});
if (!n) return 0;
writeDecisions(state.out, next);
journal(state.out, { event: 'decision_ids_migrated', n });
(state.deviations ??= []).push(`legacy decision ids rewritten (${n})`);
return n;
}
/** Bring a run directory forward to this gstack; returns the notice lines and whether run.json must be written. */
export function migrateRun(state: RunState): { lines: string[]; changed: boolean } {
const from = state.gstack_version;
if (from && compareVersions(from, GSTACK_VERSION) > 0) {
throw new RunError('GSTACK_VERSION_CHANGED', `run ${state.run} was written by gstack ${from}; this is ${GSTACK_VERSION}, which cannot read newer state`, 3);
}
const lines: string[] = [];
const migrated = migrateLegacyDecisionIds(state);
if (from === GSTACK_VERSION) return { lines, changed: migrated > 0 };
const migrations = migrated ? [`decision ids rewritten (${migrated})`] : [];
journal(state.out, { event: 'gstack_version_changed', from: from ?? null, to: GSTACK_VERSION, migrations });
lines.push(`GSTACK_VERSION_CHANGED: run ${from ?? 'unknown'} -> ${GSTACK_VERSION}; forward migrations: ${migrations.length ? migrations.join(', ') : 'none needed'}`);
state.gstack_version = GSTACK_VERSION;
return { lines, changed: true };
}
+81
View File
@@ -0,0 +1,81 @@
/**
* autoplan-plan-text — what the runner knows about a plan file's text (plan
* B1c): the reviewed hash (the sha256 `create` puts in a phase's snapshot:
* the Implementation plan after the snapshot tool's own review projection),
* the section diagnosis behind PLAN_SECTIONS_INVALID, the scaffold that
* `next --scaffold` reviews instead of refusing, and the normalization
* `revise` applies to a revised plan the way `init` does. The section scanner
* and projection are the snapshot tool's own exports, so drift is measured on
* exactly the bytes reviewers saw.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import { extractImplementationPlan, implementationForReview, sectionHeadings } from '../bin/gstack-autoplan-snapshot';
import { extractHeading } from './autoplan-prompts';
import type { RunState } from './autoplan-state';
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
/** A plan with no section headings is reviewed whole, exactly as `init` wraps it. */
export function wrapBare(text: string): string {
return sectionHeadings(text).length ? text : `## Implementation plan\n${text}${text.endsWith('\n') ? '' : '\n'}## Review record\n`;
}
/** The reviewed hash of a plan's text; throws when its sections are invalid. */
export function reviewHash(text: string): string {
return sha256(implementationForReview(extractImplementationPlan(wrapBare(text))));
}
/** The reviewed hash of a plan file (cut to `heading` first), or undefined when it is unreadable or invalid. */
export function reviewHashOfFile(file: string | undefined, heading?: string): string | undefined {
if (!file || !fs.existsSync(file)) return undefined;
try {
const text = fs.readFileSync(file, 'utf8');
const section = heading ? extractHeading(text, heading) : text;
return section === undefined ? undefined : reviewHash(scaffold(section).text ?? section);
} catch { return undefined; }
}
export interface SectionDiagnosis { problem?: string; text?: string; added?: string }
/**
* Which section problem a plan has, and the scaffold when one heading alone
* is missing. Duplicate, reversed or empty sections are ambiguous: the
* boundary between reviewed text and the private Review record is never guessed.
*/
export function scaffold(text: string): SectionDiagnosis {
const heads = sectionHeadings(text);
const names = heads.map(h => h.name).join(',');
if (!heads.length) return {};
if (names === 'Implementation plan,Review record') {
try { extractImplementationPlan(text); return {}; } catch (e: any) { return { problem: String(e.message) }; }
}
if (names === 'Implementation plan') {
return { problem: 'has "## Implementation plan" but no "## Review record"', text: `${text}${text.endsWith('\n') ? '' : '\n'}\n## Review record\n`, added: '## Review record' };
}
if (names === 'Review record') {
const before = text.slice(0, heads[0]!.start);
const title = /^(?:\s*\n|#[ \t][^\n]*\n)*/.exec(before)![0];
if (!before.slice(title.length).trim()) return { problem: 'has "## Review record" but no plan text before it' };
return { problem: 'has "## Review record" but no "## Implementation plan"', text: `${title}## Implementation plan\n${text.slice(title.length)}`, added: '## Implementation plan' };
}
return { problem: `ambiguous sections (${heads.map(h => `## ${h.name}`).join(', ')}): duplicate or reversed headings` };
}
/** The active-plan bytes for a revised plan: the restore-point comment `init` writes, then the (wrapped) plan. */
export function normalizeRevision(text: string, restorePath: string): string {
const reference = JSON.stringify(restorePath).replace(/--/g, '\\u002d\\u002d');
const body = wrapBare(text);
extractImplementationPlan(body);
return `<!-- /autoplan restore point: ${reference} -->\n${body}`;
}
/** `<code>: <file> differs from the reviewed plan; run gstack-autoplan revise …` when the owner's plan file moved past the active plan. */
export function driftLine(state: RunState, code: 'PLAN_CHANGED' | 'PLAN_SOURCE_CHANGED'): string | undefined {
const source = state.plan_input ?? state.plan_source;
const current = reviewHashOfFile(source, state.plan_input ? state.plan_heading : undefined);
let reviewed: string;
try { reviewed = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); } catch { return undefined; }
if (!current || current === reviewed) return undefined;
return `${code}: ${source} differs from the reviewed plan; run gstack-autoplan revise --out ${state.out} --plan ${source}`;
}
+20 -1
View File
@@ -14,7 +14,13 @@ import type { ResultCodeName } from './result-codes';
export type Voice = 'native' | 'outside';
export interface AttemptState { attempt: string; state: 'dispatched' | 'submitted' | 'execution_unknown' | 'cancelled' | 'superseded'; prompt: string; result: string; estimate_usd: number; dispatched_at: string; terminal_at?: string }
export interface VoiceState extends VoiceOutcome { attempt?: string; attempts?: AttemptState[]; result_sha256?: string; findings?: number; label?: string; usage?: Record<string, unknown>; detail?: string }
export interface VoiceState extends VoiceOutcome {
attempt?: string; attempts?: AttemptState[]; result_sha256?: string; findings?: number; label?: string; usage?: Record<string, unknown>; detail?: string;
/** verified: the INPUT receipt matched the snapshot; attested: a --record bound without one, on the parent's word (attest_reason). */
receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string;
}
/** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */
export interface Amendment { decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn'; at: string; settled_at?: string }
export interface PhaseState {
snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string;
native?: VoiceState; outside?: VoiceState; consensus?: Record<string, unknown>; skipped?: string;
@@ -25,7 +31,20 @@ export interface RunState extends RunManifest {
revision: number; out: string; plan_source: string; active_plan: string; restore: string; scope: Scope; phase_order: Phase[];
phases: Record<string, PhaseState>; native_model?: string; outside_model?: string; outside_enabled: boolean; attempt_counter: number;
spend_cap_usd: number | null; deadline_at?: string; light?: boolean; history: Array<Record<string, unknown>>; created_at: string; updated_at: string;
/** The gstack that last wrote this run (lib/autoplan-migrate.ts); absent on runs written before 1.91.96. */
gstack_version?: string;
/** The --plan file the owner edits (drift hints, revise), the --plan-heading it was cut from, and the scope flags revise re-detects with. */
plan_input?: string; plan_heading?: string; scope_flags?: Record<string, boolean | undefined>; scaffolded?: boolean;
plan_revision?: number; amendments?: Amendment[];
plan_reality?: { plan_sha256: string; checker: string; verdict: string; line: string };
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
/** The one terminal-voice predicate `next`, `submit`, `resume` and phase close share: a terminal voice is never dispatched or resurrected. */
export function voiceTerminal(v: { status?: string } | undefined): boolean {
return (TERMINAL_VOICE_STATUSES as readonly string[]).includes(String(v?.status));
}
export const pendingAmendments = (state: RunState): Amendment[] => (state.amendments ?? []).filter(a => a.status === 'pending');
export class RunError extends Error {
constructor(public code: ResultCodeName, public detail: string, public exit: 1 | 3 = 1) { super(`${code}: ${detail}`); }
}