350 lines
12 KiB
TypeScript
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;
|
|
}
|