From 3f0aadfffa9fc5c8a6f8fc2697c81249dd505ef7 Mon Sep 17 00:00:00 2001 From: wassname2 Date: Thu, 3 Sep 2026 14:12:34 +0800 Subject: [PATCH] Add persistent plan steward --- README.md | 22 +- .../2026-09-03_persistent-plan-steward.md | 37 ++ src/index.ts | 360 ++++++++++++++++-- src/prompts.ts | 76 +++- src/steward.ts | 156 ++++++++ test/goals-flow.test.ts | 227 ++++++++++- test/steward.test.ts | 64 ++++ 7 files changed, 909 insertions(+), 33 deletions(-) create mode 100644 docs/spec/2026-09-03_persistent-plan-steward.md create mode 100644 src/steward.ts create mode 100644 test/steward.test.ts diff --git a/README.md b/README.md index 08098a2..46c060f 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,26 @@ pi -e ./src/index.ts After eight turns without a change above `## Log`, the working set is sent back with a short upkeep reminder. +Optional persistent perspective requires the separate `pi-subagents` package (`pi install +npm:pi-subagents`, then restart or reload Pi). `/goals steward on` forks one non-writing Oracle +when Ready is selected. Work waits for its plan decision. The child process exits after the +review, while its session is retained. The first `CompleteGoal` call resumes that same session for a +trajectory and scope check; after approval, a second call runs the normal fresh evidence judge. The +steward cannot complete goals or make unresolved human decisions. `/goals steward off` disables it. +Between reviews there is no running child process or model call. The retained session receives a +bounded contract view: evidence detail stays with the fresh judge, approved checkbox state is +normalized, and current goal status remains visible. The Oracle's profile includes inspection-only +bash by contract; pi-goals rejects its decision if pi-subagents reports a file-mutation effect. This +is not an OS sandbox. If pi-subagents is absent, Ready stays in planning after a visible RPC timeout; +install it, retry Ready, or use `/goals steward off`. The integration is process-local and does not +require `pi-intercom`. + Other commands: `/goals --clear` disconnects this session from its active plan, preserving the versioned file on disk; `/goals --auto [minutes|off]` continues active goals after the agent settles -and then on that interval. It pauses after two automatic wakes with no working-plan change; `/goals ---judge ` picks a sign-off judge model (default: your current session model, else pi's -default). The `--` prefix -keeps ordinary objectives such as `judge model quality` from being parsed as commands. +and then on that interval. It pauses after two automatic wakes with no working-plan change; +`/goals --judge ` picks a sign-off judge model (default: your current session model, else +pi's default); `/goals steward [on|off|status]` controls the optional persistent plan steward. The +older `--` forms remain only for the existing clear, auto, and judge controls. ## Prompts diff --git a/docs/spec/2026-09-03_persistent-plan-steward.md b/docs/spec/2026-09-03_persistent-plan-steward.md new file mode 100644 index 0000000..5f97255 --- /dev/null +++ b/docs/spec/2026-09-03_persistent-plan-steward.md @@ -0,0 +1,37 @@ +# Persistent plan steward + +## Purpose + +Add judgement across a plan without weakening the fresh evidence check. The steward checks intent, +trajectory, goal ordering, and scope. `CompleteGoal`'s fresh judge continues to check artifacts. + +## Lifecycle + +1. The human opts in with `/goals steward on`. +2. Ready forks one non-writing `oracle` through the public `pi-subagents` RPC. Work does not start + until it returns `approve`. +3. The child process exits. Pi-subagents retains its session and run identity; no model or process + remains active between checkpoints. +4. The first `CompleteGoal` call resumes the same child with bounded approved/current contract views + and the proposed goal. Checkbox state is normalized and evidence detail is omitted because the fresh + judge owns it. The steward checks contract fidelity and whether sign-off is timely. +5. `approve` creates a one-use approval bound to the goal and current working-set hash. The next + `CompleteGoal` call consumes it and runs the existing fresh evidence judge. +6. `revise_plan`, `needs_user`, an invalid response, or a failed child never signs off the goal. + +## Authority + +The steward's contract forbids edits, goal completion, detailed evidence assessment, and answers to +unresolved human choices. The builtin Oracle retains inspection-only bash; pi-goals rejects a review +when pi-subagents reports a file-mutation effect, but this is not an OS sandbox. Structured decisions +are `approve`, `revise_plan`, and `needs_user`. An `approve` carrying drift or unresolved decisions is +downgraded. A separate fresh judge remains the only evidence sign-off path. + +## Integration + +Pi-goals uses the process-local `subagents:rpc:v1` event API. Initial execution is async `spawn` with +`context: fork`; later checks use `resume`. The current run id and approved working set persist in the +existing `pi-goals-state` session entry. `pi-intercom` is not involved. + +The feature is opt-in and has no hard package dependency. If pi-subagents is absent, RPC startup +fails visibly and the plan remains in planning mode until the user retries or turns the steward off. diff --git a/src/index.ts b/src/index.ts index 9c7b99b..b32d399 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,11 +38,34 @@ */ import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import { completeGoalDescription, completeGoalParamDescription, judgeSystem, judgeUser, planDrafting, planningState, reminder, resync } from "./prompts.js"; +import { + completeGoalDescription, + completeGoalParamDescription, + judgeSystem, + judgeUser, + planDrafting, + planningState, + reminder, + resync, + reviewingState, + stewardPlanReview, + stewardSignoffReview, +} from "./prompts.js"; +import { + rpcRunId, + rpcText, + STEWARD_OUTPUT_SCHEMA, + type StewardDecision, + SUBAGENT_ASYNC_COMPLETE_EVENT, + stewardCompletion, + stewardContract, + subagentRpc, +} from "./steward.js"; const STATE = "pi-goals-state"; const STATUS_KEY = "pi-goals"; @@ -116,7 +139,15 @@ export function nextPlanVersion(planNames: string[], sessionId: string): number return Math.max(0, ...versions) + 1; } -type Phase = "planning" | "working" | null; +type Phase = "planning" | "reviewing" | "working" | null; +type StewardReview = { + kind: "plan" | "signoff"; + runId: string; + goal?: string; + /** Full plan hash for plan review; folded working-set hash for sign-off review. */ + snapshotHash: string; +}; +type StewardApproval = { goal: string; workingSetHash: string }; interface PlanState { phase: Phase; @@ -126,11 +157,31 @@ interface PlanState { /** User-enabled interval for continuing active goals after the agent settles. */ autoIntervalMs: number | null; autoPaused: boolean; + /** Opt-in persistent, forked plan steward supplied by pi-subagents. */ + stewardEnabled: boolean; + stewardRunId: string | null; + stewardReview: StewardReview | null; + stewardApproval: StewardApproval | null; + /** Immutable working set captured when the steward approved work to start. */ + approvedPlan: string | null; } export default function piGoalsExtension(pi: ExtensionAPI): void { - let state: PlanState = { phase: null, judgeModel: null, planVersion: null, autoIntervalMs: null, autoPaused: false }; + let state: PlanState = { + phase: null, + judgeModel: null, + planVersion: null, + autoIntervalMs: null, + autoPaused: false, + stewardEnabled: false, + stewardRunId: null, + stewardReview: null, + stewardApproval: null, + approvedPlan: null, + }; let planningContextPending = false; + let liveContext: ExtensionContext | null = null; + let stewardRecoveryFrom: string | null = null; // The reminder sees only the working set. A repeated Log line must not look like progress. let turnsStale = 0; let lastSeenWorkingSet = ""; @@ -161,6 +212,18 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { pi.appendEntry(STATE, state); } + function contentHash(text: string): string { + return createHash("sha256").update(text).digest("hex"); + } + + function workingSetHash(plan: string): string { + return contentHash(foldPlan(plan)); + } + + function workMessage(ctx: ExtensionContext): string { + return `Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`; + } + function clearAutoTimer(): void { if (autoTimer !== null) clearTimeout(autoTimer); autoTimer = null; @@ -221,6 +284,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]); return; } + if (state.phase === "reviewing") { + ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "steward review")); + ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: forked steward reviewing the plan"]); + return; + } const goals = scanGoals(readPlan(ctx)); if (goals.length === 0) { ctx.ui.setStatus(STATUS_KEY, undefined); @@ -229,7 +297,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } const done = goals.filter((g) => g.status === "done").length; const auto = state.autoPaused ? " · waiting for user" : state.autoIntervalMs === null ? "" : ` · auto ${state.autoIntervalMs / 60_000}m`; - ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${auto}`)); + const steward = state.stewardEnabled ? state.stewardReview ? " · steward reviewing" : " · steward" : ""; + ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${auto}${steward}`)); const mark: Record = { done: "✔", active: "▸", open: "◻", cancelled: "✗" }; // Only live goals get lines so finished work never pushes current work off screen. The active // goal also shows its open subtasks: this file is the task list, so the widget is the task list. @@ -244,10 +313,168 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY, lines); } - // --- /goals: enter plan mode (or clear / set judge) -------------------------------------------- + function stewardMessage(decision: StewardDecision): string { + const drift = decision.contractDrift.length ? `\nContract drift:\n- ${decision.contractDrift.join("\n- ")}` : ""; + const unresolved = decision.unresolvedDecisions.length ? `\nNeeds human decision:\n- ${decision.unresolvedDecisions.join("\n- ")}` : ""; + return `Persistent plan steward: ${decision.decision}\n${decision.reason}\nNext: ${decision.nextAction}${drift}${unresolved}`; + } + + async function startPlanSteward(ctx: ExtensionContext): Promise { + const plan = readPlan(ctx); + const workingSet = stewardContract(foldPlan(plan)); + const hash = contentHash(plan); + try { + const prompt = stewardPlanReview(workingSet, planRel(ctx)); + const data = state.stewardRunId + ? await subagentRpc(pi, "resume", { id: state.stewardRunId, message: prompt }) + : await subagentRpc(pi, "spawn", { + agent: "oracle", + task: prompt, + context: "fork", + async: true, + mission: false, + outputSchema: STEWARD_OUTPUT_SCHEMA, + }); + const runId = rpcRunId(data); + if (!runId) throw new Error("pi-subagents spawn reply contained no run id"); + state = { ...state, phase: "reviewing", stewardReview: { kind: "plan", runId, snapshotHash: hash }, stewardApproval: null }; + planningContextPending = true; + persist(); + updateWidget(ctx); + ctx.ui.notify("Forked plan steward is reviewing the approved draft. Work will start after its decision.", "info"); + } catch (error) { + state = { ...state, phase: "planning", stewardRunId: null, stewardReview: null }; + persist(); + updateWidget(ctx); + ctx.ui.notify(`Could not start the plan steward: ${error instanceof Error ? error.message : String(error)}`, "error"); + } + } + + async function startSignoffSteward(ctx: ExtensionContext, goal: string): Promise { + if (!state.stewardRunId || !state.approvedPlan) return "Persistent steward has no retained approved-plan session. Select Ready again or disable the steward."; + const currentPlan = stewardContract(foldPlan(readPlan(ctx)), { preserveGoalStatus: true }); + const hash = workingSetHash(readPlan(ctx)); + try { + const data = await subagentRpc(pi, "resume", { + id: state.stewardRunId, + message: stewardSignoffReview({ + approvedPlan: state.approvedPlan, + currentPlan, + planPath: planRel(ctx), + goal, + }), + }); + const runId = rpcRunId(data); + if (!runId) throw new Error("pi-subagents resume reply contained no run id"); + state = { ...state, stewardReview: { kind: "signoff", runId, goal, snapshotHash: hash }, stewardApproval: null }; + persist(); + updateWidget(ctx); + return `Sign-off paused while the persistent steward reviews trajectory and scope (run ${runId.slice(0, 8)}). Its child process exits after the review; the retained session will be resumed at the next checkpoint.`; + } catch (error) { + return `Could not resume the persistent steward: ${error instanceof Error ? error.message : String(error)}`; + } + } + + async function reconcilePendingSteward(ctx: ExtensionContext): Promise { + const pending = state.stewardReview; + if (!pending) { + if (state.phase === "reviewing") { + state = { ...state, phase: "planning" }; + persist(); + } + return; + } + try { + const status = await subagentRpc(pi, "status", { id: pending.runId }); + if (!/\b(?:complete|failed|paused|stopped)\b/i.test(rpcText(status))) return; + if (state.stewardReview?.runId !== pending.runId) return; + const plan = readPlan(ctx); + const message = pending.kind === "plan" + ? stewardPlanReview(stewardContract(foldPlan(plan)), planRel(ctx)) + : stewardSignoffReview({ + approvedPlan: state.approvedPlan ?? "(approved plan unavailable)", + currentPlan: stewardContract(foldPlan(plan), { preserveGoalStatus: true }), + planPath: planRel(ctx), + goal: pending.goal ?? "(goal unavailable)", + }); + stewardRecoveryFrom = pending.runId; + const resumed = await subagentRpc(pi, "resume", { id: pending.runId, message }); + const runId = rpcRunId(resumed); + if (!runId) throw new Error("pi-subagents resume reply contained no run id"); + if (state.stewardReview?.runId !== pending.runId) return; + state = { ...state, stewardReview: { ...pending, runId } }; + persist(); + ctx.ui.notify("Recovered the pending persistent steward review after session restart.", "info"); + } catch (error) { + ctx.ui.notify(`Could not reconcile the pending steward review: ${error instanceof Error ? error.message : String(error)}`, "warning"); + } finally { + stewardRecoveryFrom = null; + } + } + + pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, async (payload: unknown) => { + const ctx = liveContext; + const pending = state.stewardReview; + const completion = stewardCompletion(payload); + if (completion?.runId === stewardRecoveryFrom) return; + if (!ctx || !pending || !completion || completion.runId !== pending.runId) return; + state = { ...state, stewardRunId: completion.runId, stewardReview: null }; + if (completion.error || !completion.decision) { + if (pending.kind === "plan") state = { ...state, phase: "planning" }; + persist(); + updateWidget(ctx); + pi.sendMessage({ + customType: "pi-goals-steward", + content: `Persistent plan steward failed: ${completion.error ?? "no decision"}. The plan or goal remains unapproved; retry or use /goals steward off.`, + display: true, + }, { triggerTurn: true }); + return; + } + const decision = completion.decision; + if (pending.kind === "plan") { + const current = readPlan(ctx); + if (contentHash(current) !== pending.snapshotHash) { + state = { ...state, phase: "planning" }; + persist(); + updateWidget(ctx); + pi.sendMessage({ customType: "pi-goals-steward", content: "The plan changed while the steward reviewed it. Review the current draft and select Ready again.", display: true }, { triggerTurn: true }); + return; + } + if (decision.decision === "approve") { + state = { ...state, phase: "working", approvedPlan: stewardContract(foldPlan(current)) }; + persist(); + updateWidget(ctx); + pi.sendMessage({ customType: "pi-goals-steward", content: stewardMessage(decision), display: true }); + pi.sendUserMessage(workMessage(ctx), { deliverAs: "followUp" }); + return; + } + state = { ...state, phase: "planning", approvedPlan: null }; + persist(); + planningContextPending = true; + updateWidget(ctx); + pi.sendMessage({ customType: "pi-goals-steward", content: stewardMessage(decision), display: true }, { triggerTurn: true }); + return; + } + if (decision.decision === "approve" && pending.goal) { + state = { ...state, stewardApproval: { goal: pending.goal, workingSetHash: pending.snapshotHash } }; + persist(); + updateWidget(ctx); + pi.sendMessage({ + customType: "pi-goals-steward", + content: `${stewardMessage(decision)}\n\nTrajectory review passed. Call CompleteGoal again for the fresh evidence review.`, + display: true, + }, { triggerTurn: true }); + return; + } + persist(); + updateWidget(ctx); + pi.sendMessage({ customType: "pi-goals-steward", content: stewardMessage(decision), display: true }, { triggerTurn: true }); + }); + + // --- /goals: enter plan mode (or clear / set judge / set steward) ------------------------------- pi.registerCommand("goals", { - description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals | /goals --clear (disconnect) | /goals --auto [minutes|off] | /goals --judge `, + description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals | /goals steward [on|off|status] | /goals --clear | /goals --auto [minutes|off] | /goals --judge `, handler: async (args, ctx) => { const arg = args.trim(); if (arg === "--clear") { @@ -257,7 +484,17 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } const currentPlan = planRel(ctx); clearAutoTimer(); - state = { ...state, phase: null, planVersion: null, autoIntervalMs: null, autoPaused: false }; + state = { + ...state, + phase: null, + planVersion: null, + autoIntervalMs: null, + autoPaused: false, + stewardRunId: null, + stewardReview: null, + stewardApproval: null, + approvedPlan: null, + }; persist(); updateWidget(ctx); ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info"); @@ -292,6 +529,39 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { ctx.ui.notify(`Goal auto-continue enabled every ${minutes}m.`, "info"); return; } + if (arg === "steward" || arg.startsWith("steward ")) { + const value = arg.slice("steward".length).trim() || "status"; + if (value === "status") { + const status = state.stewardEnabled + ? state.stewardReview ? `enabled; ${state.stewardReview.kind} review running` : state.stewardRunId ? "enabled; retained steward ready" : "enabled; starts when Ready is selected" + : "disabled"; + ctx.ui.notify(`Persistent plan steward: ${status}.`, "info"); + return; + } + if (value !== "on" && value !== "off") { + ctx.ui.notify("Use /goals steward on, off, or status.", "warning"); + return; + } + if (value === "on" && state.phase === "working" && !state.stewardRunId) { + ctx.ui.notify("Enable the persistent steward before selecting Ready so it can approve the plan baseline.", "warning"); + return; + } + state = { + ...state, + stewardEnabled: value === "on", + ...(value === "off" ? { + phase: state.phase === "reviewing" ? "planning" : state.phase, + stewardRunId: null, + stewardReview: null, + stewardApproval: null, + approvedPlan: null, + } : {}), + }; + persist(); + updateWidget(ctx); + ctx.ui.notify(`Persistent plan steward ${value === "on" ? "enabled" : "disabled"}.`, "info"); + return; + } if (arg === "--judge" || arg.startsWith("--judge ")) { const ref = arg.slice("--judge".length).trim(); state = { ...state, judgeModel: ref || null }; @@ -299,7 +569,15 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { ctx.ui.notify(ref ? `Sign-off judge model set to ${ref}` : "Sign-off judge reset to the session model", "info"); return; } - state = { ...state, phase: "planning", planVersion: nextVersion(ctx) }; + state = { + ...state, + phase: "planning", + planVersion: nextVersion(ctx), + stewardRunId: null, + stewardReview: null, + stewardApproval: null, + approvedPlan: null, + }; planningContextPending = true; resyncReason = null; writePlan(ctx, ""); @@ -324,7 +602,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { resyncReason = null; return why; }; - if (state.phase === "planning") return null; + if (state.phase === "planning" || state.phase === "reviewing") return null; if (!plan.trim()) return null; const why = drainResync(); if (why) return resync(plan, planRel(ctx), why); @@ -341,18 +619,21 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // The phase snapshot enters context only when planning starts or context was lost. pi.on("before_agent_start", async (_event, ctx) => { - if (state.phase !== "planning" || !planningContextPending) return; + if ((state.phase !== "planning" && state.phase !== "reviewing") || !planningContextPending) return; planningContextPending = false; - return { message: { customType: PLANNING_CONTEXT, content: planningState(planPath(ctx)), display: false } }; + const content = state.phase === "reviewing" ? reviewingState(planPath(ctx)) : planningState(planPath(ctx)); + return { message: { customType: PLANNING_CONTEXT, content, display: false } }; }); // PI: Working turns never see an obsolete planning snapshot. Auto-compaction retries skip // before_agent_start, so context restores the planning snapshot exactly once in that path. pi.on("context", async (event, ctx) => { - const messages = state.phase === "planning" ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT); - if (state.phase === "planning" && planningContextPending) { + const inPlanGate = state.phase === "planning" || state.phase === "reviewing"; + const messages = inPlanGate ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT); + if (inPlanGate && planningContextPending) { planningContextPending = false; - return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text: planningState(planPath(ctx)) }], timestamp: Date.now() }] }; + const text = state.phase === "reviewing" ? reviewingState(planPath(ctx)) : planningState(planPath(ctx)); + return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] }; } const text = dueInjection(ctx, readPlan(ctx)); if (!text) return messages === event.messages ? undefined : { messages }; @@ -371,7 +652,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { updateWidget(ctx); } } - if (state.phase === "planning" && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text)); + if ((state.phase === "planning" || state.phase === "reviewing") && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text)); }); // The staleness clock sees only the working set. Log updates are durable evidence, not progress. @@ -394,7 +675,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { if (state.phase === "working" && (event.toolName === "subagent" || (event.toolName === "process" && (event.input as { action?: string }).action === "start"))) { runStartedBackgroundWork = true; } - if (state.phase !== "planning") return; + if (state.phase !== "planning" && state.phase !== "reviewing") return; if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) { const target = (event.input as { path?: string }).path; if (target && resolve(ctx.cwd, target) === resolve(planPath(ctx))) return; @@ -407,7 +688,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // A compaction loses context, so restore either the planning snapshot or the working plan once. pi.on("session_compact", async () => { - if (state.phase === "planning") planningContextPending = true; + if (state.phase === "planning" || state.phase === "reviewing") planningContextPending = true; else resyncReason = "The session was just compacted."; }); @@ -445,22 +726,35 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } if (choice === "Cancel") { rmSync(planPath(ctx), { force: true }); - state = { ...state, phase: null, planVersion: null }; + state = { + ...state, + phase: null, + planVersion: null, + stewardRunId: null, + stewardReview: null, + stewardApproval: null, + approvedPlan: null, + }; persist(); updateWidget(ctx); ctx.ui.notify("Plan discarded.", "info"); return; } if (choice !== "Ready") return; - state = { ...state, phase: "working" }; + if (state.stewardEnabled) { + await startPlanSteward(ctx); + return; + } + state = { ...state, phase: "working", approvedPlan: foldPlan(plan) }; persist(); updateWidget(ctx); - pi.sendUserMessage(`Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`, { deliverAs: "followUp" }); + pi.sendUserMessage(workMessage(ctx), { deliverAs: "followUp" }); return; } }); pi.on("session_start", async (_event, ctx) => { + liveContext = ctx; const last = ctx.sessionManager .getEntries() .filter((e: { type?: string; customType?: string }) => e.type === "custom" && e.customType === STATE) @@ -471,16 +765,23 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { planVersion: last?.data?.planVersion ?? null, autoIntervalMs: last?.data?.autoIntervalMs ?? null, autoPaused: last?.data?.autoPaused ?? false, + stewardEnabled: last?.data?.stewardEnabled ?? false, + stewardRunId: last?.data?.stewardRunId ?? null, + stewardReview: last?.data?.stewardReview ?? null, + stewardApproval: last?.data?.stewardApproval ?? null, + approvedPlan: last?.data?.approvedPlan ?? null, }; + await reconcilePendingSteward(ctx); lastSeenWorkingSet = foldPlan(readPlan(ctx)); autoLastWorkingSet = lastSeenWorkingSet; - planningContextPending = state.phase === "planning"; + planningContextPending = state.phase === "planning" || state.phase === "reviewing"; resyncReason = state.phase === "working" ? "New session." : null; updateWidget(ctx); scheduleAutoContinue(ctx); }); pi.on("session_shutdown", async () => { + liveContext = null; clearAutoTimer(); }); @@ -494,10 +795,25 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { goal: Type.String({ description: completeGoalParamDescription }), }), async execute(_id, params, signal, onUpdate, ctx) { - if (state.phase === "planning") return result("Planning is not approved. Choose Ready before signing off a goal.", true); + if (state.phase === "planning" || state.phase === "reviewing") return result("Planning is not approved. Wait for the steward or choose Ready before signing off a goal.", true); const plan = readPlan(ctx); if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true); + if (state.stewardEnabled) { + const hash = workingSetHash(plan); + const approved = state.stewardApproval; + const approvalMatches = approved + && approved.goal.trim().toLowerCase() === params.goal.trim().toLowerCase() + && approved.workingSetHash === hash; + if (!approvalMatches) { + if (state.stewardReview) return result("Sign-off is already paused for a persistent steward review. Wait for its decision."); + const message = await startSignoffSteward(ctx, params.goal); + return result(message, message.startsWith("Could not") || message.startsWith("Persistent steward has no")); + } + state = { ...state, stewardApproval: null }; + persist(); + } + const judgeModel = state.judgeModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null); onUpdate?.({ content: [{ type: "text", text: `Read-only judge (${judgeModel ?? "pi default"}) inspecting: ${params.goal}` }], details: {} }); // decideSignOff runs the judge and derives the outcome + the one log line. judgeModel is never diff --git a/src/prompts.ts b/src/prompts.ts index 28005ff..7edac4a 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -160,6 +160,15 @@ work, mark a goal [/] or [x], or sign off a goal. The plan is not approved until Ready.`; } +export function reviewingState(planPath: string): string { + return `\ +[PLAN STEWARD REVIEW] +A forked read-only steward is reviewing the plan at ${planPath}. Work has not started. Do not edit +project files, call CompleteGoal, or treat the plan as approved. You may inspect facts and update only +the plan if the human supplies a correction; that invalidates the pending review and requires Ready +again.`; +} + export function reminder(foldedPlan: string, planRel: string): string { return `\ @@ -198,10 +207,71 @@ ${plan} } /* ───────────────────────────────────────────────────────────────────────── - * 4. completeGoal — SIGN-OFF, agent-side: the one blessed tool + * 4. persistent steward — a forked, read-only perspective kept between checkpoints + * ──────────────────────────────────────────────────────────────────────── */ +export function stewardPlanReview(plan: string, planPath: string): string { + return `\ +You are the persistent plan steward for one pi-goals plan. You are a read-only adviser, not the +worker, user, evidence judge, or final decision-maker. This review happens after the human selected +Ready but before work starts; it may be a resumed review of a revised draft. + +Judge whether the written plan preserves the user's requested result and is safe to execute. Look +for invented scope, hidden user decisions, goals that depend on later goals, overlapping +criteria, impossible ordering, and discriminators that can pass without the user-visible result. +Do not request more detail merely for audit neatness. Do not assess implementation evidence yet. +A product, scientific, editorial, scope, or authority choice that the human did not settle belongs +in unresolvedDecisions; never choose a sensible default on the human's behalf. + +Return approve only when work may start without revising the plan or asking the human. Return +revise_plan when the agent can repair the written plan without a new human decision. Return +needs_user when the human owns a material unresolved choice. Keep reason and nextAction concise. + +Plan path: ${planPath} + +--- proposed plan working set --- +${plan} +--- end proposed plan working set ---`; +} + +export function stewardSignoffReview(p: { + approvedPlan: string; + currentPlan: string; + planPath: string; + goal: string; +}): string { + return `\ +Resume your role as the persistent plan steward. This is a trajectory review before a separate +fresh evidence judge checks artifacts. Do not duplicate the evidence audit and do not mark the +goal complete. + +Decide whether signing off this goal now remains faithful to the human-approved result and the +approved plan. Check for changed meaning, scope substitution, a prerequisite allocated to another +goal, an inferred human decision, and work that optimizes judge acceptance instead of the requested +artifact or behavior. Routine task/evidence/checkbox progress is not contract drift. + +Return approve only when this is the right goal and interpretation to send to the evidence judge. +Return revise_plan when the agent should repair goal ordering, wording, or scope first. Return +needs_user when a material decision belongs to the human. Keep reason and nextAction concise. + +Plan path: ${p.planPath} +Goal proposed for sign-off: ${p.goal} + +--- human-approved plan working set --- +${p.approvedPlan} +--- end approved plan working set --- + +--- current plan working set --- +${p.currentPlan} +--- end current plan working set ---`; +} + +/* ───────────────────────────────────────────────────────────────────────── + * 5. completeGoal — SIGN-OFF, agent-side: the one blessed tool * ──────────────────────────────────────────────────────────────────────── */ export const completeGoalDescription = - "Sign off a goal once its discriminator is satisfied. First fill the goal's evidence: list in the " + + "Sign off a goal once its discriminator is satisfied. When the optional persistent steward is enabled, " + + "the first call pauses sign-off for a retained trajectory review; call CompleteGoal again only after that " + + "review approves. First fill the goal's evidence: list in the " + "plan file: each item pairs a durable artifact with a short read of it (a quoted+linked log, a " + "table plus how to read it, a metric plus what it shows -- not a bare claim). Quote verbatim from " + "output you actually observed; never reconstruct numbers from memory. If you couldn't see an " + @@ -221,7 +291,7 @@ export const completeGoalDescription = export const completeGoalParamDescription = "The goal's text: the line after 'goal:' in the plan file."; /* ───────────────────────────────────────────────────────────────────────── - * 5. judge — SIGN-OFF, judge-side: the one rigorous check. Runs on a fresh + * 6. judge — SIGN-OFF, judge-side: the one rigorous check. Runs on a fresh * read-only pi subprocess (--no-session) so it never sees the working * agent's transcript. It gets the WHOLE plan file: it finds the goal, * reads discriminator/failure modes/evidence itself (no parser between). diff --git a/src/steward.ts b/src/steward.ts new file mode 100644 index 0000000..86b179f --- /dev/null +++ b/src/steward.ts @@ -0,0 +1,156 @@ +import { randomUUID } from "node:crypto"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request"; +export const SUBAGENT_RPC_REPLY_PREFIX = "subagents:rpc:v1:reply:"; +export const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete"; + +export type StewardDecisionName = "approve" | "revise_plan" | "needs_user"; + +export interface StewardDecision { + decision: StewardDecisionName; + reason: string; + nextAction: string; + contractDrift: string[]; + unresolvedDecisions: string[]; +} + +export function stewardContract(plan: string, options: { preserveGoalStatus?: boolean } = {}): string { + const output: string[] = []; + let evidenceIndent: number | null = null; + for (const line of plan.split("\n")) { + const indent = line.match(/^\s*/)?.[0].length ?? 0; + if (evidenceIndent !== null) { + if (!line.trim()) continue; + if (!line.startsWith("#") && indent > evidenceIndent) continue; + evidenceIndent = null; + } + if (/^\s*-\s*evidence\s*:/i.test(line)) { + output.push(line.replace(/:.*/, ": (checked separately by the fresh evidence judge)")); + evidenceIndent = indent; + continue; + } + const goalLine = /^\s*(?:\d+\.|[-*])\s*\[[ xX/-]\]\s*goal:/i.test(line); + output.push(goalLine && options.preserveGoalStatus ? line : line.replace(/\[[ xX/-]\]/g, "[ ]")); + } + return output.join("\n").trimEnd(); +} + +export const STEWARD_OUTPUT_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["decision", "reason", "nextAction", "contractDrift", "unresolvedDecisions"], + properties: { + decision: { enum: ["approve", "revise_plan", "needs_user"] }, + reason: { type: "string" }, + nextAction: { type: "string" }, + contractDrift: { type: "array", items: { type: "string" } }, + unresolvedDecisions: { type: "array", items: { type: "string" } }, + }, +} as const; + +interface RpcReply { + version: 1; + requestId: string; + success: boolean; + data?: unknown; + error?: { code?: string; message?: string }; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +export function rpcText(data: unknown): string { + const top = record(data); + return typeof top?.text === "string" ? top.text : ""; +} + +export function rpcRunId(data: unknown): string | null { + const top = record(data); + const details = record(top?.details); + for (const value of [details?.runId, top?.runId, top?.id]) { + if (typeof value === "string" && value.trim()) return value; + } + return null; +} + +export function parseStewardDecision(value: unknown): StewardDecision | null { + const input = record(value); + if (!input) return null; + if (input.decision !== "approve" && input.decision !== "revise_plan" && input.decision !== "needs_user") return null; + if (typeof input.reason !== "string" || typeof input.nextAction !== "string") return null; + if (!Array.isArray(input.contractDrift) || !input.contractDrift.every((item) => typeof item === "string")) return null; + if (!Array.isArray(input.unresolvedDecisions) || !input.unresolvedDecisions.every((item) => typeof item === "string")) return null; + const contractDrift = input.contractDrift as string[]; + const unresolvedDecisions = input.unresolvedDecisions as string[]; + const decision = input.decision === "approve" && unresolvedDecisions.length + ? "needs_user" + : input.decision === "approve" && contractDrift.length + ? "revise_plan" + : input.decision; + return { + decision, + reason: input.reason, + nextAction: input.nextAction, + contractDrift, + unresolvedDecisions, + }; +} + +export function stewardCompletion(payload: unknown): { runId: string; decision: StewardDecision | null; error: string | null } | null { + const input = record(payload); + const runId = typeof input?.runId === "string" ? input.runId : typeof input?.id === "string" ? input.id : null; + if (!runId) return null; + const results = Array.isArray(input?.results) ? input.results : []; + const first = record(results[0]); + const effects = record(first?.effects); + const fileMutation = record(effects?.fileMutation); + const mutationObserved = fileMutation?.status === "observed" || fileMutation?.attempted === true; + const decision = parseStewardDecision(first?.structuredOutput); + const error = mutationObserved + ? "steward attempted or produced a file mutation" + : typeof first?.error === "string" + ? first.error + : input?.success === false + ? typeof input?.summary === "string" ? input.summary : "steward subagent failed" + : decision ? null : "steward returned no valid structured decision"; + return { runId, decision, error }; +} + +export async function subagentRpc( + pi: ExtensionAPI, + method: "spawn" | "resume" | "status", + params: Record, + timeoutMs = 5_000, +): Promise { + const requestId = randomUUID(); + const replyEvent = `${SUBAGENT_RPC_REPLY_PREFIX}${requestId}`; + return new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType; + const unsubscribe = pi.events.on(replyEvent, (value: unknown) => { + if (settled) return; + const reply = record(value) as RpcReply | null; + if (!reply || reply.requestId !== requestId) return; + settled = true; + clearTimeout(timer); + if (typeof unsubscribe === "function") unsubscribe(); + if (reply.success) resolve(reply.data); + else reject(new Error(reply.error?.message ?? `pi-subagents ${method} failed`)); + }); + timer = setTimeout(() => { + if (settled) return; + settled = true; + if (typeof unsubscribe === "function") unsubscribe(); + reject(new Error(`pi-subagents ${method} RPC did not reply within ${timeoutMs}ms`)); + }, timeoutMs); + pi.events.emit(SUBAGENT_RPC_REQUEST_EVENT, { + version: 1, + requestId, + method, + params, + source: { extension: "pi-goals" }, + }); + }); +} diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index 51ffbcd..d232ac6 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -16,12 +16,24 @@ function setup( const tools = new Map(); const entries: Array<{ type: string; customType: string; data: unknown }> = []; const events: string[] = []; - const messages: Array<{ content: string; display?: boolean }> = []; + const messages: Array<{ content: string; display?: boolean; customType?: string }> = []; + const busHandlers = new Map unknown>>(); + const bus = { + on(name: string, handler: (value: unknown) => unknown) { + const handlers = busHandlers.get(name) ?? new Set(); + handlers.add(handler); + busHandlers.set(name, handlers); + return () => handlers.delete(handler); + }, + emit(name: string, value: unknown) { + for (const handler of busHandlers.get(name) ?? []) void handler(value); + }, + }; const ctx = { cwd, hasUI: true, isIdle: () => true, - sessionManager: { getSessionId: () => "session-a", getEntries: () => entries }, + sessionManager: { getSessionId: () => "session-a", getSessionFile: () => join(cwd, "session-a.jsonl"), getEntries: () => entries }, ui: { theme: { fg: (_kind: string, text: string) => text }, setStatus: () => {}, @@ -42,14 +54,15 @@ function setup( on: (name: string, handler: any) => hooks.set(name, handler), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), registerTool: (tool: any) => tools.set(tool.name, tool), - sendMessage: (message: { content: string; display?: boolean }) => { + events: bus, + sendMessage: (message: { content: string; display?: boolean; customType?: string }) => { events.push("display"); messages.push(message); }, sendUserMessage: (message: string) => messages.push({ content: message }), }; piGoalsExtension(pi as unknown as ExtensionAPI); - return { commands, ctx, cwd, entries, events, hooks, messages, tools }; + return { bus, commands, ctx, cwd, entries, events, hooks, messages, tools }; } describe("/goals draft flow", () => { @@ -154,6 +167,212 @@ describe("/goals draft flow", () => { } }); + it("forks a persistent steward at Ready and resumes it before sign-off", async () => { + const flow = setup(["Ready"]); + flow.bus.on("subagents:rpc:v1:request", (value: unknown) => { + const request = value as { requestId: string; method: string; params: Record }; + const runId = request.method === "spawn" ? "plan-review-run" : "signoff-review-run"; + flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { + version: 1, + requestId: request.requestId, + success: true, + data: { details: { runId } }, + }); + }); + try { + await flow.hooks.get("session_start")({}, flow.ctx); + await flow.commands.get("goals").handler("steward on", flow.ctx); + await flow.commands.get("goals").handler("objective", flow.ctx); + const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); + writeFileSync(planPath, "# Plan\n\n## User-visible result\n\nA file exists.\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: the file can be read\n"); + + await flow.hooks.get("agent_settled")({}, flow.ctx); + expect(flow.entries.at(-1)?.data).toMatchObject({ + phase: "reviewing", + stewardReview: { kind: "plan", runId: "plan-review-run" }, + }); + expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(false); + const blockedDuringReview = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "README.md" } }, flow.ctx); + expect(blockedDuringReview?.block).toBe(true); + const reviewContext = await flow.hooks.get("before_agent_start")({}, flow.ctx); + expect(reviewContext.message.content).toContain("[PLAN STEWARD REVIEW]"); + + flow.bus.emit("subagent:async-complete", { + runId: "plan-review-run", + success: true, + results: [{ structuredOutput: { + decision: "approve", + reason: "The goals preserve the requested result.", + nextAction: "Start work.", + contractDrift: [], + unresolvedDecisions: [], + } }], + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardRunId: "plan-review-run" }); + expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(true); + + const firstSignoff = await flow.tools.get("CompleteGoal").execute("", { goal: "make the file" }, undefined, undefined, flow.ctx); + expect(firstSignoff.isError).toBe(false); + expect(firstSignoff.content[0].text).toContain("Sign-off paused"); + expect(flow.entries.at(-1)?.data).toMatchObject({ + stewardReview: { kind: "signoff", runId: "signoff-review-run", goal: "make the file" }, + }); + + flow.bus.emit("subagent:async-complete", { + runId: "signoff-review-run", + success: true, + results: [{ structuredOutput: { + decision: "approve", + reason: "The sign-off remains in scope.", + nextAction: "Run the fresh evidence review.", + contractDrift: [], + unresolvedDecisions: [], + } }], + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(flow.entries.at(-1)?.data).toMatchObject({ + stewardRunId: "signoff-review-run", + stewardApproval: { goal: "make the file" }, + }); + expect(flow.messages.at(-1)?.content).toContain("Call CompleteGoal again"); + } finally { + rmSync(flow.cwd, { recursive: true, force: true }); + } + }); + + it("invalidates a plan approval when the human corrects it during review", async () => { + const flow = setup(["Ready"]); + flow.bus.on("subagents:rpc:v1:request", (value: unknown) => { + const request = value as { requestId: string }; + flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { + version: 1, requestId: request.requestId, success: true, data: { details: { runId: "review-run" } }, + }); + }); + try { + await flow.hooks.get("session_start")({}, flow.ctx); + await flow.commands.get("goals").handler("steward on", flow.ctx); + await flow.commands.get("goals").handler("objective", flow.ctx); + const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); + writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n\n## Log\n\n## Interview\n"); + await flow.hooks.get("agent_settled")({}, flow.ctx); + await flow.hooks.get("input")({ text: "The file must be CSV.", source: "interactive" }, flow.ctx); + flow.bus.emit("subagent:async-complete", { + runId: "review-run", + success: true, + results: [{ structuredOutput: { + decision: "approve", reason: "The old plan was sound.", nextAction: "Start.", contractDrift: [], unresolvedDecisions: [], + } }], + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" }); + expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(false); + expect(flow.messages.at(-1)?.content).toContain("plan changed while the steward reviewed it"); + } finally { + rmSync(flow.cwd, { recursive: true, force: true }); + } + }); + + it("reconciles a completed pending steward review after session restart", async () => { + const flow = setup([]); + const methods: string[] = []; + flow.bus.on("subagents:rpc:v1:request", (value: unknown) => { + const request = value as { requestId: string; method: string }; + methods.push(request.method); + if (request.method === "resume") { + flow.bus.emit("subagent:async-complete", { + runId: "lost-review", + success: true, + results: [{ structuredOutput: { + decision: "approve", reason: "Stale completion.", nextAction: "Start.", contractDrift: [], unresolvedDecisions: [], + } }], + }); + } + flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { + version: 1, + requestId: request.requestId, + success: true, + data: request.method === "status" ? { text: "State: complete" } : { details: { runId: "recovered-review" } }, + }); + }); + try { + mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true }); + writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n"); + flow.entries.push({ + type: "custom", + customType: "pi-goals-state", + data: { + phase: "reviewing", judgeModel: null, planVersion: 1, autoIntervalMs: null, autoPaused: false, + stewardEnabled: true, stewardRunId: "lost-review", approvedPlan: null, stewardApproval: null, + stewardReview: { kind: "plan", runId: "lost-review", snapshotHash: "old" }, + }, + }); + await flow.hooks.get("session_start")({}, flow.ctx); + expect(methods).toEqual(["status", "resume"]); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "reviewing", stewardReview: { runId: "recovered-review" } }); + expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(false); + } finally { + rmSync(flow.cwd, { recursive: true, force: true }); + } + }); + + it("resumes the same steward after it requests a plan revision", async () => { + const flow = setup(["Ready", "Ready"]); + const methods: string[] = []; + flow.bus.on("subagents:rpc:v1:request", (value: unknown) => { + const request = value as { requestId: string; method: string }; + methods.push(request.method); + flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { + version: 1, + requestId: request.requestId, + success: true, + data: { details: { runId: methods.length === 1 ? "first-review" : "revised-review" } }, + }); + }); + try { + await flow.hooks.get("session_start")({}, flow.ctx); + await flow.commands.get("goals").handler("steward on", flow.ctx); + await flow.commands.get("goals").handler("objective", flow.ctx); + const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); + writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make it better\n"); + await flow.hooks.get("agent_settled")({}, flow.ctx); + flow.bus.emit("subagent:async-complete", { + runId: "first-review", + success: true, + results: [{ structuredOutput: { + decision: "revise_plan", + reason: "The result is not observable.", + nextAction: "Name the artifact.", + contractDrift: [], + unresolvedDecisions: [], + } }], + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", stewardRunId: "first-review" }); + writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: create report.html\n"); + await flow.hooks.get("agent_settled")({}, flow.ctx); + expect(methods).toEqual(["spawn", "resume"]); + expect(flow.entries.at(-1)?.data).toMatchObject({ stewardReview: { runId: "revised-review" } }); + } finally { + rmSync(flow.cwd, { recursive: true, force: true }); + } + }); + + it("refuses to enable a steward after an unreviewed plan is already working", async () => { + const flow = setup(["Ready"]); + try { + await flow.hooks.get("session_start")({}, flow.ctx); + await flow.commands.get("goals").handler("objective", flow.ctx); + const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); + writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n"); + await flow.hooks.get("agent_settled")({}, flow.ctx); + await flow.commands.get("goals").handler("steward on", flow.ctx); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, stewardRunId: null }); + } finally { + rmSync(flow.cwd, { recursive: true, force: true }); + } + }); + it("edits a plan in Pi and cancels without starting work", async () => { const original = "# Plan\n\n## Goals\n\n1. [ ] goal: original\n"; const edited = "# Plan\n\n## Goals\n\n1. [ ] goal: edited\n"; diff --git a/test/steward.test.ts b/test/steward.test.ts new file mode 100644 index 0000000..d0d6225 --- /dev/null +++ b/test/steward.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { parseStewardDecision, rpcRunId, stewardCompletion, stewardContract } from "../src/steward.js"; + +const decision = { + decision: "approve", + reason: "The goal remains faithful.", + nextAction: "Send it to the evidence judge.", + contractDrift: [], + unresolvedDecisions: [], +}; + +describe("persistent steward protocol", () => { + it("extracts run ids from pi-subagents RPC replies", () => { + expect(rpcRunId({ details: { runId: "run-1" } })).toBe("run-1"); + expect(rpcRunId({ runId: "run-2" })).toBe("run-2"); + expect(rpcRunId({ details: {} })).toBeNull(); + }); + + it("accepts only the bounded structured decision", () => { + expect(parseStewardDecision(decision)).toEqual(decision); + expect(parseStewardDecision({ ...decision, decision: "done" })).toBeNull(); + expect(parseStewardDecision({ ...decision, unresolvedDecisions: "none" })).toBeNull(); + expect(parseStewardDecision({ ...decision, unresolvedDecisions: ["Choose the published scope."] })?.decision).toBe("needs_user"); + expect(parseStewardDecision({ ...decision, contractDrift: ["The output changed."] })?.decision).toBe("revise_plan"); + }); + + it("keeps the approved contract compact across progress and evidence growth", () => { + const plan = `1. [/] goal: make the file + - discriminator: the file can be read + - tasks: + 1. [x] write it + - evidence: + - huge quoted log + - another artifact + +2. [ ] goal: publish it`; + expect(stewardContract(plan)).toBe(`1. [ ] goal: make the file + - discriminator: the file can be read + - tasks: + 1. [ ] write it + - evidence: (checked separately by the fresh evidence judge) +2. [ ] goal: publish it`); + expect(stewardContract(plan, { preserveGoalStatus: true })).toContain("1. [/] goal: make the file"); + }); + + it("correlates a structured completion by run id", () => { + expect(stewardCompletion({ + runId: "run-3", + success: true, + results: [{ structuredOutput: decision }], + })).toEqual({ runId: "run-3", decision, error: null }); + expect(stewardCompletion({ + runId: "run-4", + success: false, + summary: "child failed", + results: [{}], + })).toEqual({ runId: "run-4", decision: null, error: "child failed" }); + expect(stewardCompletion({ + runId: "run-5", + success: true, + results: [{ structuredOutput: decision, effects: { fileMutation: { status: "observed", attempted: true } } }], + })?.error).toBe("steward attempted or produced a file mutation"); + }); +});