// 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; }