Separate provisional planning from intentional acceptance

This commit is contained in:
wassname2
2026-09-21 11:46:24 +08:00
parent 54268feb26
commit adc50dc322
6 changed files with 70 additions and 79 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ The supervisor should:
- The supervisor is normally the highest-capability model: it owns high-level diagnosis, research interpretation, experimental design and consequential judgment; workers do bounded execution, evidence gathering and independent criticism. — wassname
- Keep `worker_view` as compact VCC Markdown: summarize current process/subagent presence, do not dump transcripts, raw JSON or repeated compaction, and request detail only when needed. — wassname (Pi wording/spelling edits)
- Put all model-facing prompts in `src/prompts.ts`, in conversation order. Preserve the user's verbatim requirements.
- `/goals` opens actions. New plan starts a discussion without an objective form. Unknown commands never start planning. A changed settled draft opens the approval dialogue; unchanged discussion does not repeatedly reopen it.
- `/goals` opens actions. New plan starts a discussion without an objective form. Unknown commands never start planning. Start with an explicit provisional draft, then explore and grill consequential gaps; redraft freely and honor requested shortcuts/order. Only intentional RequestPlanReview or human review opens acceptance; saves/interviews never do. Ready remains human execution authorization.
- Keep goal titles/status in widgets; omit subtask text. Tasks and evidence remain in the plan.
- Keep startup/compaction plan context, short upkeep reminders and visible check-ins. Record task/evidence bookkeeping passively; wake the supervisor only for changed requirements or goal status. — wassname (Pi wording)
- A secret-display restriction does not block an authorized credential-backed command: use the project's existing loader without exposing values, and ask the human only when authorization, the credential or execution permission is absent. — wassname (Pi wording)
+1 -1
View File
@@ -149,7 +149,7 @@ pi
/goals
```
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Discuss returns to chat and waits for your input. Menu New asks for optional instructions before creating a plan; submit blank to use the conversation, or cancel to leave things unchanged. Typed `/goals new <instructions>` still starts directly. Quit (`exit` or `clear`) leaves the original plan unchanged, clears goal state and requests removal of this session's goal check-in without a model call. Clear uses verified public scheduler commands; missing commands or an unobservable result leave removal unconfirmed. Inspect `/schedules all` for the result. Matching check-in names with missing or different session scope are left unchanged with a warning. Worker processes are unchanged; inspect their native panes and use their exact Intercom identities for steering. New creates a separate draft without overwriting earlier plans, named `.pi/plan/<last-six-session-characters>-vN.md` using the next version after existing files. The title stays inside the plan; old files are not renamed. The widget shows a plain `✓` and the relative plan path for inside-project plans. External plans use the filename with an `(external)` marker; `/goals status` keeps the full location. These are plain labels, not terminal links.
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Start with a visibly provisional first draft, explore, ask consequential questions and redraft as needed; explicit shortcuts and ordering take precedence. Saving drafts or interview notes does not print the plan or open approval. The agent intentionally calls `RequestPlanReview` for the settled draft, or you use `/goals review`; only human Ready authorizes execution. Discuss returns to chat and waits for your input. Menu New asks for optional instructions before creating a plan; submit blank to use the conversation, or cancel to leave things unchanged. Typed `/goals new <instructions>` still starts directly. Quit (`exit` or `clear`) leaves the original plan unchanged, clears goal state and requests removal of this session's goal check-in without a model call. Clear uses verified public scheduler commands; missing commands or an unobservable result leave removal unconfirmed. Inspect `/schedules all` for the result. Matching check-in names with missing or different session scope are left unchanged with a warning. Worker processes are unchanged; inspect their native panes and use their exact Intercom identities for steering. New creates a separate draft without overwriting earlier plans, named `.pi/plan/<last-six-session-characters>-vN.md` using the next version after existing files. The title stays inside the plan; old files are not renamed. The widget shows a plain `✓` and the relative plan path for inside-project plans. External plans use the filename with an `(external)` marker; `/goals status` keeps the full location. These are plain labels, not terminal links.
### Native worker lifecycle and limits
+19 -12
View File
@@ -38,12 +38,14 @@ import {
planDocument,
planning,
planningSeed,
planReviewResult,
planUnavailable,
readyApproved,
removeGoalSchedule,
reportGoalEventDescription,
reportReviewContent,
reportReviewDescription,
requestPlanReviewDescription,
resumeNotice,
scheduleCheckIn,
schedulerMessages,
@@ -372,6 +374,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
}
const stamp = generation;
if (menu || edit) {
if (menu) pi.sendMessage({ customType: "goal-plan-proposal", content: text, display: true }, { triggerTurn: false });
const choice = edit ? "Edit" : await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
if (stamp !== generation || digest(planText()) !== digest(text)) { ctx.ui.notify("Plan changed during review. Review it again.", "warning"); return; }
if (choice === "Discuss") { ctx.ui.notify(discuss, "info"); return; }
@@ -610,28 +613,22 @@ export default function mainSupervisor(pi: ExtensionAPI) {
const text = last?.role === "assistant" ? last.errorMessage || last.content.filter(part => part.type === "text").map(part => part.text).join("\n") || last.stopReason : nativeMessages.noAssistant;
reportStop(text, last?.role === "assistant" && last.stopReason === "aborted" ? "aborted" : last?.role === "assistant" && (last.stopReason === "error" || last.errorMessage) ? "blocker" : "unclassified", true, true);
finalReviewTurnDigest = undefined; refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); });
let proposedDraft = "";
let proposing = false;
pi.on("agent_start", (_event, ctx) => {
agentRunActive = true;
if (state.child && state.parent) pi.appendEntry(RUN, { plan: state.plan, parent: state.parent, session: identity(ctx) });
if (clearCheckIn) { clearTimeout(clearCheckIn.deadline); clearCheckIn.deadline = undefined; }
});
let requestedPlanReview: { generation: number; digest: string } | undefined;
pi.on("agent_settled", async (_e, ctx) => {
agentRunActive = false;
clearCheckIn?.startDeadline?.();
reconcileReports(ctx);
remindReports(ctx);
if (state.child || state.mode !== "planning" || !ctx.hasUI || proposing) return;
const text = planText();
const version = `${state.plan}:${digest(text)}`;
if (!goals(text).length || version === proposedDraft) return;
proposedDraft = version;
proposing = true;
try {
pi.sendMessage({ customType: "goal-plan-proposal", content: text, display: true }, { triggerTurn: false });
await ready(ctx, true);
} finally { proposing = false; }
const requested = requestedPlanReview; requestedPlanReview = undefined;
if (!requested || requested.generation !== generation || state.child || state.mode !== "planning" || !ctx.hasUI) return;
const snapshot = readPlan();
if (snapshot.text === undefined || digest(snapshot.text) !== requested.digest) return;
await ready(ctx, true);
});
// No context hook. Historical message arrays, native checkpoints and model selection are untouched.
pi.on("before_agent_start", (event, ctx) => {
@@ -705,6 +702,16 @@ export default function mainSupervisor(pi: ExtensionAPI) {
if (nativeWorkerControl && (state.child || state.mode === "solo")) return { block: true, reason: goalToolBlocked(state.child ? "worker" : state.mode) };
});
pi.registerTool({
name: "RequestPlanReview", label: "Present settled goal plan", description: requestPlanReviewDescription,
parameters: Type.Object({}),
async execute(_id, _params, _signal, _update, ctx) {
if (state.child || state.mode !== "planning" || !ctx.hasUI) return result(planReviewResult.unavailable);
requestedPlanReview = { generation, digest: digest(planText()) };
return result(planReviewResult.queued);
},
});
pi.registerCommand("goals", {
description: "Goal plan actions: new, edit, discuss, review, ready, status, stop, resume, solo, attach, model, quit (exit/clear)",
getArgumentCompletions: (prefix) => ["new", "attach", "edit", "discuss", "review", "ready", "status", "stop", "resume", "solo", "model", "help", "exit", "clear", "quit"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
+16 -10
View File
@@ -19,17 +19,18 @@ export const workerViewText = {
export const workerViewUnavailable = (reason: string) => `Worker view unavailable: ${reason}. Current activity unknown; use the owned saved session and native controls.`;
export const planDrafting = `\
You are in plan mode. Help the user express what they want this project to achieve in a short judgeable plan. Seek to understand their underlying goals, infer ordinary details, and use their applicable AGENTS.md instructions, relevant skills, and project context to interpret the request correctly. Do not silently substitute your own goals or expand the agreed scope. Unless the user explicitly asks for speed or no questions, follow this order and do not draft early: explore, identify protected decisions, grill, then write.
You are in plan mode. Help the user express what they want this project to achieve in a short judgeable plan. Seek to understand their underlying goals, infer ordinary details, and use their applicable AGENTS.md instructions, relevant skills, and project context to interpret the request correctly. Do not silently substitute your own goals or expand the agreed scope. Default to an explicit first provisional draft, then explore, grill consequential gaps, and intentionally present the settled draft for acceptance. Respect the user's requested order and shortcuts such as "skip questions" or "just propose a plan". Redraft at any time; do not force ritual questions when none matter.
1. Explore first. Read every user-supplied link and resource that available tools can access, the existing plan, applicable instructions and relevant project files. Grep or search to resolve facts and reduce uncertainty before asking the user. Report the exact access failure for an unavailable resource; do not ask the user for facts you can find. Only edit the plan in this phase; do not implement or mutate project state via bash. This is an instruction, not a filesystem restriction.
2. Infer which decisions the human reserves in this particular project. Use their request, User voice, AGENTS.md and prior choices. These can include publication approval or editorial voice in one project, the core experiment in another, or the principles behind an evaluation. Put any proposed change to a protected decision before implementation details, explain its effect and get explicit approval. Do not turn routine reversible implementation choices into approval requests.
3. Then use the grilling skill. Map consequential choices as a design tree and ask the current frontier one short round at a time. Questions should expose differences that would otherwise stay hidden, probe assumptions and challenge inconsistencies. Number each question and recommend an answer with its basis: cite a file or quote, or label it a guess. Recompute the frontier after each answer. Facts are your job; consequential decisions are the user's. Stop when remaining choices would not change the goal, correctness, cost or external effects. Do not use an arbitrary question quota or ask for redundant confirmation once shared understanding is clear. Record each answer, or an unanswered unknown, in ## Interview. Do not silently replace an unknown with an inference. Only withhold Ready for an unanswered protected choice that changes scope, spending or the user-visible result.
4. State the user-visible result before the goals: one concrete sentence naming what the human will inspect when this plan is done. Take it from the original request, not from your implementation plan. Every requested artifact and action must survive into this sentence. An agent-inferred constraint may not replace, defer or contradict it; ask the human if an inference would change the result. Do not present the review menu with a placeholder goal such as "work out the thing", "improve it" or "investigate".
5. When every goal has an object, observable result, settled scope and required approval, draft the plan file and present it. It should be safe to work overnight and present the requested outcome.
1. Capture and briefly present a first draft, visibly labelled provisional. TODOs and candid uncertainty (likely, tentative, not checked, depends on a decision) are welcome; do not invent numerical precision. This is a working proposal, not a claim of readiness.
2. Explore. Read every user-supplied link and resource that available tools can access, the existing plan, applicable instructions and relevant project files. Grep or search to resolve facts and reduce uncertainty before asking the user. Report the exact access failure for an unavailable resource; do not ask the user for facts you can find. Only edit the plan in this phase; do not implement or mutate project state via bash. This is an instruction, not a filesystem restriction.
3. Infer which decisions the human reserves in this particular project. Use their request, User voice, AGENTS.md and prior choices. These can include publication approval or editorial voice in one project, the core experiment in another, or the principles behind an evaluation. Put any proposed change to a protected decision before implementation details, explain its effect and get explicit approval. Do not turn routine reversible implementation choices into approval requests.
4. Use the grilling skill for consequential unresolved choices. Map consequential choices as a design tree and ask the current frontier one short round at a time. Questions should expose differences that would otherwise stay hidden, probe assumptions and challenge inconsistencies. Number each question and recommend an answer with its basis: cite a file or quote, or label it a guess. Recompute the frontier after each answer. Facts are your job; consequential decisions are the user's. Stop when remaining choices would not change the goal, correctness, cost or external effects. Do not use an arbitrary question quota or ask for redundant confirmation once shared understanding is clear. Record each answer, or an unanswered unknown, in ## Interview. Do not silently replace an unknown with an inference. Only withhold Ready for an unanswered protected choice that changes scope, spending or the user-visible result.
5. State the user-visible result before the goals: one concrete sentence naming what the human will inspect when this plan is done. Take it from the original request, not from your implementation plan. Every requested artifact and action must survive into this sentence. An agent-inferred constraint may not replace, defer or contradict it; ask the human if an inference would change the result. Do not present the review menu with a placeholder goal such as "work out the thing", "improve it" or "investigate".
6. When every goal has an object, observable result, settled scope and required approval, save the settled draft and call RequestPlanReview to present it for acceptance. It should be safe to work overnight and present the requested outcome.
How this mode ends: after each changed settled draft the human gets a menu (Ready / Discuss / Edit / Cancel).
Saving, redrafting, interview answers and Log updates never request acceptance. Do not print the entire plan again or call RequestPlanReview while questions remain open. Only an intentional RequestPlanReview call (or the human's /goals review) displays the full draft and menu (Ready / Discuss / Edit / Cancel).
Plan mode ends only when they pick Ready. Discuss continues ordinary chat. Edit opens the full
plan. When a new requirement arrives, fold it in, say what changed, and present the plan again.
plan. When a new requirement arrives, fold it in and briefly say what changed; continue discussion before intentionally requesting acceptance again.
Detail that doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
Right-size it:
@@ -125,16 +126,21 @@ Conventions:
- Appendix: unlimited and unverified. Alternatives, links, dead ends, and the settled detail that
is not part of the approved goals. Nothing here is approved and nothing here is checked.
When the goals are drafted, present them and say the plan is final. Do not begin execution.`;
A first draft is provisional, not final. Keep exploring and redrafting until ready for intentional acceptance. Do not begin execution before the human picks Ready.`;
// Planning and interview. Keep the full drafting guide one-shot rather than repeating it each turn.
export function planning(planPath: string): string {
return `Plan only in ${planPath}; do not implement or launch workers before Ready. Unless the user explicitly asks for speed or no questions: first read their supplied links/resources and inspect or search the project, then infer project-specific decisions that require their approval, then use the grilling skill for consequential unresolved choices before drafting. Facts are your job; do not ask for information tools can find. Put proposed changes to protected intent, editorial/publication authority, core research design or evaluation principles first and get explicit approval. Record unanswered unknowns and present Ready when the outcome, scope and spending are settled. Preserve the user's exact deliverable, preferences and voice. Preserve concrete technical deliverable nouns and verbs in visible goals; do not replace them with vague benefits or readiness. Use "I know it when I see it" to judge actual results in hindsight, not to rename the requested work. Put observable examples, constraints, failure modes, discriminators and evidence expectations beneath each goal, above ## Log; do not invent numerical gates to replace judgment. Record the requested worker model in preferences. When your drafted plan is ready for human review, finish your turn; the interface displays the draft and approval choices automatically. Do not ask the user to type a command to see the proposal. /goals review reopens it on request; /goals exit preserves the draft.`;
return `Plan only in ${planPath}; do not implement or launch workers before Ready. Default to a brief explicit first provisional draft (TODOs and candid uncertainty are welcome), then explore supplied resources/project facts, grill consequential gaps, and intentionally request acceptance. Redraft throughout. Respect requested ordering and shortcuts such as "skip questions" or "just propose a plan"; do not force questions when none matter. Facts are your job; do not ask for information tools can find. Put proposed changes to protected intent, editorial/publication authority, core research design or evaluation principles first and get explicit approval. Record unanswered unknowns; request acceptance only when the outcome, scope and spending are settled. Preserve the user's exact deliverable, preferences and voice. Preserve concrete technical deliverable nouns and verbs in visible goals; do not replace them with vague benefits or readiness. Use "I know it when I see it" to judge actual results in hindsight, not to rename the requested work. Put observable examples, constraints, failure modes, discriminators and evidence expectations beneath each goal, above ## Log; do not invent numerical gates to replace judgment. Record the requested worker model in preferences. Saving/redrafting/interview/Log updates do not request acceptance: do not repeat the entire plan or bury unanswered questions under Ready. When intentionally presenting the settled draft for human acceptance, call RequestPlanReview; it displays the plan and existing Ready/Discuss/Edit/Cancel menu. Only human Ready authorizes execution. /goals review also opens it on explicit request; /goals exit preserves the draft.`;
}
export function planningSeed(objective: string, planPath: string): string {
return `Enter a planning conversation focused on the user's goals. ${objective ? `Initial idea: ${objective}.` : "Use the existing conversation; ask what the user wants to achieve if it is unclear."} Read any existing plan at ${planPath} first, then discuss and draft it with the user. Do not infer approval to implement from starting this conversation. ${planning(planPath)}\n\n${planDrafting}`;
}
export const planDocument = (objective: string) => `# ${objective.split("\n")[0] || "Goal plan"}\n\n## Objective\n${objective}\n\n## Goals\n\n## Log\n`;
export const requestPlanReviewDescription = "Intentionally present the settled goal draft for human acceptance through the existing Ready/Discuss/Edit/Cancel menu. Planning parent only. Not for provisional drafts, redrafting, interview or Log updates; resolve consequential open questions first unless the user explicitly requests a shortcut. Only human Ready authorizes execution.";
export const planReviewResult = {
unavailable: "Plan review requires a planning parent and an interactive UI. No execution authorized.",
queued: "Intentional review queued for the end of this turn. Finish without repeating the full plan; the interface will present it. No execution authorized before human Ready. Further edits or a mode change cancel this request; request again only when settled.",
};
export const discuss = "Type your changes in chat; the draft stays open.";
// Ready and explicit native peer attachment. No worker environment or agent-file contract.
+21 -51
View File
@@ -8,7 +8,6 @@ import { visibleWidth } from "@earendil-works/pi-tui";
import { openProjectPane } from "pi-subagents/project-panes";
import { afterEach, expect, it, vi } from "vitest";
import goalsExtension from "../src/index.js";
import { goalCheckInWake, planDrafting, reportGoalEventDescription, supervisor } from "../src/prompts.js";
import { buildWorkerView } from "../src/worker-view.js";
vi.mock("pi-subagents/project-panes", () => ({ openProjectPane: vi.fn(async () => ({ ok: true, data: { bindingPath: "/project/.pi/subagents/project-pane.json", disposition: "opened", binding: { paneId: "native-pane", projectRoot: "/project", command: "pi" } } })) }));
@@ -78,39 +77,6 @@ function fixture(child = false) {
return { ctx, pi, hooks, tools, commands, messages, command, get path() { return path; }, plan, draft, shutdown, changed, atomicWrite, get entries() { return entries.filter(entry => entry.customType === "pi-goals-main-supervisor-v1"); }, start, launch, channel, event: (event: any) => registration.onEvent(event) };
}
it("puts exploration, protected decisions and grilling before the plan draft", () => {
const explore = planDrafting.indexOf("1. Explore first");
const protectedDecisions = planDrafting.indexOf("2. Infer which decisions the human reserves");
const grill = planDrafting.indexOf("3. Then use the grilling skill");
const draft = planDrafting.indexOf("5. When every goal");
expect(explore).toBeGreaterThan(0);
expect(explore).toBeLessThan(protectedDecisions);
expect(protectedDecisions).toBeLessThan(grill);
expect(grill).toBeLessThan(draft);
expect(planDrafting).toContain("Read every user-supplied link and resource");
expect(planDrafting).toContain("publication approval or editorial voice");
});
it("leaves stop events informal until the supervisor chooses full review", () => {
expect(reportGoalEventDescription).toContain("never create formal review by themselves");
expect(reportGoalEventDescription).toContain("only the supervisor can choose full review");
const role = supervisor("worker", "/tmp/plan.md", "parent");
expect(role).toContain("Goal, Changed, Judgment, Next, Need from you");
expect(role).toContain("personally perform the high-level diagnosis, research interpretation, experimental design and consequential judgment");
expect(role).toContain("do not outsource the central reasoning");
expect(role).toContain("Do not invent pass/fail thresholds or turn a ranking metric");
expect(role).toContain("never stopped, retasked, closed or reviewed without explicit user authority");
expect(role).toContain("Humour is a reflective meta-learning mechanism");
expect(role).toContain("The human can inspect, talk to and change /model in the worker pane directly");
expect(role).toContain("pi-goals owns attachment/report correlation, not generic writer concurrency");
expect(role).toContain("Only your third choice creates review paperwork");
expect(role).toContain("Never wait on an inferred or nonexistent pane");
expect(role).toContain("unable to display a secret file does not make an already authorized credential-backed command impossible");
expect(role).toContain("python-dotenv or a shell-sourced .env");
expect(role).toContain("without reading, printing or sending secret values");
expect(goalCheckInWake).toContain("Use formal review only when you choose to allow it to stop");
});
it("shows incremental VCC Markdown without raw tool results or compaction dumps", async () => {
initTheme("dark");
const f = fixture(true), history = f.ctx.sessionManager.getBranch(), timestamp = new Date().toISOString();
@@ -348,26 +314,30 @@ it("discusses plan changes only during planning", async () => {
await f.command("discuss"); expect(f.messages).toHaveLength(before);
});
it("automatically proposes a changed settled draft once and preserves Discuss", async () => {
it("keeps provisional drafts and interview updates separate from intentional acceptance", async () => {
const f = fixture(); await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("Discuss");
await f.hooks.get("agent_settled")({}, f.ctx);
expect(f.messages.some(m => m.message.customType === "goal-plan-proposal" && m.message.content === f.plan)).toBe(true);
expect(f.entries.at(-1).data.mode).toBe("planning");
const calls = f.ctx.ui.select.mock.calls.length;
await f.hooks.get("agent_settled")({}, f.ctx);
expect(f.ctx.ui.select).toHaveBeenCalledTimes(calls);
writeFileSync(f.path, f.plan.replace("first output", "revised output"));
f.ctx.ui.select.mockResolvedValueOnce("Ready");
await f.hooks.get("agent_settled")({}, f.ctx);
expect(f.entries.at(-1).data.mode).toBe("supervising");
});
it("does not propose an empty draft or a delegated worker's plan", async () => {
const f = fixture(); await f.command("new");
const review = async () => {
await f.tools.get("RequestPlanReview").execute("review", {}, undefined, undefined, f.ctx);
await f.hooks.get("agent_settled")({}, f.ctx);
};
for (const text of [f.plan, f.plan + "## Interview\nTODO: consequential choice unanswered.\n", f.plan.replace("first output", "revised output")]) {
writeFileSync(f.path, text);
await f.hooks.get("agent_settled")({}, f.ctx);
}
await f.tools.get("RequestPlanReview").execute("stale", {}, undefined, undefined, f.ctx);
writeFileSync(f.path, f.plan + "\n## Interview\nNew unresolved choice.\n");
await f.hooks.get("agent_settled")({}, f.ctx);
expect(f.ctx.ui.select).not.toHaveBeenCalled();
const child = fixture(true); await child.hooks.get("agent_settled")({}, child.ctx);
expect(f.messages.some(m => m.message.customType === "goal-plan-proposal")).toBe(false);
f.ctx.ui.select.mockResolvedValueOnce("Discuss"); await review();
expect(f.entries.at(-1).data.mode).toBe("planning");
expect(f.messages.some(m => m.message.customType === "goal-plan-proposal")).toBe(true);
await f.hooks.get("agent_settled")({}, f.ctx);
expect(f.ctx.ui.select).toHaveBeenCalledTimes(1);
f.ctx.ui.select.mockResolvedValueOnce("Ready"); await review();
expect(f.entries.at(-1).data.mode).toBe("supervising");
const child = fixture(true);
await child.tools.get("RequestPlanReview").execute("child", {}, undefined, undefined, child.ctx);
expect(child.ctx.ui.select).not.toHaveBeenCalled();
});
+12 -4
View File
@@ -1,6 +1,6 @@
import { type ChildProcessWithoutNullStreams, execFileSync, spawn } from "node:child_process";
import { once } from "node:events";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
@@ -106,7 +106,7 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
function start(role: "parent" | "worker", sessionFile?: string) {
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test",
"-e", resolve("test/fixtures/offline-model.ts"), "-e", resolve("src/index.ts"),
"-e", resolve("node_modules/pi-intercom/index.ts"), "-e", resolve("node_modules/@jl1990/pi-scheduler/extensions/scheduler/index.ts"),
"-e", resolve("node_modules/pi-intercom/index.ts"), "-e", realpathSync(resolve("node_modules/@jl1990/pi-scheduler/extensions/scheduler/index.ts")),
...(role === "worker" ? ["-e", resolve("node_modules/pi-subagents/index.ts")] : []),
...(sessionFile ? ["--session", sessionFile] : [])], { cwd, env: {
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("PI_SUBAGENT_") && !name.startsWith("PI_GOALS_") && !name.startsWith("HERDR_"))),
@@ -139,6 +139,13 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
let parent = start("parent"), worker: RpcClient | undefined;
try {
parent.send({ type: "prompt", id: "new", message: "/goals new deliver the greeting" });
await parent.waitFor(m => m.type === "agent_settled");
expect(parent.messages.some(isSelect)).toBe(false);
const interviewed = plan + "\n## Interview\nProvisional: output format depends on the user's answer.\n";
await run(parent, "parent", call("write", { path: planPath, content: interviewed }), { content: "Provisional draft saved. Which greeting format do you want?" });
expect(parent.messages.some(isSelect)).toBe(false);
expect(records((await state(parent)).sessionFile, "pi-goals-main-supervisor-v1").at(-1).mode).toBe("planning");
parent.send({ type: "prompt", id: "review-edit", message: "/goals review" });
const proposal = await parent.waitFor(isSelect);
parent.send({ type: "extension_ui_response", id: proposal.id, value: "Edit" });
const editor = await parent.waitFor(isEditor);
@@ -146,14 +153,15 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
const editAt = parent.messages.length;
parent.send({ type: "extension_ui_response", id: editor.id, value: approved });
await parent.waitFor(m => m.type === "extension_ui_request" && m.method === "setWidget", editAt);
expect(readFileSync(planPath, "utf8")).toBe(approved); expect(requests.parent).toHaveLength(2);
expect(readFileSync(planPath, "utf8")).toBe(approved);
const discussion = parent.messages.length;
parent.send({ type: "prompt", id: "discuss", message: "/goals review" });
const discuss = await parent.waitFor(isSelect, discussion);
parent.send({ type: "extension_ui_response", id: discuss.id, value: "Discuss" });
await parent.waitFor(m => m.type === "response" && m.command === "prompt", discussion);
const discussionAt = parent.messages.length;
parent.send({ type: "prompt", id: "discussion", message: "Keep the edited requirement." });
replies.parent.push(call("RequestPlanReview", {}), { content: "Human decision recorded." });
parent.send({ type: "prompt", id: "discussion", message: "Keep the edited requirement. Present the settled draft for acceptance." });
const ready = await parent.waitFor(isSelect, discussionAt);
expect(systemText(requests.parent.at(-1)!)).toContain("Plan only in");
const startupState = await state(parent), checkInName = `goals-${startupState.sessionId}`;