/** * pi-goal-expander — goal harness for Pi sessions. * * Surfaces: * /goal start harness (research → expand plan → execute) * /goal-expand same as /goal (starts harness) * /goal status|pause|… lifecycle * goal_expand tool expand + start harness * update_goal tool message | completed | blocked_reason */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { resolveConfig } from "./config.ts"; import { buildContinuationDirective } from "./loop/continuation.ts"; import { runSkepticPanel } from "./loop/panel.ts"; import { runPreverify } from "./loop/preverify.ts"; import { resolveSkepticNForVerify } from "./loop/verify-policy.ts"; import { nextUncheckedStep } from "./plan/next-step.ts"; import { isDraftEquivalentPlan } from "./loop/preverify.ts"; import { buildGoalSetContextMessage, planFromExpansion } from "./roles/planner.ts"; import { buildExecuteKickoffPrompt } from "./roles/execute-kickoff.ts"; import { extractParentContextText } from "./roles/parent-context.ts"; import { runPlanner } from "./roles/run-planner.ts"; import { clearOwnedGoalDir, createGoalOwner, ensureGoalDirs, findActiveGoal, isGoalOwnershipLockBusy, ownerTokenFor, renewGoalForOwnerToken, readEvidenceIndex, readPlan, resolveGoalDir, restoreGoalForOwner, takeOverGoal, withOwnedGoalLock, writeOwnedGoalState, writeOwnedPlan, writeOwnedReceipt, writeState, } from "./store.ts"; import { GoalTracker } from "./tracker.ts"; import type { GoalOrchestration, GoalOwnerToken } from "./types.ts"; import { buildGoalEvent } from "./ui/events.ts"; import { formatStatusline } from "./ui/statusline.ts"; import { pingSubagents, stopSubagent, type EventBus } from "./subagents/client.ts"; function getEventBus(pi: ExtensionAPI): EventBus | null { const bus = (pi as { events?: EventBus }).events; return bus ?? null; } const STATUS_KEY = "goal"; const config = resolveConfig(); /** * B031: pi-subagents runs child subagent sessions in-process with extensions * enabled and the same cwd. While extension factories run it signals depth via * globalThis[Symbol.for("pi-subagents:extension-depth")] — either a number or * { depth, agentId, parentAgentId }. At depth > 0 this extension must stay * fully inert: otherwise session_start restores the parent's cwd-keyed goal * and agent_end injects goal messages into the subagent's session. */ const SUBAGENT_DEPTH_KEY = Symbol.for("pi-subagents:extension-depth"); function subagentLoadDepth(): number { const value = (globalThis as Record)[SUBAGENT_DEPTH_KEY]; if (typeof value === "number" && Number.isFinite(value)) return value; if (value && typeof value === "object") { const depth = (value as { depth?: unknown }).depth; if (typeof depth === "number" && Number.isFinite(depth)) return depth; } return 0; } /** * Goal ids that have already completed successfully in this process. * Blocks further continuation injects for that goal (B012). * Other extensions may still emit; the goal harness stays quiet. */ const completedGoalIds = new Set(); /** * Test-only hook: runs once after update_goal ownership entry check succeeds * (before mutate/persist). Used to simulate transfer interleaving for B031. */ let ownershipRaceHookForTests: (() => void) | null = null; export function setOwnershipRaceHookForTests(hook: (() => void) | null): void { ownershipRaceHookForTests = hook; } /** Shown by `/goal help` (toast-only; no transcript injection). */ export const GOAL_HELP_TEXT = [ "pi-goal-expander — goal harness", "", "Start / pursue", " /goal Start: planner (pi-subagents if available) → execute", " /goal expand Same as /goal ", " /goal-expand Same as /goal ", "", "Lifecycle", " /goal status Show active goal (toast only)", " /goal pause [reason] Pause the active goal (stops planner/verifier agents)", " /goal resume [--takeover] Resume your goal; --takeover explicitly transfers this repository goal", " /goal clear Clear goal state and .pi/goal// (stops agents)", " /goal help This help", "", "Tools (for the model)", " goal_expand({ goal }) Same as /goal ", " update_goal({ message }) Progress note (binds to in-memory active goal only)", " update_goal({ completed: true }) Request harness verification", " update_goal({ blocked_reason }) Record stuckness (3× → blocked)", "", "Flow", " 1. Planner subagent researches + writes plan.md (or degraded structural draft)", " 2. Baseline snapshot + phase → executing; kickoff turn implements the plan", " 3. Keep evidence/index.md (criterion → artifacts)", " 4. update_goal(completed:true) — preverify → skeptic panel (if subagents) → Achieved", "", "Tips", " /goal with no args is status. Empty goal text is rejected.", " Status is toast-only (does not inject into the transcript).", " One active goal per session: starting a new goal replaces the prior active (notified).", " Use /goal clear to drop the current goal before starting another if you prefer.", ].join("\n"); function goalCwd(ctx?: { cwd?: string }): string { return process.env.PI_GOAL_CWD || ctx?.cwd || process.cwd(); } function gapFingerprint(gaps: string[]): string { return [...gaps] .map((g) => g.trim().toLowerCase()) .filter(Boolean) .sort() .join("|"); } function goalRulesText(objective: string, planPath: string): string { return [ `A goal is active. Objective: ${objective}`, `Plan: ${planPath}`, "Required progress milestones — call update_goal(message) at least for: researched, implementing, pre-complete (optional mid-checklist notes too).", "Use update_goal(message) for progress, update_goal(completed:true) when done, update_goal(blocked_reason) if stuck.", "Maintain evidence/index.md mapping criteria to artifacts.", "Goal state is under .pi/goal/ (cwd / PI_GOAL_ROOT / PI_GOAL_CWD); objective edit targets may be outside that dir.", "No test theater. Work the task checklist; mark [x] as you go.", ].join("\n"); } /** Compact next-step guidance (B009). */ function buildNextStepMessage(input: { objective: string; planPath: string; nextStep: string; reason: "kickoff" | "advanced" | "resume"; }): string { const why = input.reason === "kickoff" ? "First unchecked task-checklist item after planning." : input.reason === "advanced" ? "Checklist advanced — new next step." : "Resumed; focus on the current next step."; return [ "# Goal — next step", "", `**Objective:** ${input.objective}`, `**Plan:** \`${input.planPath}\``, "", why, "", `**Focus on:** ${input.nextStep}`, "", "Flip `- [ ]` → `- [x]` in plan.md when done; then continue to the next unchecked item.", "Call `update_goal({ message })` at milestones; `update_goal({ completed: true })` only when criteria + evidence hold.", ].join("\n"); } type GoalEventDetails = { kind: string; goalId?: string; status?: string; phase?: string; objective?: string; [key: string]: unknown; }; function setStatusline(ctx: { ui?: { setStatus?: (k: string, t: string | undefined) => void } }, state: GoalOrchestration | null): void { ctx.ui?.setStatus?.(STATUS_KEY, formatStatusline(state)); } /** Deterministic persist outcome for owner-fenced state writes (B031). */ type PersistResult = | { ok: true; state: GoalOrchestration } | { ok: false; error: "ownership_lost" | "ownership_busy"; reason: "unavailable" | "foreign" | "unowned" | "missing" | "busy"; }; /** * Persist tracker state only while the exact owner token still owns disk. * On refusal: clear the stale in-memory tracker and return ownership_lost — * never throw for normal transfer races. * Lock contention returns ownership_busy with no tracker/message effects. */ function persist(cwd: string, tracker: GoalTracker): PersistResult { const snap = tracker.snapshot(); if (!snap?.owner) { tracker.clear(); return { ok: false, error: "ownership_lost", reason: "unavailable" }; } let result; try { result = writeOwnedGoalState(cwd, snap, ownerTokenFor(snap.owner)); } catch (err) { if (isGoalOwnershipLockBusy(err)) { // No state clear / no messages — lock busy is not ownership transfer. return { ok: false, error: "ownership_busy", reason: "busy" }; } tracker.clear(); return { ok: false, error: "ownership_lost", reason: "unavailable" }; } if (result.kind !== "owned") { tracker.clear(); return { ok: false, error: "ownership_lost", reason: result.kind === "foreign" ? "foreign" : "unowned", }; } tracker.load(result.state); return { ok: true, state: result.state }; } function ownershipLostToolResult(reason?: string): { content: Array<{ type: "text"; text: string }>; details: { ok: false; error: "ownership_lost"; reason?: string }; } { return { content: [ { type: "text" as const, text: "Goal ownership lost: another session took over this repository goal. Disk was not updated.", }, ], details: { ok: false, error: "ownership_lost", reason }, }; } function ownershipBusyToolResult(): { content: Array<{ type: "text"; text: string }>; details: { ok: false; error: "ownership_busy"; reason: "busy" }; } { return { content: [ { type: "text" as const, text: "Goal ownership is temporarily unavailable (lock busy). Retry shortly; disk was not updated.", }, ], details: { ok: false, error: "ownership_busy", reason: "busy" }, }; } /** Map a failed PersistResult to a deterministic tool result (no throws). */ function ownershipFenceToolResult( persisted: Extract, ): ReturnType | ReturnType { if (persisted.error === "ownership_busy") return ownershipBusyToolResult(); return ownershipLostToolResult(persisted.reason); } function sessionIdFor(ctx: { sessionManager?: { getSessionId?: () => string } }): string | null { const value = ctx.sessionManager?.getSessionId?.(); return typeof value === "string" && value.trim() ? value.trim() : null; } /** * Degraded verify: require evidence index with a row covering each criterion. * (Stricter than soft preverify — used after preverify already passed.) */ function fileIoForCwd(cwd: string): { fileExists: (p: string) => boolean; readText: (p: string) => string | null; } { const resolvePath = (p: string) => (p.startsWith("/") ? p : join(cwd, p)); return { fileExists: (p) => existsSync(resolvePath(p)), readText: (p) => { try { return readFileSync(resolvePath(p), "utf8"); } catch { return null; } }, }; } function degradedVerify( planMarkdown: string, evidenceIndex: string | null, cwd: string, ): { achieved: boolean; gaps: string[] } { if (!evidenceIndex?.trim()) { return { achieved: false, gaps: ["evidence index incomplete"] }; } const io = fileIoForCwd(cwd); const result = runPreverify({ planMarkdown, evidenceIndexMarkdown: evidenceIndex, fileExists: io.fileExists, readText: io.readText, }); // Soft preverify treats missing index as ok; we already required presence. // Re-run with presence: any remaining gaps fail. if (!result.ok || result.gaps.some((g) => !g.includes("no evidence index"))) { const gaps = result.gaps.filter((g) => !g.includes("no evidence index")); if (gaps.length === 0 && !result.ok) { return { achieved: false, gaps: ["evidence index incomplete"] }; } if (gaps.length > 0) { return { achieved: false, gaps }; } } // Also require at least one data row in the table const hasRow = evidenceIndex .split("\n") .some((l) => { const t = l.trim(); return t.startsWith("|") && !/^\|\s*-+/.test(t) && !/criterion/i.test(t); }); if (!hasRow) { return { achieved: false, gaps: ["evidence index incomplete"] }; } // Count criteria vs rows roughly const criteriaCount = (planMarkdown.match(/^##\s+Acceptance criteria\s*$/im) ? planMarkdown .split(/^##\s+Acceptance criteria\s*$/im)[1] ?.split(/^##\s+/im)[0] ?.split("\n") .filter((l) => /^\s*-\s+/.test(l) && !/\(none\)/.test(l)).length : 0) ?? 0; const rowCount = evidenceIndex .split("\n") .filter((l) => { const t = l.trim(); return t.startsWith("|") && !/^\|\s*-+/.test(t) && !/criterion/i.test(t); }).length; if (criteriaCount > 0 && rowCount < criteriaCount) { return { achieved: false, gaps: [`evidence index incomplete: ${rowCount}/${criteriaCount} criteria covered`], }; } return { achieved: true, gaps: [] }; } /** User-visible completion summary body (B007/B012). */ function buildCompletionSummary(details: { objective: string; goalId: string; receiptPath?: string | null; mode: "panel" | "degraded"; }): string { const receiptBit = details.receiptPath ? `Receipt: ${details.receiptPath}` : config.receipts ? "Receipt: (write skipped or unavailable)" : "Receipts disabled"; const modeBit = details.mode === "panel" ? "Verified by skeptic panel." : "Verified via degraded evidence path (no live skeptic panel)."; return [ `Goal complete: ${details.objective}`, `Goal id: ${details.goalId}`, modeBit, receiptBit, "Reload Pi (/reload) if UI settings or extensions changed and the UI looks stale.", ].join("\n"); } function formatStatusSummary(state: GoalOrchestration | null): string { if (!state) return "No active goal."; const lines = [ `Goal: ${state.goalId}`, `Objective: ${state.objective}`, `Status: ${state.status} · phase: ${state.phase}`, `Verify: ${state.verifyAttempts}/${state.verifyMax}`, `Plan: ${state.planPath}`, ]; if (state.pauseMessage) lines.push(`Pause: ${state.pauseMessage}`); if (state.lastGaps?.length) { lines.push("Last gaps:"); for (const g of state.lastGaps) lines.push(` - ${g}`); } return lines.join("\n"); } export default function goalExpanderExtension(pi: ExtensionAPI): void { // B031: loaded inside a pi-subagents child session — stay fully inert so goal // state and injections never leak into the subagent's conversation. if (subagentLoadDepth() > 0) return; const tracker = new GoalTracker({ stallThreshold: config.stallThreshold }); /** In-flight planner/verifier agent ids for best-effort cancel on pause/clear (B019). */ const trackedAgentIds = new Set(); /** Last next-step text injected while executing — debounce agent_end re-inject (B009). */ let lastInjectedNextStep: string | null = null; /** * Bumped on pause / clear / replace so an in-flight `update_goal(completed:true)` * aborts instead of completing or throwing after the panel returns (B019). */ let lifecycleEpoch = 0; /** Pause message written when startGoal supersedes a prior active goal (B017). */ const REPLACED_BY_NEW_GOAL = "replaced by new goal"; type OwnedTrackerResult = | { kind: "owned"; state: GoalOrchestration } | { kind: "none" } | { kind: "busy" }; /** * Refresh the durable lease and reject a tracker that is not this context's owner. * Lock contention is fail-closed as `busy` (no tracker clear, no throw). */ function ownedTrackerGoalResult( cwd: string, ctx: { sessionManager?: { getSessionId?: () => string } }, ): OwnedTrackerResult { const snap = tracker.snapshot(); const sessionId = sessionIdFor(ctx); if (!snap?.owner || !sessionId || snap.owner.sessionId !== sessionId) { tracker.clear(); return { kind: "none" }; } let restored; try { restored = renewGoalForOwnerToken(cwd, snap.goalId, ownerTokenFor(snap.owner)); } catch (err) { // Lock busy: do not clear tracker (ownership may still be valid) and do not throw. if (isGoalOwnershipLockBusy(err)) return { kind: "busy" }; tracker.clear(); return { kind: "none" }; } if (restored?.kind !== "owned") { tracker.clear(); return { kind: "none" }; } tracker.load(restored.state); return { kind: "owned", state: restored.state }; } function ownedTrackerGoal( cwd: string, ctx: { sessionManager?: { getSessionId?: () => string } }, ): GoalOrchestration | null { const result = ownedTrackerGoalResult(cwd, ctx); return result.kind === "owned" ? result.state : null; } /** * B031: lifecycle/custom events that carry a goalId are owner-token fenced. * After ownership loss, the former owner produces no goal-owned message effects. * Events without a goalId remain direct (observer-only, no active goal effect). * * `cwd` must be the operation/session repository root (ctx-derived), not * process.cwd() / ambient env alone — owner fencing locks the goal store for * that repository. */ function emitEvent(cwd: string, details: GoalEventDetails): void { try { const goalId = typeof details.goalId === "string" ? details.goalId : undefined; if (!goalId) { pi.sendMessage(buildGoalEvent(details), { deliverAs: "nextTurn" }); return; } const snap = tracker.snapshot(); const token = snap?.goalId === goalId && snap.owner ? ownerTokenFor(snap.owner) : null; if (!token) { // No local owner fence — suppress goal-owned lifecycle emission. return; } const result = withOwnedGoalLock(cwd, goalId, token, () => { pi.sendMessage(buildGoalEvent(details), { deliverAs: "nextTurn" }); return true as const; }); if (result.kind !== "owned") { tracker.clear(); lastInjectedNextStep = null; return; } tracker.load(result.state); } catch (err) { // Lock busy or send failure: no message effects. if (!isGoalOwnershipLockBusy(err)) { /* non-fatal */ } } } const refreshUi = (ctx: { ui?: { setStatus?: (k: string, t: string | undefined) => void } }) => { const snap = tracker.snapshot(); setStatusline(ctx, snap); }; function trackAgent(id: string | undefined | null): void { if (id && typeof id === "string") trackedAgentIds.add(id); } function trackAgents(ids: readonly string[] | undefined | null): void { if (!ids) return; for (const id of ids) trackAgent(id); } function bumpLifecycle(): void { lifecycleEpoch += 1; } /** * Best-effort stop of tracked planner/verifier agents; clears the set. * Also emits `subagents:failed` so local waitSubagent callers unblock promptly * when the host does not fan out a completion event after stop (B019). */ async function stopTrackedAgents(): Promise { const bus = getEventBus(pi); const ids = [...trackedAgentIds]; trackedAgentIds.clear(); if (!bus || ids.length === 0) return; await Promise.all( ids.map(async (id) => { try { await stopSubagent(bus, id); } catch { /* best-effort */ } try { bus.emit("subagents:failed", { id, status: "failed", error: "stopped by goal harness (pause/clear/replace)", }); } catch { /* best-effort */ } }), ); } type OwnedMessageSendResult = | { kind: "sent"; state: GoalOrchestration } | { kind: "refused"; reason: "foreign" | "unowned" | "missing" | "no_token" | "error" | "busy"; }; /** * B031: goal-owned transcript injects must send under the exact owner-token * lock so a concurrent takeover cannot land between ownership check and * pi.sendMessage. On refusal, clear stale tracker state and send nothing. * Lifecycle emitEvent payloads with goalId are also owner-fenced. */ function sendOwnedGoalMessage( cwd: string, goalId: string, token: GoalOwnerToken | null | undefined, message: unknown, opts?: { deliverAs?: "followUp" | "nextTurn"; triggerTurn?: boolean }, ): OwnedMessageSendResult { if (!token) { tracker.clear(); lastInjectedNextStep = null; return { kind: "refused", reason: "no_token" }; } try { const result = withOwnedGoalLock(cwd, goalId, token, () => { if (opts?.triggerTurn) { pi.sendMessage(message as never, { deliverAs: opts.deliverAs ?? "followUp", triggerTurn: true, }); } else { pi.sendMessage(message as never, { deliverAs: opts?.deliverAs ?? "followUp", }); } return true as const; }); if (result.kind !== "owned") { tracker.clear(); lastInjectedNextStep = null; return { kind: "refused", reason: result.kind }; } tracker.load(result.state); return { kind: "sent", state: result.state }; } catch (err) { // Lock busy or sendMessage failure — do not inject; no state effects on busy. if (isGoalOwnershipLockBusy(err)) { return { kind: "refused", reason: "busy" }; } return { kind: "refused", reason: "error" }; } } function injectNextStep( details: { objective: string; planPath: string; nextStep: string; goalId: string; reason: "kickoff" | "advanced" | "resume"; ownerToken?: GoalOwnerToken | null; }, opts?: { triggerTurn?: boolean; cwd?: string }, ): boolean { const cwd = opts?.cwd ?? goalCwd(); const token = details.ownerToken ?? (tracker.snapshot()?.goalId === details.goalId && tracker.snapshot()?.owner ? ownerTokenFor(tracker.snapshot()!.owner!) : null); const sent = sendOwnedGoalMessage( cwd, details.goalId, token, { customType: "pi-goal-expander:next_step", display: true, details: { kind: "next_step", goalId: details.goalId, nextStep: details.nextStep, reason: details.reason, }, content: buildNextStepMessage({ objective: details.objective, planPath: details.planPath, nextStep: details.nextStep, reason: details.reason, }), }, opts?.triggerTurn ? { deliverAs: "followUp", triggerTurn: true } : { deliverAs: "followUp" }, ); if (sent.kind === "sent") { lastInjectedNextStep = details.nextStep; return true; } return false; } /** * User-visible completion beat (B007/B012). * All goal-visible completion effects (toast + transcript inject) run under the * exact owner-token fence so a concurrent takeover suppresses every effect. */ function emitCompletionSummary( cwd: string, token: GoalOwnerToken | null | undefined, ctx: { ui?: { notify?: (m: string, l?: "info" | "warning" | "error") => void } }, details: { objective: string; goalId: string; receiptPath?: string | null; mode: "panel" | "degraded"; }, ): void { const summary = buildCompletionSummary(details); if (!token) { tracker.clear(); lastInjectedNextStep = null; return; } try { const result = withOwnedGoalLock(cwd, details.goalId, token, () => { try { ctx.ui?.notify?.(summary, "info"); } catch { /* non-fatal toast */ } pi.sendMessage( { customType: "pi-goal-expander:complete", display: true, details: { kind: "goal_complete_summary", goalId: details.goalId, objective: details.objective, receiptPath: details.receiptPath ?? undefined, mode: details.mode, }, content: summary, } as never, { deliverAs: "followUp" }, ); return true as const; }); if (result.kind !== "owned") { tracker.clear(); lastInjectedNextStep = null; return; } tracker.load(result.state); } catch (err) { // Lock busy or send failure: suppress all completion UI effects. if (!isGoalOwnershipLockBusy(err)) { /* non-fatal */ } } } // Restore active goal only when this Pi session owns its repository-local state. pi.on("session_start", (_event, ctx) => { const cwd = goalCwd(ctx); const existing = findActiveGoal(cwd); const sessionId = sessionIdFor(ctx); lastInjectedNextStep = null; trackedAgentIds.clear(); let restored: ReturnType = null; if (existing && sessionId) { try { restored = restoreGoalForOwner(cwd, existing.goalId, sessionId); } catch (err) { // Lock busy: no-effect restore — do not load tracker or emit events. if (isGoalOwnershipLockBusy(err)) { tracker.load(null); setStatusline(ctx, null); return; } throw err; } } if (restored?.kind === "owned") { tracker.load(restored.state); setStatusline(ctx, restored.state); emitEvent(cwd, { kind: "session_restore", goalId: restored.state.goalId, status: restored.state.status, phase: restored.state.phase, objective: restored.state.objective, }); } else { tracker.load(null); setStatusline(ctx, null); } }); // B009: after each agent run while active+executing, re-inject next step only if checklist advanced. pi.on("agent_end", (_event, ctx) => { const cwd = goalCwd(ctx); const snap = ownedTrackerGoal(cwd, ctx); if (!snap) return; if (snap.status !== "active" || snap.phase !== "executing") return; if (completedGoalIds.has(snap.goalId)) return; const planMd = readPlan(cwd, snap.goalId) ?? ""; const next = nextUncheckedStep(planMd); if (!next) return; if (next === lastInjectedNextStep) return; // debounce: same text injectNextStep( { objective: snap.objective, planPath: snap.planPath, nextStep: next, goalId: snap.goalId, reason: "advanced", ownerToken: snap.owner ? ownerTokenFor(snap.owner) : null, }, { triggerTurn: false, cwd }, ); }); /** * Create goal, run planner (subagent if available, else degraded expand), * snapshot baseline after plan, completePlanning, inject execute kickoff. */ async function startGoal( objective: string, ctx: { cwd?: string; ui?: { setStatus?: (k: string, t: string | undefined) => void; notify?: (m: string, l?: "info" | "warning" | "error") => void; }; /** Present on real ExtensionContext; used for ownership and planner context handoff. */ sessionManager?: { getSessionId?: () => string; getBranch?: () => unknown; }; }, ): Promise< | { ok: true; goalId: string; planPath: string; mode: "subagent" | "degraded" } | { ok: false; error: string } > { const cwd = goalCwd(ctx); const sessionId = sessionIdFor(ctx); if (!sessionId) { return { ok: false, error: "Goal ownership requires a Pi session identity." }; } const disk = findActiveGoal(cwd); if (disk && disk.owner?.sessionId !== sessionId) { return { ok: false, error: "A goal in this repository belongs to another session. Use /goal resume --takeover for a deliberate handoff.", }; } if (tracker.hasGoal() && !ownedTrackerGoal(cwd, ctx)) { return { ok: false, error: "The active goal is no longer owned by this session." }; } let createdGoalId: string | null = null; let createdOwnerToken: GoalOwnerToken | null = null; try { // B017: hard single active goal — replace prior in-memory active with notify. const prev = tracker.snapshot(); if (prev) { const prevId = prev.goalId; const prevObj = prev.objective; bumpLifecycle(); await stopTrackedAgents(); // Park previous on disk as user_paused (superseded). findActiveGoal skips // pauseMessage "replaced by new goal" so session restore won't resurrect it. try { tracker.pause("user", REPLACED_BY_NEW_GOAL); void persist(cwd, tracker); // best-effort park; ownership loss is non-fatal here } catch { /* best-effort */ } tracker.clear(); lastInjectedNextStep = null; ctx.ui?.notify?.( `Replaced active goal ${prevId} ("${prevObj.slice(0, 60)}") with new goal.`, "info", ); emitEvent(cwd, { kind: "goal_replaced", goalId: prevId, previousObjective: prevObj, }); } const created = tracker.createGoal({ objective, verifyMax: config.verifyMax, skepticN: config.skepticN, strategistEvery: config.strategistEvery, stallThreshold: config.stallThreshold, owner: createGoalOwner(sessionId), }); tracker.startPlanning(); const goalId = created.goalId; createdGoalId = goalId; createdOwnerToken = created.owner ? ownerTokenFor(created.owner) : null; ensureGoalDirs(cwd, goalId); // New goals have no prior disk record, so create their owner state before any fenced update. writeState(cwd, tracker.snapshot()!); const planPath = join(resolveGoalDir(cwd, goalId), "plan.md"); const baselinePath = join(resolveGoalDir(cwd, goalId), "plan.baseline.md"); const evidencePath = join(resolveGoalDir(cwd, goalId), "evidence", "index.md"); const mid = tracker.snapshot()!; mid.planPath = planPath; mid.planBaselinePath = baselinePath; tracker.load(mid); { const persisted = persist(cwd, tracker); if (!persisted.ok) { return { ok: false, error: persisted.error === "ownership_busy" ? "goal ownership is temporarily unavailable (lock busy)" : "ownership_lost", }; } } refreshUi(ctx); ctx.ui?.notify?.("Planning… (subagent if available)", "info"); emitEvent(cwd, { kind: "planning_started", goalId, status: "active", phase: "planning", objective, }); // Immediate LLM/user context: goal is set; planner may still be running. // Owner-fenced: transfer before inject must refuse and clear tracker. const setToken = createdOwnerToken ?? (tracker.snapshot()?.owner ? ownerTokenFor(tracker.snapshot()!.owner!) : null); const setSent = sendOwnedGoalMessage( cwd, goalId, setToken, { customType: "pi-goal-expander:goal_set", display: true, details: { kind: "goal_set", goalId, status: "active", phase: "planning", objective, plannerPending: true, }, content: buildGoalSetContextMessage({ objective, goalId, planPath, evidencePath, plannerPending: true, }), }, { deliverAs: "followUp" }, ); if (setSent.kind !== "sent") { return { ok: false, error: "goal ownership changed before goal_set inject" }; } const bus = getEventBus(pi); // Soft-degrade by default: if plannerRequired and subagents hard-fail we pause. // For interactive use we prefer degraded over pause so /goal always works. // Prefer session fork (inheritContext) + prompt handoff of parent branch so the // planner sees prior chat/research even if the host ignores inheritContext. const parentContext = extractParentContextText(ctx); // B029: planner wait is separate from panel skeptic waits. // Ownership epoch: pause/clear/replace mid-plan bumps this (mirrors verify path). const planningEpoch = lifecycleEpoch; const planner = await runPlanner({ objective, planPath, bus, timeoutMs: config.plannerTimeoutMs, requireSubagents: false, inheritContext: true, parentContext, onSpawned: (agentId) => { trackAgent(agentId); const cur = tracker.snapshot(); if (!cur || cur.goalId !== goalId) return; cur.plannerAgentId = agentId; tracker.load(cur); void persist(cwd, tracker); // best-effort statusline renew refreshUi(ctx); }, }); // B025: if runPlanner already stopped the agent (timeout), drop tracking. // Otherwise best-effort stop only on hard failure paths that left an id. if (planner.agentId) { if (!planner.ok && !planner.stopped) { try { const eventBus = getEventBus(pi); if (eventBus) { await stopSubagent(eventBus, planner.agentId); try { eventBus.emit("subagents:failed", { id: planner.agentId, status: "failed", error: "stopped by goal harness (planning failed)", }); } catch { /* best-effort */ } } } catch { /* best-effort */ } } trackedAgentIds.delete(planner.agentId); } // Abandon completePlanning/kickoff if goal was paused/cleared/replaced mid-wait. const stillOwned = (): boolean => { if (lifecycleEpoch !== planningEpoch) return false; const cur = ownedTrackerGoal(cwd, ctx); if (!cur || cur.goalId !== goalId) return false; if (cur.status !== "active") return false; if (cur.phase !== "planning") return false; return true; }; if (!stillOwned()) { refreshUi(ctx); emitEvent(cwd, { kind: "planning_abandoned", goalId, objective, plannerReason: planner.reason, error: "planning abandoned (goal paused, cleared, or replaced)", }); return { ok: false, error: "planning abandoned (goal paused, cleared, or replaced)", }; } if (!planner.ok) { // B024: wait_timeout (and other hard fails) → pause; do not execute/baseline draft. const failMsg = planner.reason === "wait_timeout" ? `Planner timed out after ${config.plannerTimeoutMs}ms (agent stopped; plan not baselined as draft). Write a real plan.md then /goal resume, or /goal clear and retry.` : (planner.error ?? "planner failed"); tracker.failPlanning(failMsg); { const persisted = persist(cwd, tracker); if (!persisted.ok) { return { ok: false, error: persisted.error === "ownership_busy" ? "goal ownership is temporarily unavailable (lock busy)" : "ownership_lost", }; } } refreshUi(ctx); try { ctx.ui?.notify?.(failMsg, "warning"); } catch { /* non-fatal */ } emitEvent(cwd, { kind: "planning_failed", goalId, status: "user_paused", phase: "idle", objective, error: planner.error ?? failMsg, plannerMode: planner.mode, plannerReason: planner.reason, agentId: planner.agentId, stopped: planner.stopped, }); // Visible follow-up so the model does not keep waiting for execute after goal_set. const failOwner = tracker.snapshot()?.owner; sendOwnedGoalMessage( cwd, goalId, failOwner ? ownerTokenFor(failOwner) : createdOwnerToken, { customType: "pi-goal-expander:planning_failed", display: true, details: { kind: "planning_failed", goalId, objective, plannerReason: planner.reason, error: planner.error ?? failMsg, }, content: [ "# Goal — planning failed", "", `**Objective:** ${objective}`, `**Reason:** \`${planner.reason}\``, `**Detail:** ${failMsg}`, `**Plan path:** \`${planPath}\``, "", "Do **not** implement the objective yet.", "Options: write a real plan to the plan path and `/goal resume`, or `/goal clear` and start over.", "Never treat a structural skeleton as the frozen acceptance contract.", ].join("\n"), }, { deliverAs: "followUp" }, ); return { ok: false, error: planner.error ?? failMsg }; } // Persist plan + refresh baseline to the *post-planner* contract (B003). // Only reached on ok:true (subagent success or true structural degrade). // Owner-token fenced under the same lock as the ownership check so a // takeover cannot land between validation and the plan artifact write. const planOwner = tracker.snapshot()?.owner; if (!planOwner) { tracker.clear(); return { ok: false, error: "goal ownership is unavailable" }; } const ownedPlan = writeOwnedPlan( cwd, goalId, planner.planMarkdown, ownerTokenFor(planOwner), { refreshBaseline: true }, ); if (ownedPlan.kind !== "owned") { tracker.clear(); return { ok: false, error: "goal ownership changed before plan persistence", }; } tracker.load(ownedPlan.state); const snap = tracker.completePlanning(); // Planner finished — clear statusline agent id for executing phase. if (snap.plannerAgentId) { delete snap.plannerAgentId; tracker.load(snap); } const afterPlan = tracker.snapshot()!; { const persisted = persist(cwd, tracker); if (!persisted.ok) { return { ok: false, error: persisted.error === "ownership_busy" ? "goal ownership is temporarily unavailable (lock busy)" : "ownership_lost", }; } } refreshUi(ctx); emitEvent(cwd, { kind: "planning_completed", goalId, status: afterPlan.status, phase: afterPlan.phase, objective, plannerMode: planner.mode, plannerReason: planner.reason, agentId: planner.agentId, }); const kickoff = buildExecuteKickoffPrompt({ objective, planPath, evidencePath, plannerMode: planner.mode, plannerReason: planner.reason, }); const rules = goalRulesText(objective, planPath); // B009: include first unchecked checklist item in kickoff guidance. const next = nextUncheckedStep(planner.planMarkdown); const nextBlock = next ? `\n\n---\n\n${buildNextStepMessage({ objective, planPath, nextStep: next, reason: "kickoff", })}` : ""; const kickoffOwner = tracker.snapshot()?.owner; const kickoffToken = kickoffOwner ? ownerTokenFor(kickoffOwner) : createdOwnerToken; const kickoffSent = sendOwnedGoalMessage( cwd, goalId, kickoffToken, { customType: "pi-goal-expander:goal_start", display: true, details: { kind: "goal_start", goalId, status: afterPlan.status, phase: afterPlan.phase, objective, plannerMode: planner.mode, plannerReason: planner.reason, nextStep: next ?? undefined, }, content: `${rules}\n\n---\n\n${kickoff}${nextBlock}`, }, { deliverAs: "followUp", triggerTurn: true }, ); if (kickoffSent.kind !== "sent") { return { ok: false, error: "goal ownership changed before execute kickoff", }; } if (next) lastInjectedNextStep = next; emitEvent(cwd, { kind: "goal_start", goalId, status: afterPlan.status, phase: afterPlan.phase, objective, plannerMode: planner.mode, plannerReason: planner.reason, agentId: planner.agentId, nextStep: next ?? undefined, }); return { ok: true, goalId, planPath, mode: planner.mode }; } catch (error) { if (createdGoalId) { try { tracker.clear(); if (createdOwnerToken) clearOwnedGoalDir(cwd, createdGoalId, createdOwnerToken); } catch { /* best-effort */ } refreshUi(ctx); } const message = error instanceof Error ? error.message : String(error); return { ok: false, error: message }; } } // ── /goal-expand ────────────────────────────────────────────── // Alias of /goal : starts the harness (research → expand → execute). pi.registerCommand("goal-expand", { description: "Start a goal: research codebase, expand plan.md, then execute", handler: async (args, ctx) => { const text = (args ?? "").trim(); if (!text) { ctx.ui.notify("Usage: /goal-expand ", "warning"); return; } const result = await startGoal(text, ctx); if (!result.ok) { ctx.ui.notify(`/goal-expand: ${result.error}`, "error"); return; } ctx.ui.notify( `Goal started (${result.mode} plan → execute): ${text}`, "info", ); }, }); // ── /goal ───────────────────────────────────────────────────── pi.registerCommand("goal", { description: "Goal harness: set objective, status, pause, resume, clear, expand, help", handler: async (args, ctx) => { const cwd = goalCwd(ctx); const raw = (args ?? "").trim(); const [head, ...rest] = raw.split(/\s+/); const cmd = (head ?? "").toLowerCase(); const remainder = rest.join(" ").trim(); if (cmd === "help" || cmd === "-h" || cmd === "--help") { ctx.ui.notify(GOAL_HELP_TEXT, "info"); return; } // status / empty — toast only; foreign state is visible but never activated locally. // Busy is deterministic no-effect: do not collapse into inactive/no-goal status. if (!raw || cmd === "status") { const sessionId = sessionIdFor(ctx); const ownedAtEntry = ownedTrackerGoalResult(cwd, ctx); if (ownedAtEntry.kind === "busy") { ctx.ui.notify( "Cannot status: goal ownership is temporarily unavailable (lock busy).", "warning", ); return; } let snap = ownedAtEntry.kind === "owned" ? ownedAtEntry.state : null; let disk: ReturnType = null; if (!snap) { try { disk = findActiveGoal(cwd); } catch (err) { if (isGoalOwnershipLockBusy(err)) { ctx.ui.notify( "Cannot status: goal ownership is temporarily unavailable (lock busy).", "warning", ); return; } throw err; } } if (!snap && disk && sessionId && disk.owner?.sessionId === sessionId) { try { const restored = restoreGoalForOwner(cwd, disk.goalId, sessionId); if (restored?.kind === "owned") { tracker.load(restored.state); snap = restored.state; } } catch (err) { if (isGoalOwnershipLockBusy(err)) { ctx.ui.notify( "Cannot status: goal ownership is temporarily unavailable (lock busy).", "warning", ); return; } throw err; } } refreshUi(ctx); if (snap) { ctx.ui.notify(formatStatusSummary(snap), "info"); } else if (disk) { const owner = disk.owner ? `${disk.owner.sessionId} (generation ${disk.owner.generation})` : "legacy state with no owner"; ctx.ui.notify( `${formatStatusSummary(disk)}\nOwnership: read-only; owned by ${owner}. Use /goal resume --takeover to transfer this repository goal.`, "warning", ); } else { ctx.ui.notify("No active goal.", "warning"); } return; } if (cmd === "pause") { const before = ownedTrackerGoal(cwd, ctx); if (!before) { ctx.ui.notify("No active goal owned by this session to pause.", "warning"); return; } bumpLifecycle(); await stopTrackedAgents(); // Mid-planning pause: fail planning (phase idle + planning_failed) so resume // cannot soft-seed a structural draft and re-enter execute (post-merge review). const snap = before.phase === "planning" ? tracker.failPlanning(remainder || "paused during planning") : tracker.pause("user", remainder || "user pause"); { const persisted = persist(cwd, tracker); if (!persisted.ok) { refreshUi(ctx); ctx.ui.notify( persisted.error === "ownership_busy" ? "Cannot pause: goal ownership is temporarily unavailable (lock busy)." : "Cannot pause: goal ownership changed before persistence.", "warning", ); return; } } refreshUi(ctx); emitEvent(cwd, { kind: "paused", goalId: snap.goalId, status: snap.status, phase: snap.phase, duringPlanning: before.phase === "planning", }); ctx.ui.notify( before.phase === "planning" ? "Goal paused during planning (planning cancelled)." : "Goal paused.", "info", ); return; } if (cmd === "resume") { const sessionId = sessionIdFor(ctx); if (!sessionId) { ctx.ui.notify("Cannot resume: Pi session identity is unavailable.", "warning"); return; } let before = ownedTrackerGoal(cwd, ctx); if (!before) { const disk = findActiveGoal(cwd); if (!disk) { ctx.ui.notify("No goal to resume.", "warning"); return; } try { if (disk.owner?.sessionId === sessionId) { const restored = restoreGoalForOwner(cwd, disk.goalId, sessionId); if (restored?.kind === "owned") before = restored.state; } else if (remainder === "--takeover") { const taken = takeOverGoal(cwd, disk.goalId, sessionId); if (taken.kind === "taken_over") before = taken.state; } else { ctx.ui.notify( "Goal is owned by another session in this repository. Plain /goal resume never transfers ownership; use /goal resume --takeover for a deliberate handoff.", "warning", ); return; } } catch (err) { if (isGoalOwnershipLockBusy(err)) { // No tracker mutation, no goal-owned messages on busy refusal. ctx.ui.notify( "Cannot resume: goal ownership is temporarily unavailable (lock busy).", "warning", ); return; } throw err; } if (before) tracker.load(before); } if (!before) { ctx.ui.notify("No goal to resume.", "warning"); return; } const existingPlan = readPlan(cwd, before.goalId); const planningFailed = before.history?.some((h) => h.event === "planning_failed") === true || before.phase === "idle" || before.phase === "planning"; // B024 recovery: never seed structural draft + execute after planner failure // or while still in planning phase (mid-plan pause without failPlanning). if (!existingPlan?.trim()) { if (planningFailed) { ctx.ui.notify( "Cannot resume: plan.md missing after planning failure. Write a real plan to the plan path, then resume; or /goal clear and retry.", "warning", ); return; } // Legacy path: plan missing for other pauses — soft seed only when not post-planning-fail. // Owner-fenced: same lock validates {sessionId,generation} before writing. const seedOwner = before.owner; if (!seedOwner) { ctx.ui.notify("Cannot resume: goal ownership is unavailable.", "warning"); return; } const planMd = planFromExpansion(before.objective); const seeded = writeOwnedPlan( cwd, before.goalId, planMd, ownerTokenFor(seedOwner), ); if (seeded.kind !== "owned") { tracker.clear(); ctx.ui.notify( "Cannot resume: goal ownership changed before plan seed.", "warning", ); return; } tracker.load(seeded.state); before = seeded.state; } else if ( planningFailed && isDraftEquivalentPlan(existingPlan, before.objective) ) { ctx.ui.notify( "Cannot resume: plan.md is still a structural draft after planning failure. Expand the plan on disk first, then /goal resume.", "warning", ); return; } const snap = tracker.resume(); { const persisted = persist(cwd, tracker); if (!persisted.ok) { refreshUi(ctx); ctx.ui.notify( persisted.error === "ownership_busy" ? "Cannot resume: goal ownership is temporarily unavailable (lock busy)." : "Cannot resume: goal ownership changed before persistence.", "warning", ); return; } } refreshUi(ctx); const planPath = snap.planPath; const planMd = readPlan(cwd, snap.goalId) ?? ""; const next = nextUncheckedStep(planMd); const continuation = buildContinuationDirective({ objective: snap.objective, planPath, gaps: snap.lastGaps ?? [], nextStep: next, strategyNote: snap.lastStrategyRecommendation, verifyRound: snap.verifyAttempts, }); const resumeOwner = tracker.snapshot()?.owner; const resumeSent = sendOwnedGoalMessage( cwd, snap.goalId, resumeOwner ? ownerTokenFor(resumeOwner) : null, { customType: "pi-goal-expander:continuation", display: true, details: { goalId: snap.goalId, kind: "resume" }, content: `${goalRulesText(snap.objective, planPath)}\n\n${continuation}`, }, { deliverAs: "followUp" }, ); if (resumeSent.kind !== "sent") { ctx.ui.notify( "Cannot resume: goal ownership changed before continuation inject.", "warning", ); return; } if (next) lastInjectedNextStep = next; emitEvent(cwd, { kind: "resumed", goalId: snap.goalId, status: snap.status }); ctx.ui.notify("Goal resumed.", "info"); return; } if (cmd === "clear") { const ownedAtClear = ownedTrackerGoalResult(cwd, ctx); if (ownedAtClear.kind === "busy") { ctx.ui.notify( "Cannot clear: goal ownership is temporarily unavailable (lock busy).", "warning", ); return; } const snap = ownedAtClear.kind === "owned" ? ownedAtClear.state : null; if (!snap?.owner) { ctx.ui.notify("No active goal owned by this session to clear.", "warning"); return; } const clearedGoalId = snap.goalId; const clearToken = ownerTokenFor(snap.owner); let cleared: ReturnType; try { // Cleared lifecycle event is owner-fenced: sent under the same lock as // the token check, before delete, so a transfer cannot interleave a // stale emission after clear returns. cleared = clearOwnedGoalDir(cwd, clearedGoalId, clearToken, () => { pi.sendMessage(buildGoalEvent({ kind: "cleared", goalId: clearedGoalId }), { deliverAs: "nextTurn", }); }); } catch (err) { if (isGoalOwnershipLockBusy(err)) { ctx.ui.notify( "Cannot clear: goal ownership is temporarily unavailable (lock busy).", "warning", ); return; } throw err; } if (cleared !== "cleared") { ctx.ui.notify("Goal ownership changed before clear; disk state was left intact.", "warning"); tracker.clear(); refreshUi(ctx); return; } bumpLifecycle(); await stopTrackedAgents(); tracker.clear(); lastInjectedNextStep = null; refreshUi(ctx); ctx.ui.notify("Goal cleared.", "info"); return; } if (cmd === "expand") { // /goal expand — same as start (research → expand → execute) const text = remainder; if (!text) { ctx.ui.notify("Usage: /goal expand ", "warning"); return; } const result = await startGoal(text, ctx); if (!result.ok) { ctx.ui.notify(`/goal expand: ${result.error}`, "error"); return; } ctx.ui.notify(`Goal started (${result.mode} plan → execute): ${text}`, "info"); return; } // Otherwise: treat full args as objective → start harness const objective = raw; const result = await startGoal(objective, ctx); if (!result.ok) { ctx.ui.notify(`/goal: ${result.error}`, "error"); return; } ctx.ui.notify(`Goal started (${result.mode} plan → execute): ${objective}`, "info"); }, }); // ── goal_expand tool ────────────────────────────────────────── pi.registerTool({ name: "goal_expand", label: "Expand Goal", description: "Start the goal harness for a rough objective: run planner (pi-subagents if available) to write plan.md, then kick off an execute turn. Prefer this (or /goal) when the user states a goal to pursue.", promptSnippet: "Start a goal: plan via subagent (or degraded), then execute.", promptGuidelines: [ "Use goal_expand (or tell the user /goal ) when the user states a goal to pursue.", "After calling it, follow the injected execute kickoff: read plan.md, implement, fill evidence.", "Do not treat a degraded structural draft as final criteria — tighten the plan if still skeletal.", ], parameters: Type.Object({ goal: Type.String({ description: "Rough goal text to expand and start pursuing." }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const goal = String(params.goal ?? "").trim(); if (!goal) { return { content: [{ type: "text" as const, text: "goal_expand failed: goal must be a non-empty string" }], details: { ok: false, error: "empty" }, }; } const result = await startGoal(goal, ctx ?? {}); if (!result.ok) { return { content: [{ type: "text" as const, text: `goal_expand failed: ${result.error}` }], details: { ok: false, error: result.error }, }; } return { content: [ { type: "text" as const, text: `Goal started (${result.goalId}, planner=${result.mode}). Plan at ${result.planPath}. Execute kickoff turn was triggered.`, }, ], details: { ok: true, kind: "goal_start", goalId: result.goalId, planPath: result.planPath, plannerMode: result.mode, }, }; }, }); // ── update_goal tool ────────────────────────────────────────── pi.registerTool({ name: "update_goal", label: "Update Goal", description: "Report progress on the active goal, mark blocked, or request completion verification. completed:true runs preverify, then (when pi-subagents is available) a blocking skeptic panel — the model cannot self-certify.", promptSnippet: "Update active goal progress, block, or request completion verification.", promptGuidelines: [ "Call update_goal(message) for progress notes while working — required milestones: researched, implementing, pre-complete.", "Call update_goal(completed:true) only when you believe acceptance criteria are met and evidence/index.md is filled.", "Call update_goal(blocked_reason) only after genuine stuckness.", "After a goal is Achieved, do not call update_goal again for that goal; the harness stays quiet.", ], parameters: Type.Object({ message: Type.Optional(Type.String({ description: "Progress note to log." })), completed: Type.Optional(Type.Boolean({ description: "Request completion verification." })), blocked_reason: Type.Optional(Type.String({ description: "Why the goal is stuck." })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const cwd = goalCwd(ctx); // B031: tools bind only to the in-memory goal when this session still owns it. // Disk load is reserved for session_start and explicit /goal resume. const ownedAtEntry = ownedTrackerGoalResult(cwd, ctx ?? {}); if (ownedAtEntry.kind === "busy") { return ownershipBusyToolResult(); } if (ownedAtEntry.kind !== "owned") { return { content: [ { type: "text" as const, text: "No active goal in this session. Use /goal (or /goal resume after session restore via status).", }, ], details: { ok: false, error: "no_active_goal" }, }; } // B031 test hook: allow simulated transfer after ownership entry check. if (ownershipRaceHookForTests) { const hook = ownershipRaceHookForTests; ownershipRaceHookForTests = null; hook(); } const notes: string[] = []; if (params.message?.trim()) { const text = params.message.trim(); const snap = tracker.noteProgress(text); const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } setStatusline(ctx, persisted.state); // Do not emit events after ownership refusal (handled above). emitEvent(cwd, { kind: "progress", goalId: persisted.state.goalId, status: persisted.state.status, phase: persisted.state.phase, message: text, }); notes.push(`Logged: ${text}`); } if (params.blocked_reason?.trim()) { const snap = tracker.requestBlocked(params.blocked_reason.trim()); const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } setStatusline(ctx, persisted.state); emitEvent(cwd, { kind: "blocked_attempt", goalId: persisted.state.goalId, status: persisted.state.status, }); notes.push( persisted.state.status === "blocked" ? `Goal blocked: ${params.blocked_reason.trim()}` : `Blocked attempt recorded (${persisted.state.blockedAttempts}/3): ${params.blocked_reason.trim()}`, ); return { content: [{ type: "text" as const, text: notes.join("\n") }], details: { ok: true, status: persisted.state.status, blockedAttempts: persisted.state.blockedAttempts, }, }; } if (params.completed === true) { const current = tracker.snapshot()!; // B012: already complete — stay quiet, no continuation injects if (current.status === "complete" || completedGoalIds.has(current.goalId)) { completedGoalIds.add(current.goalId); return { content: [ { type: "text" as const, text: `Goal already complete: ${current.objective}. No further harness action.`, }, ], details: { ok: true, achieved: true, status: "complete", alreadyComplete: true }, }; } // Snapshot identity for abort checks after await (pause/clear/replace mid-panel). const goalIdAtStart = current.goalId; const epochAtStart = lifecycleEpoch; /** True iff this verify still owns the in-memory goal (B019). */ const verifyStillOwned = (): boolean => { if (lifecycleEpoch !== epochAtStart) return false; const s = ownedTrackerGoal(cwd, ctx ?? {}); if (!s || s.goalId !== goalIdAtStart) return false; if (tracker.isPaused()) return false; if (s.status === "complete") return false; return true; }; const abortVerify = (why: string) => ({ content: [ { type: "text" as const, text: `Verification aborted: ${why}`, }, ], details: { ok: false, achieved: false, aborted: true, reason: why, goalId: goalIdAtStart, }, }); const planMd = readPlan(cwd, current.goalId) ?? ""; const evidence = readEvidenceIndex(cwd, current.goalId); // Enter verify phase (history: verify_started on tracker). // Always emit verify_started for dogfood/debug; panel path may re-emit with agentIds. let snap = tracker.beginVerify(); if (snap.status === "back_off_paused") { const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } setStatusline(ctx, persisted.state); emitEvent(cwd, { kind: "verify_paused", goalId: snap.goalId, status: snap.status, phase: snap.phase, objective: snap.objective, reason: "verify_cap", verifyMax: snap.verifyMax, }); return { content: [ { type: "text" as const, text: `Verify cap reached (${snap.verifyMax}). Goal paused. Use /goal resume to continue.`, }, ], details: { ok: false, status: snap.status, achieved: false }, }; } { const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } snap = persisted.state; } setStatusline(ctx, snap); emitEvent(cwd, { kind: "verify_started", goalId: snap.goalId, status: snap.status, phase: snap.phase, objective: snap.objective, verifyAttempts: snap.verifyAttempts, verifyMax: snap.verifyMax, }); // Helper: NotAchieved path with optional continuation (suppressed if already complete id) const failVerify = (gaps: string[], label: string) => { if (!verifyStillOwned()) { return abortVerify("goal paused, cleared, or replaced during verification"); } const fp = gapFingerprint(gaps); snap = tracker.recordVerifyResult({ achieved: false, gaps, fingerprint: fp }); { const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } snap = persisted.state; } setStatusline(ctx, snap); if (!completedGoalIds.has(snap.goalId) && snap.status !== "complete") { const next = nextUncheckedStep(planMd); const continuation = buildContinuationDirective({ objective: snap.objective, planPath: snap.planPath, gaps, nextStep: next, strategyNote: snap.lastStrategyRecommendation, verifyRound: snap.verifyAttempts, }); const contOwner = snap.owner; const contSent = sendOwnedGoalMessage( cwd, snap.goalId, contOwner ? ownerTokenFor(contOwner) : null, { customType: "pi-goal-expander:continuation", display: true, details: { kind: "not_achieved", goalId: snap.goalId, gaps }, content: continuation, }, { deliverAs: "followUp" }, ); if (contSent.kind === "sent" && next) { lastInjectedNextStep = next; } } emitEvent(cwd, { kind: "verify_not_achieved", goalId: snap.goalId, status: snap.status, gaps, }); return { content: [ { type: "text" as const, text: `NotAchieved (${label}):\n${gaps.map((g) => `- ${g}`).join("\n")}`, }, ], details: { ok: true, achieved: false, gaps, status: snap.status }, }; }; // 1) Preverify — fail here skips skeptic panel (no token spend) const io = fileIoForCwd(cwd); const pre = runPreverify({ planMarkdown: planMd, evidenceIndexMarkdown: evidence ?? undefined, fileExists: io.fileExists, readText: io.readText, }); const preGaps = [...pre.gaps]; if (!evidence?.trim()) { preGaps.push("evidence index incomplete"); } const preOk = pre.ok && Boolean(evidence?.trim()) && !preGaps.some( (g) => g.includes("incomplete") || g.includes("missing evidence") || g.includes("empty artifact") || g.includes("missing on disk") || g.includes("json assertion failed") || g.includes("plan not expanded beyond draft"), ); if (!preOk && (preGaps.length > 0 || !evidence?.trim())) { const gaps = preGaps.length > 0 ? preGaps.filter((g) => g !== "no evidence index yet") : ["evidence index incomplete"]; if (gaps.length === 0) gaps.push("evidence index incomplete"); return failVerify(gaps, "preverify"); } // 2) Degraded structural verify (evidence completeness) — still required // even when a live panel will run (cheap gate before skeptic spend). const degraded = degradedVerify(planMd, evidence, cwd); if (!degraded.achieved) { return failVerify(degraded.gaps, "evidence"); } // 3) Critical-path skeptic panel when pi-subagents bus is alive. // Policy: bus missing / ping fail → degraded Achieved (preverify+evidence passed). // Bus present but panel total failure/timeout → fail-CLOSED NotAchieved // ("verifier panel unavailable") for dogfood honesty (B011/B015). if (!verifyStillOwned()) { return abortVerify("goal paused, cleared, or replaced during verification"); } const bus = getEventBus(pi); let verifyMode: "panel" | "degraded" = "degraded"; let panelAgentIds: string[] = []; if (bus) { const canPing = await pingSubagents(bus, 1500); if (canPing) { // B020: N from plan Goal kind (analysis/research → 1, code-change → 3); // config.skepticN is fallback when kind is missing/unknown. const skepticN = resolveSkepticNForVerify({ planMarkdown: planMd, configSkepticN: config.skepticN, }); // Blocking panel on critical path: spawn → emit verify_started(agentIds) // → wait → aggregate. No fire-and-forget after Achieved (B011/B015). const panel = await runSkepticPanel(bus, { objective: current.objective, planMarkdown: planMd, evidenceIndex: evidence ?? undefined, gaps: current.lastGaps, skepticN, timeoutMs: config.subagentsTimeoutMs, onSpawned: (agentIds) => { panelAgentIds = agentIds; trackAgents(agentIds); emitEvent(cwd, { kind: "verify_started", goalId: snap.goalId, status: snap.status, phase: "verifying", objective: snap.objective, agentIds, skepticN, }); }, }); panelAgentIds = panel.agentIds.length > 0 ? panel.agentIds : panelAgentIds; // Panel done — clear tracked verifier ids so later pause doesn't re-stop them. for (const id of panelAgentIds) trackedAgentIds.delete(id); // B019: pause/clear/replace during panel must not apply results. if (!verifyStillOwned()) { return abortVerify("goal paused, cleared, or replaced during verification"); } if (panel.panelUnavailable) { // Bus present but total panel failure/timeout → fail-CLOSED return failVerify( panel.gaps.length > 0 ? panel.gaps : ["verifier panel unavailable"], "panel", ); } if (!panel.ran) { // Unexpected: ping ok then no run without panelUnavailable — degraded verifyMode = "degraded"; } else if (!panel.achieved) { return failVerify( panel.gaps.length > 0 ? panel.gaps : ["skeptic panel refuted achievement"], "panel", ); } else { verifyMode = "panel"; } } } // 4) Achieved (panel pass or degraded path) if (!verifyStillOwned()) { return abortVerify("goal paused, cleared, or replaced during verification"); } snap = tracker.recordVerifyResult({ achieved: true, gaps: [] }); { const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } snap = persisted.state; } setStatusline(ctx, snap); completedGoalIds.add(snap.goalId); let receiptPath: string | null = null; if (config.receipts) { try { const receiptOwner = snap.owner; if (receiptOwner) { const ownedReceipt = writeOwnedReceipt( cwd, snap.goalId, { summary: `Goal achieved: ${snap.objective}`, plan: planMd, evidence: evidence ?? "", }, ownerTokenFor(receiptOwner), ); if (ownedReceipt.kind === "owned") { receiptPath = ownedReceipt.value; tracker.load(ownedReceipt.state); snap = ownedReceipt.state; } } } catch { /* non-fatal */ } } emitEvent(cwd, { kind: "verify_achieved", goalId: snap.goalId, status: snap.status, objective: snap.objective, mode: verifyMode, agentIds: panelAgentIds.length > 0 ? panelAgentIds : undefined, receiptPath: receiptPath ?? undefined, }); emitEvent(cwd, { kind: "goal_completed", goalId: snap.goalId, status: snap.status, phase: snap.phase, objective: snap.objective, mode: verifyMode, receiptPath: receiptPath ?? undefined, }); emitCompletionSummary( cwd, snap.owner ? ownerTokenFor(snap.owner) : null, ctx, { objective: snap.objective, goalId: snap.goalId, receiptPath, mode: verifyMode, }, ); return { content: [ { type: "text" as const, text: `Achieved. Goal complete: ${snap.objective}`, }, ], details: { ok: true, achieved: true, status: snap.status, mode: verifyMode, agentIds: panelAgentIds, receiptPath, }, }; } // message-only or empty const persisted = persist(cwd, tracker); if (!persisted.ok) { return ownershipFenceToolResult(persisted); } setStatusline(ctx, persisted.state); if (notes.length === 0) { notes.push("No-op: provide message, completed, or blocked_reason."); } return { content: [{ type: "text" as const, text: notes.join("\n") }], details: { ok: true, status: persisted.state.status }, }; }, }); }