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
+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" },
});
});
}