/** * pi-goal-list-loop-audit — v0.24.5 * extensions/goal-loop-core.ts * * Shared types, state machine, JSONL persistence, helpers. * * Design: see docs/DESIGN.md */ import * as fs from "node:fs"; import * as path from "node:path"; import { execSync } from "node:child_process"; import { normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, sanitizeProviderAuditReport, sanitizeProviderDisplayText, type QuotaSignal } from "./quota-retry.js"; import { MAX_MAIN_MODEL_FALLBACKS } from "./main-model-recovery.js"; import { resolveGllaStateDir, stateRootPending } from "./glla-state-root.js"; export { normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, sanitizeProviderAuditReport, sanitizeProviderDisplayText } from "./quota-retry.js"; export { globalSettingsPath, resolveRuntimeSessionDir, setRuntimeSessionDir, setRuntimeSessionDirFromSessionManager, stateRootPending, type GllaStateRoot } from "./glla-state-root.js"; /** v0.26.1: consecutive heartbeat refires without a real agent turn * before the supervisor gives up (pauses the goal / stops the loop). * 0 = never escalate (legacy silent-spin behavior). */ export const DEFAULT_STALL_ESCALATION_REFIRES = 5; /** v0.26.1: pure gate — has the refire streak hit the escalation * threshold? threshold 0 disables escalation entirely. */ export function shouldEscalateStall(consecutiveStalls: number, threshold: number): boolean { return threshold > 0 && consecutiveStalls >= threshold; } /** The next top-of-hour (:00:00.000) strictly after now — the hourly * * v0.34.92: superseded by nextHourlyProbeMs for the actual probe ticker * (the prompt slot is no longer used — the v0.34.58/v0.34.90 prompt * machinery is removed). Kept here so any external caller (older * extensions, the LEGACY hourlyPromptMs that may be referenced elsewhere) * still compiles. */ export function nextHourlyPromptMs(now = Date.now()): number { const d = new Date(now); d.setHours(d.getHours() + 1, 0, 0, 0); return d.getTime(); } /** v0.34.92: the next :00:30 strictly after now — the hourly retry ticker * slot. We use :00:30 (not :00:00) to leave a small clock-skew margin before * the extra attempt. The slot is per-hour: at 14:00:01 the next slot is * 14:00:30 (29s away); at 14:00:31 the next slot is 15:00:30. */ export function nextHourlyProbeMs(now = Date.now()): number { const d = new Date(now); // Start with this hour's :00:30 d.setMinutes(0, 30, 0); if (d.getTime() <= now) { // Already past this hour's :00:30 — jump to next hour d.setHours(d.getHours() + 1); } return d.getTime(); } // ================================================================= // Types // ================================================================= export type Status = | "active" | "auditing" | "complete" | "paused" | "aborted"; export type Policy = "goal" | "list"; // v0.3.0: "loop". /** Explicit specialist routing requested by the user or by a task plan. */ export type AgentRole = "designer"; /** User-facing controls whose command root follows the active goal policy. */ export type ModeCommand = "pause" | "tweak" | "resume"; /** Return the command root for a supervised work surface. */ export function workCommandRoot(mode: Policy | "loop" | undefined): "/goal" | "/list" | "/loop" { if (mode === "list") return "/list"; if (mode === "loop") return "/loop"; return "/goal"; } /** Build a mode-correct pause/tweak/resume command for a goal or list item. */ export function modeCommand(mode: Policy | undefined, command: ModeCommand): string { return `${workCommandRoot(mode)} ${command}`; } /** Build a command for a goal, list item, or metric loop. */ export function workCommand(mode: Policy | "loop" | undefined, command: string): string { return `${workCommandRoot(mode)} ${command}`; } export interface Task { id: string; title: string; status: "pending" | "in_progress" | "complete"; /** Optional specialist hand-off for this task. */ agentRole?: AgentRole; /** Optional verification gate for milestone-checked tasks. */ verificationContract?: string; subtasks?: Task[]; } export interface TaskList { version: 1; tasks: Task[]; } // ================================================================= // Task-list proposal validation (used by the propose_task_list tool) // // The caps are the fix for pi-goal-x flaw #4: the agent could grow subtasks // indefinitely, drifting into self-generated busywork. Hard limits keep a // breakdown a breakdown. // ================================================================= export const MAX_TOP_LEVEL_TASKS = 20; export const MAX_SUBTASKS_PER_TASK = 5; export interface TaskProposal { title: string; agentRole?: AgentRole; verificationContract?: string; subtasks?: string[]; } /** Validate a proposed breakdown. Returns an error string or null. */ export function validateTaskProposal(tasks: TaskProposal[]): string | null { if (!Array.isArray(tasks) || tasks.length === 0) return "Empty task list."; if (tasks.length > MAX_TOP_LEVEL_TASKS) { return `Too many top-level tasks (${tasks.length}); max ${MAX_TOP_LEVEL_TASKS}. Coarser granularity, please.`; } for (const t of tasks) { if (!t.title || !t.title.trim()) return "Every task needs a non-empty title."; const n = t.subtasks?.length ?? 0; if (n > MAX_SUBTASKS_PER_TASK) { return `Task "${t.title}" has ${n} subtasks; max ${MAX_SUBTASKS_PER_TASK}. Merge or split into coarser tasks.`; } } return null; } /** Assign hierarchical ids ("1", "1.1", …) and pending statuses to a proposal. */ export function buildTaskList(tasks: TaskProposal[]): TaskList { return { version: 1, tasks: tasks.map((t, i) => ({ id: String(i + 1), title: t.title.trim(), status: "pending" as const, ...(t.agentRole ? { agentRole: t.agentRole } : {}), ...(t.verificationContract ? { verificationContract: t.verificationContract } : {}), subtasks: (t.subtasks ?? []).map((s, j) => ({ id: `${i + 1}.${j + 1}`, title: s.trim(), status: "pending" as const, })), })), }; } export interface AuditVerdict { at: string; approved: boolean; disapproved: boolean; /** v0.24.2: the auditor's third verdict — the goal can NEVER be satisfied as stated. */ impossible?: boolean; impossibleReason?: string; model: string; thinkingLevel?: string; report?: string; /** Infrastructure failure detail (abort, auth, no model). Verdicts only — an entry with error and no report is not a real audit. */ error?: string; /** regression_shield outcome when the goal had a verification contract. */ regressionShieldPassed?: boolean; /** Contract items the shield found unreferenced (fed into the next audit's prompt, v0.22.6). */ regressionShieldMissing?: string[]; /** v0.34.60 (steal #3): the goal revision this audit ran against. An * approval recorded here is only valid for that contract revision — * complete_goal gates on the latest audited revision matching the * goal's current revision so an old approval can never be cited * against a tweaked contract. Legacy entries lack the field and pass * the gate unchanged. */ revision?: number; } /** The display classification for one stored auditor result. Keep semantic * verdicts separate from operational failures: a shield-blocked approval is * not a disapproval, and an infrastructure error is not a verdict at all. */ export type AuditVerdictLabel = | "approved" | "disapproved" | "impossible" | "shield-blocked" | "infrastructure failure" | "no verdict"; export function auditVerdictLabel(v: Pick): AuditVerdictLabel { if (v.approved && v.regressionShieldPassed === false) return "shield-blocked"; if (v.approved) return "approved"; if (v.impossible) return "impossible"; if (v.disapproved) return "disapproved"; if (v.error) return "infrastructure failure"; return "no verdict"; } /** * Sum token usage across assistant messages, counting each message once. * `agent_end` events may include already-seen history, so callers pass a * dedup set keyed by timestamp+tokens (good-enough identity for counting). * * v0.12.0: counts input+output (real spend) when the usage object carries * the split; totalTokens includes cache reads, which inflate 10-50× on long * sessions (a day-long goal "used" 216M while real spend was a fraction). */ export function sumNewAssistantTokens(messages: unknown[], seen: Set): number { let total = 0; for (const m of messages) { const msg = m as { role?: string; timestamp?: unknown; usage?: { input?: unknown; output?: unknown; totalTokens?: unknown }; }; if (msg?.role !== "assistant") continue; const u = msg.usage; const split = (typeof u?.input === "number" ? u.input : 0) + (typeof u?.output === "number" ? u.output : 0); const tokens = split > 0 ? split : (typeof u?.totalTokens === "number" ? u.totalTokens : 0); if (tokens <= 0) continue; const key = `${String(msg.timestamp ?? "?")}:${tokens}`; if (seen.has(key)) continue; seen.add(key); total += tokens; } return total; } export type CompletionAuditPhase = "running" | "recovery-pending" | "retry-waiting" | "quota-waiting"; /** Durable completion claim metadata. The claim itself is the user's exact * completion assertion; the lifecycle fields make an interrupted isolated * audit distinguishable from one that is actively running. Fields beyond * `at` are optional so v0.34.20 and older claims remain recoverable. */ export interface PendingCompletion { completionSummary?: string; verificationSummary?: string; /** When the completion claim was first persisted. */ at: string; /** Current audit lifecycle. Missing = legacy claim, treated as recovery-pending. */ phase?: CompletionAuditPhase; /** Identifies the isolated-auditor attempt, not the goal. */ attemptId?: string; /** Start/deadline for the current isolated-auditor attempt. */ startedAt?: string; wallDeadlineAt?: string; /** Why the claim is waiting for a fresh attempt. */ recoveryAt?: string; recoveryReason?: string; /** Durable due time for the one bounded no-verdict recovery retry. */ recoveryRetryAt?: string; /** Bounded raw provider/auditor diagnostic retained for forensics only. */ providerErrorDiagnostic?: string; /** Stable identity for one detached-auditor provider recovery episode. */ recoveryEpisodeKey?: string; /** Durable per-episode notice fence; display projections must consult it. */ recoveryNoticeKeys?: string[]; /** * Durable one-shot recovery fence. A parked claim may receive one * automatic retry after a validated healthy lifecycle/recovery event; * manual /goal resume remains available after that attempt. Missing on * legacy claims means "not yet consumed". */ automaticRecoveryAttempted?: boolean; automaticRecoveryAt?: string; automaticRecoveryGeneration?: number; /** When aggressiveMode is enabled, the no-verdict recovery path keeps * retrying inside this durable window instead of stopping after one retry. */ automaticRecoveryAttempts?: number; automaticRecoveryFirstAt?: string; automaticRecoveryUntil?: string; /** Durable generic retry accounting; survives reloads and worker restarts. */ retryAttempts?: number; retryFirstAt?: string; retryUntil?: string; /** @deprecated v0.34.142: migrated to the generic retry fields on load. */ quotaAttempts?: number; quotaFirstAt?: string; quotaAutoRetryUntil?: string; /** @deprecated v0.34.142: retained only when reading older state. */ quotaSignal?: QuotaSignal; /** @deprecated v0.34.142: upstream hints never control scheduling. */ retryAfterSec?: number; retryFromUpstream?: boolean; resetAt?: string; } export interface ObjectiveRepairTarget { /** Queue/goal identity of the malformed intent that needs a replan. */ id: string; objective: string; verificationContract?: string; reasons: string[]; source: string; /** Durable one-shot guard: the repair bootstrap turn was already sent. * A user-confirmed task-list redraft clears the whole target; an explicit * resume may clear this timestamp to retry one bounded bootstrap turn. */ replanPromptedAt?: string; } export interface ObjectiveRepairRecord { at: string; action: "auto-applied" | "queued"; originalObjective: string; replacementObjective?: string; originalContract?: string; replacementContract?: string; source: string; reason: string; evidence: string; confidence: "best-effort" | "fallback"; revisionBefore: number; revisionAfter: number; } /** Durable intent captured before an objective can be overwritten by a * reviewer fragment, transcript replay, or malformed queue restore. */ export interface ObjectiveProvenance { originalObjective: string; originalContract?: string; userSeeds?: string[]; } export interface Goal { id: string; objective: string; status: Status; policy: Policy; /** Explicit specialist routing requested for this goal/list item. */ agentRole?: AgentRole; verificationContract?: string; autoContinue: boolean; /** v0.34.81 (LIGHT parent/child): set ONLY when this goal was activated * from a queue item that declared `Subtask of: `. The * parent is identified by its queue item id so the cascade on completion * can locate it without resolving objectives at archive time. Persists in * state.json (the durable goal .md is a render projection and intentionally * does not carry this — a crash-restart that drops it leaves the parent * visible as a plain queue item rather than mis-handling the cascade). */ parentId?: string; /** v0.35.1: a control goal created to re-plan suspicious saved intent. * It is cleared only after a confirmed task-list re-draft, so the generic * repair card cannot complete repeatedly while the original target remains * opaque in the queue. */ repairTarget?: ObjectiveRepairTarget; taskList?: TaskList; auditHistory?: AuditVerdict[]; stopReason?: string; /** v0.34.91: the agent's own 1-paragraph completion recap (from * complete_goal's completionSummary), captured when the claim is made and * persisted with the goal. The terminal summary line shows THIS instead * of echoing the objective — the end-of-goal recap should say what * happened, not restate the contract. Absent on legacy/aborted goals * (the render falls back to the objective/reason). */ completionSummary?: string; pauseReason?: string; pauseSuggestedAction?: string; /** v0.28.22: pause classification — drives the widget/status rendering * (a decision pause, an operational failure, a time-gated wait, and a * generic block must not look alike). Undefined = legacy flat card. */ pauseKind?: "decision" | "error" | "wait" | "blocked"; /** v0.28.22: decision pauses — the options the user picks between. */ pauseOptions?: string[]; /** v0.28.22: 1-based index into pauseOptions the agent recommends. */ pauseRecommended?: number; /** v0.28.22: ISO time a wait-pause becomes resumable (countdown shown). */ pauseResumeAt?: string; /** v0.35.28 (issue #16): set when glla AUTO-resumed a lapsed wait — the * continuation prompt renders a recovery notice from it so the agent * understands IT was the session that was disconnected and recovered * (issue #16 part 2: agents waited "for themselves to be recovered"). * Cleared by an explicit manual /goal resume. */ autoResumedAt?: string; autoResumedEvent?: string; /** v0.28.1 (S1/S2): stale-handle interrupt marker. Set INSTEAD of pausing * when pi invalidates the extension handle mid-goal — the goal stays * active so a fresh session auto-resumes it via the restore gate. Cleared * on that auto-resume. */ interruptedAt?: string; interruptedReason?: string; /** v0.28.5 (E2): trailing auditor INFRA-structure errors (not verdicts). * At 3 the goal pauses loudly — a broken auditor model must not spin a * silent retry-forever loop. Cleared on any real auditor run. */ auditInfraStreak?: number; /** v0.34.15: persisted error-brake rung — survives /reload so the 6-brake park can engage. */ errorBrakeStreak?: number; /** v0.28.26: the completion claim captured when an audit attempt stops * before a verdict. The stored-claim retry re-runs the AUDITOR directly * instead of re-engaging the agent — re-engaging produced a * hallucinated-closure repetition loop in the field (π-games 2026-07-29: * the agent concluded the goal was closed, stopped calling complete_goal, * and repeated the same essay until the stall brake fired). Cleared when * the retry resolves. Only consumed while paused in the auditor-retry * lifecycle, so a stale value is unreachable by construction. */ pendingCompletion?: PendingCompletion; /** v0.28.28: provenance — who created this goal ("user", "list-cascade", * "draft-confirmed", "draft-autoaccepted"). Ledgered on goal_created so * "where did this come from" is answerable after the fact. */ createdVia?: string; /** v0.25.0 (contract item 22): auditor objections extracted as TODOs when * aggressiveMode keeps the goal active past the disapproval cap. Rendered * into every continuation prompt until the next audit clears them. */ pendingTasks?: string[]; activePath?: string; archivedPath?: string; usage: { tokensUsed: number; tokensLimit: number; }; createdAt: string; updatedAt: string; /** Bounded raw provider diagnostic retained for the active/archive record; * user-facing projections use the sanitized pause/recovery copy instead. */ providerErrorDiagnostic?: string; /** Stable provider recovery episode identity for goal-level error brakes. */ recoveryEpisodeKey?: string; /** Durable per-episode notice fence for goal-level recovery messages. */ recoveryNoticeKeys?: string[]; /** v0.25.2: per-goal telemetry for /glla stats premature-success * detection. Bumped live: turns on agent_end, fileWrites/bashCalls on * tool_result while the goal is active. */ telemetry?: { turns: number; fileWrites: number; bashCalls: number }; /** v0.34.59: focus token / revision counter on every goal mutation. * Persisted alongside the goal; bumped on every persistState. Detached * workers capture (goalId, revision) at dispatch and refuse to apply * their result if the captured revision no longer matches — a stale * handle cannot silently overwrite a goal that moved on. */ revision?: number; /** v0.35.x: durable record of suspicious-objective recovery decisions. */ objectiveRepairHistory?: ObjectiveRepairRecord[]; /** v0.35.x: original/user-supplied intent retained for repair after a * reviewer fragment or stale state overwrites the live objective. */ objectiveProvenance?: ObjectiveProvenance; } /** * Route `/goal` args (v0.8.0 top-level consolidation). Subcommands match ONLY * on exact word (except tweak/archive which take args) — an objective that * starts with "pause" ("/goal pause the pipeline and fix it") must set a * goal, not pause one. */ export type GoalRoute = | { kind: "draft" } | { kind: "set"; text: string } | { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "decide" | "verify" | "audit" | "tweak" | "archive" | "start" | "plan"; rest: string }; // v0.29.8: "audit" moved to ARG subs ("/goal audit [focus]" is the one-shot // project audit — user: "/goal audit IS the audit goal"); the v0.28.27 // manual current-goal verification moved to "verify" (it happens // automatically at completion anyway — verify is the on-demand handle). const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel", "decide", "verify"]); const GOAL_ARG_SUBS = new Set(["audit", "tweak", "archive", "start", "plan"]); export function routeGoalArgs(raw: string): GoalRoute { const trimmed = raw.trim(); if (!trimmed) return { kind: "draft" }; const space = trimmed.indexOf(" "); const first = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase(); const rest = space === -1 ? "" : trimmed.slice(space + 1).trim(); if (GOAL_EXACT_SUBS.has(first) && rest === "") { return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel" | "decide" | "verify", rest: "" }; } if (GOAL_ARG_SUBS.has(first)) { return { kind: "sub", name: first as "audit" | "tweak" | "archive" | "start" | "plan", rest }; } return { kind: "set", text: trimmed }; } /** v0.35.33: drafting depth. "plan" is the EXTENDED DRAFT — research-first * (read the code before asking), multi-round interviewing, and a structured * expanded objective. It changes the PROMPT, never the trust machinery: the * Confirm card still gates activation and the regular draft stays the fast * path. No separate artifact — the objective itself is the single truth * (the respec lesson: a second document always goes stale). */ export type DraftingDepth = "normal" | "plan"; export function draftingTemplateFile(target: "goal" | "list" | "loop", depth: DraftingDepth): string { if (depth === "plan") return target === "loop" ? "goal-loop-plan-loop.md" : "goal-loop-plan.md"; return target === "loop" ? "goal-loop-forever-draft.md" : "goal-loop-draft.md"; } /** * Parse a bulk list-import file (v0.8.1): markdown checklists (`- [ ]`, * `- [x]`), bullets (`-`, `*`, `•`), numbered items (`1.`, `2)`), and plain * lines all become list items. Headings (`# …`), blank lines, and HTML * comments are skipped. A sisyphus-style plan file should import clean. */ export function parseListImport(content: string): string[] { const items: string[] = []; for (const line of content.split("\n")) { let t = line.trim(); if (!t) continue; if (t.startsWith("#")) continue; // headings if (t.startsWith("