Add persistent plan steward

This commit is contained in:
wassname2
2026-09-03 14:17:39 +08:00
parent f09d443d88
commit 3f0aadfffa
7 changed files with 909 additions and 33 deletions
+18 -4
View File
@@ -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 <model-ref>` 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 <model-ref>` 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
@@ -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.
+338 -22
View File
@@ -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<PlanState>(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<GoalStatus, string> = { 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<void> {
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<string> {
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<void> {
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 <objective> | /goals --clear (disconnect) | /goals --auto [minutes|off] | /goals --judge <model>`,
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals <objective> | /goals steward [on|off|status] | /goals --clear | /goals --auto [minutes|off] | /goals --judge <model>`,
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
+73 -3
View File
@@ -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 `\
<system-reminder>
@@ -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).
+156
View File
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown>,
timeoutMs = 5_000,
): Promise<unknown> {
const requestId = randomUUID();
const replyEvent = `${SUBAGENT_RPC_REPLY_PREFIX}${requestId}`;
return new Promise((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout>;
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" },
});
});
}
+223 -4
View File
@@ -16,12 +16,24 @@ function setup(
const tools = new Map<string, any>();
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<string, Set<(value: unknown) => 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<string, unknown> };
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";
+64
View File
@@ -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");
});
});