Files
stack/extensions/goal/lib/state.ts
T

350 lines
12 KiB
TypeScript

// Pure goal state machine for the pi /goal extension.
// No I/O, no pi imports — hermetically testable.
import { createHash } from "node:crypto";
export type GoalStatus = "active" | "paused" | "blocked" | "none";
export type GoalTerminalStatus = "complete";
export type ProgressKind = "measurement" | "action" | "delegation" | "wait" | "non_tool";
export interface GoalTerminalOutcome {
text: string;
status: GoalTerminalStatus;
evidence: string;
at: string;
}
export interface ProgressDetails {
kind: ProgressKind;
/** Nearest incomplete acceptance or delivery gate. */
gate: string;
/** Current owner of that gate or external wait. */
owner: string;
/** Most recent live measurement, including what and when. */
lastMeasurement: string;
/** Next concrete action or check. */
nextAction: string;
/** Concrete artifact produced by legitimate non-tool work. */
artifact?: string;
/** Approved agent-watch identifier for a waiting cycle. */
watchId?: string;
/** Concrete condition that ends or rechecks a waiting cycle. */
nextCheck?: string;
}
export interface InProgressReport {
evidence: string;
progress?: ProgressDetails;
}
export type InProgressClassification = "progress" | "waiting" | "no_progress";
export interface InProgressOutcome {
state: GoalState;
classification: InProgressClassification;
reason: string;
}
export interface GoalState {
version: 1;
text: string;
status: GoalStatus;
/** Check prompts injected since the last accepted progress or waiting report. */
checks: number;
/** Cap on consecutive checks without an accepted report (Q4: default 25, per-goal --max). */
maxChecks: number;
/** Consecutive empty, malformed, unsupported, or duplicate in_progress reports. */
noProgressReports: number;
/** Bounded report-only loop threshold. */
maxNoProgressReports: number;
/** A successful non-goal_report tool result is available to back one progress report. */
workEventSinceReport: boolean;
lastActivityTool?: string;
lastActivityAt?: string;
/** Digest only: do not persist free-form evidence in the state file. */
lastProgressFingerprint?: string;
/** Bounded next action shown in the next forced-check prompt. */
lastNextAction?: string;
/** Explicit wait state. Repeated reports keep it active but do not claim new progress. */
activeWait?: {
owner: string;
watchId?: string;
nextCheck?: string;
deadlineAt?: number;
/** Dispatch receipt; wakeObserved confirms before_agent_start, not task completion. */
wakeSent?: boolean;
wakeRequestId?: string;
wakeDispatchedAt?: number;
wakeObserved?: boolean;
};
/** Operator opt-in: add one deadline wake to a suspended wait per goal/resume. */
waitTimeoutSeconds?: number;
waitWakeUsed?: boolean;
setAt: string;
/** Why the goal is paused or blocked: operator stop, abort, error, cap, no-progress, or blocker reason. */
pausedReason?: string;
/** Most recent completed goal, retained for status recall without keeping its loop active. */
lastOutcome?: GoalTerminalOutcome;
}
export const DEFAULT_MAX_CHECKS = 25;
export const DEFAULT_MAX_NO_PROGRESS_REPORTS = 3;
/** Default cap for new goals; GOAL_MAX_CHECKS env overrides (invalid values fall back). */
export function envDefaultMaxChecks(env: NodeJS.ProcessEnv = process.env): number {
return positiveInteger(env.GOAL_MAX_CHECKS, DEFAULT_MAX_CHECKS);
}
/** Report-only loop cap; intentionally separate from the no-report check cap. */
export function envDefaultMaxNoProgressReports(env: NodeJS.ProcessEnv = process.env): number {
return positiveInteger(env.GOAL_MAX_NO_PROGRESS_REPORTS, DEFAULT_MAX_NO_PROGRESS_REPORTS);
}
function positiveInteger(raw: string | undefined, fallback: number): number {
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isInteger(n) && n >= 1 ? n : fallback;
}
export function initialState(
maxChecks: number = envDefaultMaxChecks(),
maxNoProgressReports: number = envDefaultMaxNoProgressReports(),
): GoalState {
return {
version: 1,
text: "",
status: "none",
checks: 0,
maxChecks,
noProgressReports: 0,
maxNoProgressReports,
workEventSinceReport: false,
setAt: "",
};
}
export function setGoal(state: GoalState, text: string, maxChecks?: number, waitTimeoutSeconds?: number): GoalState {
return {
...initialState(maxChecks ?? envDefaultMaxChecks(), envDefaultMaxNoProgressReports()),
text,
...(waitTimeoutSeconds === undefined ? {} : { waitTimeoutSeconds, waitWakeUsed: false }),
status: "active",
setAt: new Date().toISOString(),
};
}
export function pauseGoal(state: GoalState, reason: string): GoalState {
if (state.status !== "active") return state;
return { ...state, status: "paused", pausedReason: reason };
}
export function blockGoal(state: GoalState, reason: string): GoalState {
if (state.status !== "active") return state;
return { ...state, status: "blocked", pausedReason: reason };
}
export function resumeGoal(state: GoalState): GoalState {
if (state.status !== "paused" && state.status !== "blocked" &&
!(state.status === "active" && state.activeWait)) return state;
return {
...state,
status: "active",
checks: 0,
noProgressReports: 0,
workEventSinceReport: false,
pausedReason: undefined,
activeWait: undefined,
waitWakeUsed: state.waitTimeoutSeconds ? false : state.waitWakeUsed,
};
}
export function clearGoal(state: GoalState): GoalState {
return { ...initialState(state.maxChecks, state.maxNoProgressReports) };
}
export function completeGoal(state: GoalState, evidence: string, at: string = new Date().toISOString()): GoalState {
const cleared = initialState(state.maxChecks, state.maxNoProgressReports);
return {
...cleared,
lastOutcome: {
text: state.text,
status: "complete",
evidence: bounded(evidence, 1000),
at,
},
};
}
/** Terminal reports retain the original reset behavior. */
export function recordReport(state: GoalState): GoalState {
return {
...state,
checks: 0,
noProgressReports: 0,
workEventSinceReport: false,
};
}
/** Record one successful tool result that may back exactly one progress report. */
export function recordWorkEvent(state: GoalState, toolName: string, at: string = new Date().toISOString()): GoalState {
if (state.status !== "active") return state;
return {
...state,
workEventSinceReport: true,
lastActivityTool: bounded(toolName, 80),
lastActivityAt: at,
};
}
/**
* Classify an in_progress report mechanically.
*
* Tool-backed progress requires a successful work event. Non-tool work requires
* a concrete artifact. Waits require an owner plus a watch id or next-check
* condition. Duplicate evidence is never progress. Empty/malformed/unsupported
* reports increment a persisted counter and pause at the bound.
*/
export function recordInProgressReport(state: GoalState, report: InProgressReport, now: number = Date.now()): InProgressOutcome {
if (state.status !== "active") {
return { state, classification: "no_progress", reason: "goal is not active" };
}
const evidence = report.evidence.trim();
const progress = normalizeProgress(report.progress);
if (evidence === "" || !progress) {
return recordNoProgress(state, evidence === "" ? "empty evidence" : "missing structured progress");
}
const missing = requiredProgressField(progress);
if (missing) return recordNoProgress(state, `missing ${missing}`);
const fingerprint = progressFingerprint(evidence, progress);
if (progress.kind === "wait") {
if (!progress.watchId && !progress.nextCheck) {
return recordNoProgress(state, "wait requires an approved watch id or concrete next-check condition");
}
if (state.waitTimeoutSeconds && state.waitWakeUsed) {
return {
state: pauseGoal(state, "wait deadline wake exhausted; resolve the dependency before /goal resume"),
classification: "no_progress",
reason: "one automatic deadline wake per goal/resume has already been used",
};
}
const sameWait = state.lastProgressFingerprint === fingerprint;
return {
state: {
...state,
checks: 0,
workEventSinceReport: false,
lastProgressFingerprint: fingerprint,
lastNextAction: bounded(progress.nextAction, 240),
activeWait: {
...(state.waitTimeoutSeconds ? {
deadlineAt: state.activeWait?.deadlineAt ?? now + state.waitTimeoutSeconds * 1000,
wakeSent: false,
} : {}),
owner: bounded(progress.owner, 120),
watchId: progress.watchId ? bounded(progress.watchId, 120) : undefined,
nextCheck: progress.nextCheck ? bounded(progress.nextCheck, 240) : undefined,
},
},
classification: "waiting",
reason: sameWait ? "approved wait remains active" : "approved wait recorded",
};
}
if (progress.kind === "non_tool") {
if (!progress.artifact) return recordNoProgress(state, "non-tool work requires a concrete artifact");
} else if (!state.workEventSinceReport) {
return recordNoProgress(state, `${progress.kind} lacks a successful work event`);
}
if (state.lastProgressFingerprint === fingerprint) {
return recordNoProgress(state, "duplicate evidence");
}
return {
state: {
...state,
checks: 0,
noProgressReports: 0,
workEventSinceReport: false,
lastProgressFingerprint: fingerprint,
lastNextAction: bounded(progress.nextAction, 240),
activeWait: undefined,
},
classification: "progress",
reason: `${progress.kind} recorded`,
};
}
function recordNoProgress(state: GoalState, reason: string): InProgressOutcome {
const nextNoProgress = state.noProgressReports + 1; // NG8_COUNT_GATE
let next: GoalState = {
...state,
noProgressReports: nextNoProgress,
workEventSinceReport: false,
};
if (nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE
next = pauseGoal(next, `paused: no substantive progress after ${nextNoProgress} in_progress reports`);
}
return { state: next, classification: "no_progress", reason };
}
function normalizeProgress(progress: ProgressDetails | undefined): ProgressDetails | undefined {
if (!progress) return undefined;
return {
kind: progress.kind,
gate: progress.gate?.trim() ?? "",
owner: progress.owner?.trim() ?? "",
lastMeasurement: progress.lastMeasurement?.trim() ?? "",
nextAction: progress.nextAction?.trim() ?? "",
artifact: progress.artifact?.trim() || undefined,
watchId: progress.watchId?.trim() || undefined,
nextCheck: progress.nextCheck?.trim() || undefined,
};
}
function requiredProgressField(progress: ProgressDetails): string | undefined {
if (!progress.gate) return "nearest gate";
if (!progress.owner) return "gate owner";
if (!progress.lastMeasurement) return "last live measurement";
if (!progress.nextAction) return "next action";
return undefined;
}
function progressFingerprint(evidence: string, progress: ProgressDetails): string {
return createHash("sha256")
.update(JSON.stringify({ evidence: evidence.trim(), progress }))
.digest("hex");
}
function bounded(value: string, max: number): string {
return value.length <= max ? value : value.slice(0, max);
}
/** True when the check cap is exhausted and the loop must auto-pause instead of injecting. */
export function checkLimitReached(state: GoalState): boolean {
return state.checks >= state.maxChecks;
}
/** Count one injected check prompt. Callers guard with checkLimitReached first. */
export function recordCheckInjected(state: GoalState): GoalState {
return { ...state, checks: state.checks + 1 };
}
/**
* FR3: satisfied requires evidence, blocked requires a reason.
* in_progress is intentionally accepted here so empty/malformed reports reach
* the no-progress counter instead of being rejected before enforcement.
*/
export function validateReport(status: string, evidence: string | undefined): string | null {
const e = (evidence ?? "").trim();
if (status === "satisfied" && e === "") {
return 'goal_report status "satisfied" requires non-empty evidence describing how the goal is met';
}
if (status === "blocked" && e === "") {
return 'goal_report status "blocked" requires a non-empty reason';
}
return null;
}