mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
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:
1 parent
4128ba751b
commit
ee7b341e5d
5 files changed
+241
-4
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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}`); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user