/** * Run the goal planner role: prefer pi-subagents, else degraded parent path. * * Policy (B024–B028 / B031): * - True unavailability (no bus / ping fail) → structural degrade when * requireSubagents is false. * - After a planner is spawned, wait timeout is fail-CLOSED (never silent * degrade + execute). The agent is stopped so it cannot race the baseline. * - Structural draft is never treated as authoritative over a non-draft plan * already on disk (B027 read-only check); this module never writes plan.md. * - Late planner results after stop are intentionally ignored (B028 policy A). * - Canonical plan/baseline publication is owned solely by the extension * (owner-fenced writeOwnedPlan in index.ts). runPlanner returns markdown only. * - Trusted planner children run in an isolated staging cwd and never receive * the canonical plan path. B031 does not provide an OS filesystem sandbox; * only returned markdown is authoritative and the parent publishes artifacts. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { planFromExpansion, buildPlannerPrompt } from "./planner.ts"; import { pingSubagents, spawnSubagent, stopSubagent, waitSubagent, type EventBus, } from "../subagents/client.ts"; export type PlannerMode = "subagent" | "degraded"; /** Why planning ended this way (B030 / B026). */ export type PlannerReason = | "ok" | "no_bus" | "ping_failed" | "wait_timeout" | "spawn_error" | "agent_failed" | "structural_fallback"; export interface RunPlannerInput { objective: string; /** * Informational path where the harness will later publish the canonical plan. * Never written or read for authority by this module, and never exposed to the * planner child. Only the returned planMarkdown is accepted for later * owner-fenced publication by the parent harness. */ planPath: string; bus?: EventBus | null; /** Timeout for planner wait (default 30m — use config.plannerTimeoutMs). */ timeoutMs?: number; /** Max turns for planner subagent. */ maxTurns?: number; /** * If true and subagents unavailable, return error (fail-CLOSED). * If false, fall back to structural expand (degraded) only for true * unavailability — not for wait timeout after spawn. */ requireSubagents?: boolean; /** * Prefer forking parent session into the planner (pi-subagents inheritContext). * Default true so the planner sees parent conversation history. * Set false only for isolated/regression tests. */ inheritContext?: boolean; /** * Optional parent-transcript handoff when fork/inherit is unavailable or as a * belt-and-suspenders inject into the planner prompt. Truncate large histories * at the call site if needed. */ parentContext?: string; /** Fired when planner subagent spawns (statusline + B019 cancel tracking). */ onSpawned?: (agentId: string) => void; /** * Optional absolute staging directory for the planner child cwd. When omitted, * runPlanner creates (and cleans up) a unique temp staging dir so the child * cannot write the canonical goal plan path via relative tools. */ plannerCwd?: string; } export interface RunPlannerResult { ok: boolean; mode: PlannerMode; reason: PlannerReason; /** Plan body for the harness to publish via owner-fenced writeOwnedPlan. */ planMarkdown: string; agentId?: string; error?: string; /** True when this result path stopped the planner agent (timeout/abandon). */ stopped?: boolean; } /** Default planner wait when caller omits timeoutMs (30 minutes). */ export const DEFAULT_PLANNER_TIMEOUT_MS = 1_800_000; const PLAN_HEADING = /^#\s+Plan:/m; export function looksLikePlan(md: string): boolean { return PLAN_HEADING.test(md) && /##\s+Acceptance criteria/i.test(md); } /** * True only for waitSubagent timeouts — not spawn timeouts. * spawn uses label "subagents spawn timed out after Nms" which must map to spawn_error. */ export function isPlannerWaitTimeoutError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return /wait subagent .+ timed out after \d+ms/i.test(msg); } /** * Extract plan markdown from a subagent result string. * Prefers a fenced ```markdown block or content starting with `# Plan:`. */ export function extractPlanMarkdown(result: string | undefined, fallback: string): string { if (!result?.trim()) return fallback; const text = result.trim(); const fence = text.match(/```(?:markdown|md)?\s*([\s\S]*?)```/i); if (fence?.[1] && looksLikePlan(fence[1])) { return fence[1].trim() + "\n"; } const idx = text.search(PLAN_HEADING); if (idx >= 0) { const slice = text.slice(idx).trim() + "\n"; if (looksLikePlan(slice)) return slice; } if (looksLikePlan(text)) return text.endsWith("\n") ? text : text + "\n"; return fallback; } /** * Structural draft selection — memory only. Never reads or writes planPath. * Child/canonical on-disk plan content is intentionally ignored here so a * misbehaving planner agent cannot inject plan text into publication. */ export function chooseStructuralDraftIfAllowed( _planPath: string, draft: string, _objective: string, ): string { return draft; } /** * @deprecated Prefer chooseStructuralDraftIfAllowed (no disk I/O). * Kept as a pure alias so older imports resolve; never reads or writes disk. */ export function writeStructuralDraftIfAllowed( planPath: string, draft: string, objective: string, ): string { return chooseStructuralDraftIfAllowed(planPath, draft, objective); } async function stopPlannerAgent(bus: EventBus, agentId: string): Promise { try { await stopSubagent(bus, agentId); } catch { /* best-effort */ } // Unblock any remaining waiters; marks agent failed for late-completion ignore (B028). try { bus.emit("subagents:failed", { id: agentId, status: "failed", error: "stopped by goal harness (planner timeout/abandon)", }); } catch { /* best-effort */ } } /** * Build planner prompt body, optionally prepending parent conversation handoff. * Used when inheritContext is off or as supplemental context text. * * B031: never expose the canonical plan path to the planner child. The parent * harness publishes plan.md via owner-fenced writeOwnedPlan after planning. * The child may only return plan markdown in its final message; any local * writes stay inside the isolated planner staging cwd. */ export function buildPlannerTaskPrompt(input: { objective: string; /** @deprecated ignored — canonical path must not be exposed to the child. */ planPath?: string; parentContext?: string; /** Isolated staging cwd the child was spawned into (informational only). */ plannerCwd?: string; }): string { const parts: string[] = []; const parent = input.parentContext?.trim(); if (parent) { parts.push( "# Parent conversation context", "The following is history from the parent session that spawned you.", "Use it to understand prior research, decisions, and constraints.", "", parent, "", "---", "# Your task (plan only)", "", ); } const stagingNote = input.plannerCwd ? `Your working directory is an isolated staging area (${input.plannerCwd}). Writes there are discarded.` : "Your working directory is an isolated staging area. Writes there are discarded."; parts.push( buildPlannerPrompt(input.objective), "", "Return the full plan markdown in your final message only.", "Do NOT write plan.md, plan.baseline.md, state.json, or any path under `.pi/goal/`.", "Do NOT use write/edit tools to create or update any canonical goal plan file.", "The parent harness alone publishes the canonical plan after you return markdown.", stagingNote, "", "Do NOT implement the objective — plan only.", "Research the codebase first (read/search) before writing criteria.", "You inherit parent session context when spawn used inheritContext/fork;", "still re-check the codebase rather than trusting memory alone.", ); return parts.join("\n"); } /** * Run planner. * * Subagent path: spawn Explore-like agent with plan-only prompt into an isolated * staging cwd; prefer inheritContext (parent session fork) so the planner sees * chat history; wait; extract plan markdown from the wait/RPC result only. * Never reads or writes the canonical planPath — child on-disk writes stay in * staging and are non-authoritative. * Degraded: structural expand via planFromExpansion — returns markdown only; * only when subagents are truly unavailable, never on wait timeout after spawn. */ export async function runPlanner(input: RunPlannerInput): Promise { const timeoutMs = input.timeoutMs ?? DEFAULT_PLANNER_TIMEOUT_MS; const draft = planFromExpansion(input.objective); const inheritContext = input.inheritContext !== false; const requireSubagents = Boolean(input.requireSubagents); const bus = input.bus; let agentId: string | undefined; let stagingDir: string | null = null; let ownsStaging = false; const cleanupStaging = () => { if (ownsStaging && stagingDir) { try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best-effort */ } } }; if (!bus) { if (requireSubagents) { return { ok: false, mode: "subagent", reason: "no_bus", planMarkdown: draft, error: "no event bus (pi.events unavailable)", }; } const planMarkdown = chooseStructuralDraftIfAllowed( input.planPath, draft, input.objective, ); return { ok: true, mode: "degraded", reason: "no_bus", planMarkdown, }; } const alive = await pingSubagents(bus, 2000); if (!alive) { if (requireSubagents) { return { ok: false, mode: "subagent", reason: "ping_failed", planMarkdown: draft, error: "pi-subagents not available (ping failed)", }; } const planMarkdown = chooseStructuralDraftIfAllowed( input.planPath, draft, input.objective, ); return { ok: true, mode: "degraded", reason: "ping_failed", planMarkdown, }; } try { if (input.plannerCwd) { stagingDir = input.plannerCwd; ownsStaging = false; } else { stagingDir = mkdtempSync(join(tmpdir(), "pi-goal-planner-")); ownsStaging = true; } const prompt = buildPlannerTaskPrompt({ objective: input.objective, parentContext: input.parentContext, plannerCwd: stagingDir, }); const spawned = await spawnSubagent( bus, { type: "Explore", prompt, description: `goal-planner: ${input.objective.slice(0, 40)}`, maxTurns: input.maxTurns ?? 25, isBackground: true, inheritContext, // Isolate tool cwd so relative writes cannot touch the canonical plan. cwd: stagingDir, }, 30_000, ); agentId = spawned.id; try { input.onSpawned?.(agentId); } catch { /* non-fatal statusline / tracking hook */ } const waited = await waitSubagent(bus, agentId, timeoutMs); if (waited.status === "failed") { const errText = waited.error ?? "planner subagent failed"; // B019/B024: harness stop (pause/clear/replace/timeout abandon) unblocks wait via // subagents:failed — must not soft-degrade into execute (that undoes user_paused). const harnessStopped = /stopped by goal harness/i.test(errText); if (requireSubagents || harnessStopped) { cleanupStaging(); return { ok: false, mode: "subagent", reason: "agent_failed", planMarkdown: draft, agentId, error: errText, stopped: harnessStopped, }; } // Soft degrade only after agent-reported failure (not timeout / harness stop). const planMarkdown = chooseStructuralDraftIfAllowed( input.planPath, draft, input.objective, ); cleanupStaging(); return { ok: true, mode: "degraded", reason: "agent_failed", planMarkdown, agentId, error: errText, }; } // Accept plan text only from the wait/RPC result (or structural draft // fallback). Never promote a child write of the canonical plan path. const planMd = extractPlanMarkdown(waited.result, draft); cleanupStaging(); return { ok: true, mode: "subagent", reason: "ok", planMarkdown: planMd, agentId, }; } catch (err) { const error = err instanceof Error ? err.message : String(err); const timedOut = isPlannerWaitTimeoutError(err); // B025: stop orphaned planner on timeout/abandon after spawn if (agentId) { await stopPlannerAgent(bus, agentId); } if (timedOut) { // B024: fail-CLOSED — never silent degrade + execute after spawn timeout. // B027/B031: planMarkdown is non-authoritative when ok:false; do not // surface any on-disk child write as the result body. cleanupStaging(); return { ok: false, mode: "subagent", reason: "wait_timeout", planMarkdown: "", agentId, error, stopped: true, }; } // Spawn / other errors before or without a settled wait if (requireSubagents) { cleanupStaging(); return { ok: false, mode: "subagent", reason: "spawn_error", planMarkdown: draft, agentId, error, stopped: Boolean(agentId), }; } const planMarkdown = chooseStructuralDraftIfAllowed( input.planPath, draft, input.objective, ); cleanupStaging(); return { ok: true, mode: "degraded", reason: "spawn_error", planMarkdown, agentId, error, stopped: Boolean(agentId), }; } }