// pi /goal extension — operator-set goal with a forced check<->proceed loop. // // Source of truth for requirements: docs/PRD.md (mosaic-brain, issue #52). // Design locked with Jason 2026-08-28 (Q1-Q8): verb set, agent-self-declared // satisfaction via goal_report, abort pauses, seat-durable state, runaway cap. import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { goalDisplay, goalDetails } from "./lib/display.ts"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; import { clearGoal, completeGoal, blockGoal, pauseGoal, recordCheckInjected, recordInProgressReport, recordReport, recordWorkEvent, resumeGoal, setGoal, validateReport, type GoalState, type ProgressDetails, } from "./lib/state.ts"; import { decideSettle, hasUnresolvedWait } from "./lib/settle.ts"; import { parseGoalCommand } from "./lib/parse.ts"; import { loadState, saveState, resolveStatePath } from "./lib/store.ts"; import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts"; import { incarnationIdentity } from "../mosaic-core/lib/incarnation.ts"; import { goalPolicyPublication, recordGoalPolicyDenial, registerGoalPolicyConsumer } from "../mosaic-core/lib/goal-policy.ts"; const WIDGET_ID = "goal"; export default function goalExtension(pi: ExtensionAPI) { // Register before role-specific adapters and Mosaic Core publish the one // trusted version-4 policy attestation for this process incarnation. const incarnationId = incarnationIdentity(); registerGoalPolicyConsumer(incarnationId); // NG-7 incarnation fencing (Mercer F1): state is scoped to THIS process // launch incarnation — a sibling incarnation of the same seat (marcie-2) // resolves its own fenced file and can never inherit, replace, pause, or // settle this incarnation's active focus. Legacy unfenced state is // quarantined without adoption (store.ts). // NG development state belongs to this project copy, never the live fleet agent home. const statePath = resolveStatePath(fileURLToPath(new URL("../../state/goal/", import.meta.url))); let state: GoalState = loadState(statePath); // Set on session_shutdown: after teardown, any ctx/runner use throws. let dead = false; let waitTimer: ReturnType | undefined; let armedDeadline: string | undefined; let deadlinePending = false; // Process-local queue latch. Only observing this check's start releases it. let pendingCheck: string | undefined; function cancelWaitTimer(): void { if (waitTimer !== undefined) clearTimeout(waitTimer); waitTimer = undefined; armedDeadline = undefined; deadlinePending = false; } // A deadline is a single reconciliation wake, never an approval or a heartbeat. function wakeDeadline(ctx: ExtensionContext): void { if (dead || state.status !== "active" || !state.waitTimeoutSeconds || !state.activeWait || state.activeWait.wakeSent) return; state = { ...state, waitWakeUsed: true, activeWait: { ...state.activeWait, wakeSent: true, wakeRequestId: randomUUID(), wakeDispatchedAt: Date.now(), wakeObserved: false } }; if (!persist(ctx)) return; beginCheck(ctx); } function syncWaitTimer(ctx: ExtensionContext): void { const wait = state.activeWait; if (dead || ctx.mode === "print" || state.status !== "active" || !state.waitTimeoutSeconds || !wait || !Number.isFinite(wait.deadlineAt) || (wait.wakeSent && wait.wakeObserved)) { cancelWaitTimer(); return; } const deadline = wait.wakeSent ? wait.wakeDispatchedAt! + 30000 : wait.deadlineAt!; const key = `${wait.wakeSent ? wait.wakeRequestId : "wait"}:${deadline}`; if (armedDeadline === key) return; cancelWaitTimer(); armedDeadline = key; const tick = () => { waitTimer = undefined; if (dead || state.status !== "active" || armedDeadline !== key) return; if (wait.wakeSent) { pauseWith(ctx, "deadline wake was not observed within 30s", "wake delivery unconfirmed; paused — /goal resume after checking the session"); return; } if (!ctx.isIdle()) { // Compaction can make a session busy without ever emitting agent_settled. // Poll runtime readiness in code only, for at most 60s; never call the model. if (Date.now() >= deadline + 60000) { pauseWith(ctx, "session remained busy after wait deadline", "deadline could not start work; paused — /goal resume when ready"); return; } deadlinePending = true; waitTimer = setTimeout(tick, 1000); waitTimer.unref?.(); return; } wakeDeadline(ctx); }; waitTimer = setTimeout(tick, Math.max(1, deadline - Date.now())); waitTimer.unref?.(); } // ---------- helpers ---------- function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void { try { ctx.ui.notify(message, level); } catch { // stale ctx or no UI — never break a turn over a notification } } function persist(ctx: ExtensionContext): boolean { try { saveState(state, statePath); syncWaitTimer(ctx); return true; } catch (err) { state = pauseGoal(state, "state save failed; continuation stopped"); cancelWaitTimer(); try { safeNotify(ctx, `goal: state save failed: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch { // no UI — nothing else to do } return false; } } function renderWidget(ctx: ExtensionContext): void { if (!ctx.hasUI) return; try { // Clear a stale widget after upgrading/reloading; never register widget content. ctx.ui.setWidget(WIDGET_ID, undefined); const display = goalDisplay(state); const label = `Goal: ${display.label} (/goal, Alt+G)`; ctx.ui.setStatus("goal", process.env.NO_COLOR !== undefined ? label : ctx.ui.theme.fg(display.color, label)); } catch { // Rendering is best-effort; never break the session over it. } } function notifyStatus(ctx: ExtensionContext): void { safeNotify(ctx, goalDetails(state), "info"); } function checkPrompt(): string { const deadline = state.waitTimeoutSeconds && state.activeWait?.wakeSent ? " Wait deadline reached. Reconcile the dependency once. A deadline is not approval. If still unresolved and no independent work remains, report blocked; do not re-arm or poll." : ""; const next = state.lastNextAction ? ` Execute the recorded next action first: ${state.lastNextAction}.` : ""; return ( `[goal check ${state.checks}/${state.maxChecks}; no-progress ${state.noProgressReports}/${state.maxNoProgressReports}]` + `${state.activeWait?.wakeRequestId ? `[goal wake ${state.activeWait.wakeRequestId}] ` : ""}${deadline}${next} Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` + `Report with goal_report exactly once. Use "satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step exists. For "in_progress", do not submit ` + `status alone: provide structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) ` + `with evidence plus progress.kind, nearest gate, owner, last live measurement, and next action. ` + `Measurement/action/delegation requires a successful work tool event; non-tool work ` + `requires an artifact; waiting requires an approved watch id or concrete next-check condition. ` + `Empty, malformed, or duplicate reports increment the no-progress pause counter.` ); } /** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */ function beginCheck(ctx: ExtensionContext): void { if (dead || state.status !== "active" || hasUnresolvedWait(state) || pendingCheck) return; // The loop needs a persistent session. Print mode exits after the prompt // pipeline completes; a forced turn there races teardown (measured). The // goal directive (before_agent_start) still applies in print mode. if (ctx.mode === "print") return; state = recordCheckInjected(state); if (!persist(ctx)) return; renderWidget(ctx); const msg = `${checkPrompt()} [goal dispatch ${randomUUID()}]`; pendingCheck = msg; if (ctx.isIdle()) { try { pi.sendUserMessage(msg); return; } catch { // fall through to the queued form } } try { pi.sendUserMessage(msg, { deliverAs: "followUp" }); } catch (err) { pauseWith(ctx, "check injection failed", "check injection failed — /goal resume after resolving delivery"); safeNotify(ctx, `goal: check injection failed: ${err instanceof Error ? err.message : String(err)}`, "error"); } } /** Stop reason of the most recent assistant message, or undefined when none. */ function lastAssistantStopReason(ctx: ExtensionContext): string | undefined { try { const branch = ctx.sessionManager.getBranch(); for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; if (entry?.type === "message") { const msg = entry.message; if (msg?.role === "assistant") return msg.stopReason; } } } catch { // session introspection is best-effort } return undefined; } function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void { state = pauseGoal(state, reason); pendingCheck = undefined; persist(ctx); renderWidget(ctx); safeNotify(ctx, `goal: ${notice}`, "warning"); } // ---------- /goal command ---------- pi.registerCommand("goal", { description: "Set or manage the session goal: | stop | clear | resume | --max N | --wait-timeout S", handler: async (args, ctx) => { const cmd = parseGoalCommand(args ?? ""); switch (cmd.kind) { case "error": safeNotify(ctx, `goal: ${cmd.message}`, "error"); return; case "status": notifyStatus(ctx); return; case "stop": if (state.status !== "active") { safeNotify(ctx, "goal: nothing active to stop", "warning"); return; } pauseWith(ctx, "stopped by operator", "paused — /goal resume to continue"); return; case "clear": if (state.status === "none" && !state.lastOutcome) { safeNotify(ctx, "goal: no goal set", "warning"); return; } state = clearGoal(state); pendingCheck = undefined; if (!persist(ctx)) return; renderWidget(ctx); safeNotify(ctx, "goal: cleared — normal operation", "info"); return; case "resume": if (state.status !== "paused" && state.status !== "blocked" && !hasUnresolvedWait(state)) { safeNotify(ctx, "goal: nothing paused or waiting to resume", "warning"); return; } state = resumeGoal(state); pendingCheck = undefined; if (!persist(ctx)) return; renderWidget(ctx); safeNotify(ctx, "goal: resumed", "info"); beginCheck(ctx); return; case "set": { const replacing = state.status !== "none"; state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds); pendingCheck = undefined; if (!persist(ctx)) return; renderWidget(ctx); safeNotify( ctx, `${replacing ? "goal: replaced" : "goal: set"} — ${truncate(cmd.text, 60)} (cap ${state.maxChecks})`, "info", ); beginCheck(ctx); return; } } }, }); pi.registerShortcut("alt+g", { description: "Show the full NG goal text and status", handler: async (ctx) => { notifyStatus(ctx); }, }); // ---------- goal_report tool (the loop's only exit) ---------- pi.registerTool({ name: "goal_report", label: "Goal Report", description: "Report progress on the active operator goal. Required at every goal check-in. " + "Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. " + 'Use "satisfied" with evidence, "blocked" with the reason, or "in_progress" with ' + "mechanically backed structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) or an explicit waiting state. Status-only and duplicate " + "in_progress reports count toward an automatic no-progress pause.", promptSnippet: "Report goal status with evidence and mechanically backed progress", parameters: Type.Object({ status: StringEnum(["satisfied", "blocked", "in_progress"] as const), evidence: Type.Optional( Type.String({ description: "Completion evidence, blocker reason, or concise evidence for this work cycle", }), ), progress: Type.Optional( Type.Object({ kind: StringEnum(["measurement", "action", "delegation", "wait", "non_tool"] as const), gate: Type.String({ description: "Nearest incomplete gate" }), owner: Type.String({ description: "Current gate or external-wait owner" }), lastMeasurement: Type.String({ description: "Most recent live measurement, including what and when" }), nextAction: Type.String({ description: "Next concrete action or check" }), artifact: Type.Optional(Type.String({ description: "Required artifact locator for non_tool work" })), watchId: Type.Optional(Type.String({ description: "Approved active watch identifier" })), nextCheck: Type.Optional(Type.String({ description: "Concrete condition that ends or rechecks a wait" })), }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { if (state.status === "none") { return { content: [{ type: "text", text: "No goal is active. Nothing to report." }], details: { goalStatus: "none" }, }; } if (state.status !== "active") { throw new Error("Goal is paused; only /goal resume or a new operator goal permits reports"); } const reportingState = state; // A v4 policy parses and resolves the untrimmed payload before any // existing validation, state transition, persistence, or UI operation. const policy = goalPolicyPublication(incarnationId); if (policy) { const attested = await validateAttestedGoalReport(params.evidence, policy, params.status); if (!attested.ok) { const denial = goalPolicyDenialCode(attested.reason); recordGoalPolicyDenial(incarnationId, denial); throw new Error(`goal_report rejected by attested policy: ${denial}`); } } if (state !== reportingState || state.status !== "active") { throw new Error("Goal changed while validating report; report was not applied"); } const evidence = (params.evidence ?? "").trim(); const reportError = validateReport(params.status, evidence); if (reportError) { throw new Error(reportError); } if (params.status === "satisfied") { state = completeGoal(state, evidence); if (!persist(ctx)) { state = pauseGoal(reportingState, "state save failed; completion not recorded"); throw new Error("Goal completion could not be saved; continuation stopped"); } renderWidget(ctx); safeNotify(ctx, `goal: satisfied and cleared${evidence ? ` — ${truncate(evidence, 80)}` : ""}`, "info", ); return { content: [ { type: "text", text: "Goal recorded as SATISFIED. The goal is cleared; normal operation resumes. Do not call goal_report again unless the operator sets a new goal.", }, ], details: { goalStatus: "none" }, }; } if (params.status === "blocked") { const reason = evidence || "no reason given"; state = blockGoal(recordReport(state), truncate(reason, 120)); if (!persist(ctx)) throw new Error("Goal blocker could not be saved; continuation stopped"); renderWidget(ctx); safeNotify(ctx, `goal: BLOCKED — ${truncate(reason, 100)} — /goal resume after resolving`, "warning"); return { content: [ { type: "text", text: "Goal recorded as BLOCKED. The loop is paused and the operator has been notified. Do not call goal_report again until the operator resumes or sets a new goal.", }, ], details: { goalStatus: "blocked" }, }; } const outcome = recordInProgressReport(state, { evidence, progress: params.progress as ProgressDetails | undefined, }); state = outcome.state; if (!persist(ctx)) throw new Error("Goal state could not be saved; continuation stopped"); renderWidget(ctx); if (state.status === "paused") { safeNotify(ctx, `${state.pausedReason} — /goal resume after resolving`, "warning"); return { content: [ { type: "text", text: `Goal paused: ${state.pausedReason} (${outcome.reason}). The operator has been notified. Do not continue this goal until the operator resumes it.`, }, ], details: { goalStatus: "paused", classification: outcome.classification }, }; } const text = outcome.classification === "progress" ? `Recorded substantive progress (${outcome.reason}). Continue with the recorded next action.` : outcome.classification === "waiting" ? (state.waitTimeoutSeconds && ctx.mode !== "print" ? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.` : (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : "Waiting recorded. Automatic goal checks are suspended. No deadline timer is armed. Keep the existing message/watch delivery path; nextCheck is descriptive, not executable. Reconcile on relevant input or explicit /goal resume. Do not poll or report unchanged status. Checkpoint and yield now.")) : `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`; return { content: [{ type: "text", text }], details: { goalStatus: "active", classification: outcome.classification }, }; }, }); // ---------- loop wiring ---------- pi.on("before_agent_start", async (event, ctx) => { if (pendingCheck && event.prompt.includes(pendingCheck)) pendingCheck = undefined; if (state.status !== "active") { if (event.prompt.startsWith("[goal check ")) { return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` }; } return; } if (state.activeWait?.wakeRequestId && event.prompt.includes(`[goal wake ${state.activeWait.wakeRequestId}]`)) { state = { ...state, activeWait: { ...state.activeWait, wakeObserved: true } }; if (!persist(ctx)) return { systemPrompt: `${event.systemPrompt}\nGoal state save failed. Stop this goal and report the recording failure.` }; } const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print" ? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses." : " Accepted waits suspend automatic goal checks without a deadline timer. Incoming messages do not clear the wait by themselves: on unrelated input preserve it; on relevant input verify the dependency before reporting progress. Do not poll, add timers, or report unchanged status. The operator can use /goal resume to reconcile once."; const directive = `[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` + `Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` + `At every check-in you MUST call goal_report exactly once. Use ` + `"satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step ` + `exists. An "in_progress" report MUST follow a concrete action and use structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked), or ` + `declare an explicit approved wait/non-tool artifact. Status-only and duplicate reports are no progress. ` + `Do not end a turn without reporting.${waitingDirective}`; return { systemPrompt: `${event.systemPrompt}\n\n${directive}` }; }); pi.on("tool_result", async (event, ctx) => { if (state.status !== "active" || event.isError || event.toolName === "goal_report") return; state = recordWorkEvent(state, event.toolName); persist(ctx); renderWidget(ctx); }); pi.on("agent_settled", async (_event, ctx) => { if (state.status !== "active") return; // Abort/error pause (Q3i) and runaway cap (Q4) live in lib/settle.ts (pure, tested). const decision = decideSettle(state, lastAssistantStopReason(ctx)); if (decision.action === "pause") { pauseWith(ctx, decision.reason, `${decision.reason} — /goal resume to continue`); return; } if (decision.action === "wait") { if (deadlinePending) wakeDeadline(ctx); else syncWaitTimer(ctx); return; } beginCheck(ctx); }); // Teardown guard: after session replacement/reload/quit, stop touching ctx. pi.on("session_shutdown", async () => { dead = true; pendingCheck = undefined; cancelWaitTimer(); }); // Seat-durable state (Q7b): reload from disk on every session start/reload/resume/new // so a fresh session in the same seat picks up the active goal. pi.on("session_start", async (_event, ctx) => { dead = false; state = loadState(statePath); syncWaitTimer(ctx); renderWidget(ctx); if (state.status === "active") { safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info"); // Durability (Q7b): a fresh session with an active goal re-engages the // loop immediately — no prior turn exists to emit agent_settled. if (!hasUnresolvedWait(state)) beginCheck(ctx); } else if (state.status === "paused" || state.status === "blocked") { safeNotify(ctx, "goal: paused — /goal resume to continue", "info"); } }); } function truncate(s: string, n: number): string { return s.length <= n ? s : s.slice(0, n - 1) + "…"; }