mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-18 12:50:08 +08:00
Add persistent plan steward
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user