diff --git a/README.md b/README.md index f0f940a..6dcef6d 100644 --- a/README.md +++ b/README.md @@ -149,15 +149,19 @@ pi /goals ``` -`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Quit (`exit` or `clear`) backs up the plan beside the original as a `.bak` file, removes this session's goal check-in, and clears goal state without a model call. Worker processes are unchanged; manage them through `/subagents`. New creates a separate draft without overwriting earlier plans. +`/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 ` still starts directly. Quit (`exit` or `clear`) leaves the original plan unchanged, removes this session's goal check-in, and clears goal state without a model call. Matching check-in names with missing or different session bindings are left unchanged with a warning. Worker processes are unchanged; manage them through `/subagents`. New creates a separate draft without overwriting earlier plans, named `.pi/plan/-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 (the fallback for unverified terminal links). ## Context delivery New injected `[pi-goals]` prompts display as a compact notice; `Ctrl+O` expands the full text. This changes only the display: the original prompt still reaches the model once through normal role preparation. Older notices without a saved display entry remain expanded. — Pi/OpenAI -Startup, session restore and successful compaction inject the complete current plan document at the next ordinary prompt. Other ordinary plan-context messages inject the title, introductory paragraph and `## User-visible result`; plan-change and manual-review messages carry that short view directly rather than only a path. After eight unchanged turns, the next ordinary prompt carries a medium view: the short view, verbatim `## User voice`, and goal headings with their checkbox status. It omits task and evidence details. A Log heading at any Markdown heading level starts history for these short and medium views. The complete refresh retains the entire document, including Log history. The first request to complete the final non-cancelled goal queues a full-plan review without recording sign-off; only a second completion request in that review turn can record it. A plan edit invalidates the queued review. Compaction uses Pi's configured threshold; this plugin does not set a separate 150k limit. Supervisor upkeep cycles through curated nudges, advancing only when delivered; the editable hourly `schedule_prompt` check-in is unchanged. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress. +Startup, attachment/resume, session restore, successful compaction and changed requirements restore the active plan above Log at the next ordinary prompt. This includes current preferences and User voice, but leaves historical Log on disk. Routine context and requested reviews quote unfinished or unreviewed goal lines. After eight unchanged turns, the next ordinary prompt carries an upkeep reminder with its reason, those goal lines and the plan path. It omits preferences, role prose and rotating quotations. Reviewed, cancelled and paused work receives no periodic upkeep; manual ticks remain unreviewed. A fresh plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule a refresh. Missing plans are retried. Compaction still uses Pi's configured threshold. -This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do not receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. Quit sends no model message. +Plan-change notices direct the agent to read the current file, including changed constraints or a final cancellation. Only our own pending notice is coalesced; unrelated queued input does not suppress it. The editable hourly `schedule_prompt` check-in remains separate. + +The first request to complete the final non-cancelled goal queues a review without recording sign-off. The reviewer must read the complete plan file and actual evidence, then call `CompleteGoal` again in that review run. The review survives intervening inspection tool rounds and same-run queued delivery, but a plan edit invalidates it. Routine messages do not paste the archive. + +This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. Automatic plan resync waits for ordinary prompt preparation; a delivered plan-change notice instead directs a current-file read. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. Quit sends no model message. ## Prompts diff --git a/src/index.ts b/src/index.ts index af48081..d7daa40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ // Pi/OpenAI: Plan and supervise in the main chat; delegate implementation to a visible worker. -import { createHash, randomUUID } from "node:crypto"; -import { existsSync, type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs"; -import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { type FSWatcher, mkdirSync, readdirSync, readFileSync, watch, writeFileSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { type ExtensionAPI, type ExtensionContext, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { CronStorage } from "pi-schedule-prompt/src/storage.js"; import { Type } from "typebox"; @@ -70,6 +70,7 @@ function goals(text: string) { return [{ subject: match[2].trim(), status: (box === "x" ? "done" : box === "/" ? "active" : box === "-" ? "cancelled" : "open") as GoalStatus, index }]; }); } +const requirements = (text: string) => goals(text).map(g => goalAcceptanceSignature(text, g.subject)).join("\n"); const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} }); export default function mainSupervisor(pi: ExtensionAPI) { @@ -103,13 +104,18 @@ export default function mainSupervisor(pi: ExtensionAPI) { const clearChangedFinalReview = (text: string) => { if (!state.finalReview || state.finalReview.planDigest === digest(text)) return false; state.finalReview = undefined; + finalReviewTurnDigest = undefined; save(); return true; }; let turnsStale = 0; - let upkeepRound = 0; let lastWorkingSet = ""; + let pendingPlanNotice: string | undefined; let pendingUpkeep: { generation: number; workingSet: string } | undefined; + const unfinishedGoals = (text: string) => foldPlan(text).split("\n").filter(line => { + const match = GOAL_LINE.exec(line); + return match && match[1] !== "-" && !(match[1].toLowerCase() === "x" && state.signoffs[key(match[2])] && state.signoffs[key(match[2])].signature === goalAcceptanceSignature(text, match[2])); + }).join("\n"); const checkIn = (ctx: ExtensionContext) => scheduleCheckIn(ctx.sessionManager.getSessionId(), state.plan ?? ""); const hasScheduleTool = () => pi.getAllTools().some((tool) => tool.name === "schedule_prompt"); const notedPlanValue = (prefix: string) => { @@ -134,7 +140,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { } const accepted = items.filter((g) => g.status === "done" && state.signoffs[key(g.subject)]).length; ctx.ui.setStatus("goals", `👀 ${accepted}/${items.length} goals`); - const mark = (status: GoalStatus) => status === "done" ? "✔" : status === "active" ? "◼" : status === "cancelled" ? "✗" : "◻"; + const mark = (status: GoalStatus) => status === "done" ? "✓" : status === "active" ? "◼" : status === "cancelled" ? "✗" : "◻"; const priority: Record = { active: 0, open: 1, done: 2, cancelled: 3 }; const sorted = [...items].sort((a, b) => priority[a.status] - priority[b.status]); const visible = sorted.slice(0, WIDGET_GOAL_LIMIT); @@ -147,9 +153,11 @@ export default function mainSupervisor(pi: ExtensionAPI) { }).filter(Boolean); lines.push(`… ${counts.join(", ")}`); } + lines.unshift(relative(ctx.cwd, state.plan!)); // Readable path fallback; terminal link activation is not verified. ctx.ui.setWidget("goals", lines); } function watchPlan(ctx: ExtensionContext) { + pendingPlanNotice = undefined; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); @@ -160,8 +168,8 @@ export default function mainSupervisor(pi: ExtensionAPI) { const stamp = generation; // Watch the directory so atomic plan replacement remains observable. This is an event hook: // plan-change reviews, not another scheduled loop (the hourly job is schedule_prompt's). A - // short debounce coalesces bursts. Existing high-level plan views exclude maintenance - // (tasks/evidence/Log) while preserving requirement wording and goal checkbox claims. + // short debounce coalesces bursts. The notification view excludes Log and worker identity; + // requirement changes additionally request active-plan context. try { planWatcher = watch(dirname(state.plan), { persistent: false }, () => { if (stamp !== generation) return; @@ -177,7 +185,8 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (hash === planHash) return; planHash = hash; notice = true; - send(planChangedReview(state.plan!, snapshot.text)); + fullPlanContextDue ||= requirements(snapshot.text) !== requirements(lastWorkingSet); + if (!pendingPlanNotice) { pendingPlanNotice = planChangedReview(state.plan!); send(pendingPlanNotice); } }, 150); }); planWatcher.on("error", (error) => { planWatcher?.close(); planWatcher = undefined; ctx.ui.notify(`Plan monitoring failed: ${error.message}`, "error"); }); @@ -199,7 +208,6 @@ export default function mainSupervisor(pi: ExtensionAPI) { state.helpers ??= []; // sessions persisted before helper bookkeeping notice = true; turnsStale = 0; - upkeepRound = 0; lastWorkingSet = ""; pendingUpkeep = undefined; finalReviewTurnDigest = undefined; @@ -234,10 +242,10 @@ export default function mainSupervisor(pi: ExtensionAPI) { } function enterSolo(ctx: ExtensionContext) { state.mode = "solo"; state.workerStopped = true; - generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); + generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx); send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${soloNotice(state.plan!)}`); } - const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach [solo] | model | quit (exit/clear)\n/subagents opens the worker controls. Stop pauses work. Quit/exit/clear backs up the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook."; + const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach [solo] | model | quit (exit/clear)\n/subagents opens the worker controls. Stop pauses work. Quit/exit/clear preserves the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook."; async function ready(ctx: ExtensionContext, menu: boolean, edit = false) { if (state.mode !== "planning") { ctx.ui.notify("Ready applies to a draft; use status or resume.", "warning"); return; } const text = planText(); @@ -249,7 +257,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (menu || edit) { 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") { send(discuss); return; } + if (choice === "Discuss") { ctx.ui.notify(discuss, "info"); return; } if (choice === "Edit") { const edited = await ctx.ui.editor("Edit goal plan", text); if (edited !== undefined && stamp === generation && planText() === text && state.plan) { writeFileSync(state.plan, edited); refresh(ctx); } @@ -258,7 +266,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (choice !== "Ready") return; } if (!compatible()) { ctx.ui.notify("Requires edxeth/pi-subagents 2.9.x, not nicobailon/pi-subagents. Draft preserved; /goals solo is available.", "error"); return; } - state.mode = "supervising"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); + state.mode = "supervising"; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx); send(`${checkIn(ctx)}\n\n${readyApproved(WORKER, state.plan!, state.worker?.sessionFile, text, ctx.sessionManager.getSessionId())}`); } @@ -269,21 +277,31 @@ export default function mainSupervisor(pi: ExtensionAPI) { // Defer to prompt preparation: same-run continuation retains Pi's current role/context. pi.on("session_compact", () => { notice = true; fullPlanContextDue = true; }); pi.on("turn_end", (_event, ctx) => { - finalReviewTurnDigest = undefined; if (!["supervising", "solo"].includes(state.mode)) return; const snapshot = readPlan(); if (snapshot.text === undefined) { notice = true; return; } const workingSet = foldPlan(snapshot.text); + fullPlanContextDue ||= requirements(workingSet) !== requirements(lastWorkingSet); turnsStale = workingSet === lastWorkingSet ? turnsStale + 1 : 0; lastWorkingSet = workingSet; refresh(ctx); - if (turnsStale === 8 && goals(snapshot.text).some(g => g.status === "open" || g.status === "active")) { + if (turnsStale === 8 && unfinishedGoals(snapshot.text)) { // In Pi 0.85.1 triggerTurn:false updates saved history, not the live loop snapshot. // Queue intent locally until ordinary prompt preparation, never force another turn. pendingUpkeep = { generation, workingSet }; } }); - pi.on("agent_end", (_e, ctx) => { refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); }); + // Queued follow-ups can be consumed inside the same run, without before_agent_start. + pi.on("message_end", (event) => { + if (event.message.role !== "user") return; + const content = typeof event.message.content === "string" ? event.message.content : event.message.content.filter(part => part.type === "text").map(part => part.text).join("\n"); + if (pendingPlanNotice && content === `[pi-goals]\n${pendingPlanNotice}`) pendingPlanNotice = undefined; + if (!state.finalReview || !["supervising", "solo"].includes(state.mode)) return; + const snapshot = readPlan(); + if (snapshot.text === undefined || state.finalReview.planDigest !== digest(snapshot.text)) return; + if (content === `[pi-goals]\n${finalReview(state.plan!, snapshot.text)}`) finalReviewTurnDigest = state.finalReview.planDigest; + }); + pi.on("agent_end", (_e, ctx) => { finalReviewTurnDigest = undefined; refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); }); let proposedDraft = ""; let proposing = false; pi.on("agent_settled", async (_e, ctx) => { @@ -310,22 +328,23 @@ export default function mainSupervisor(pi: ExtensionAPI) { const role = state.child ? childPlanRole : state.mode === "supervising" ? supervisor(WORKER, state.plan!, ctx.sessionManager.getSessionId()) : state.mode === "planning" ? planning(state.plan!) : state.mode === "paused" ? pausedRole : soloRole; - const pendingFinalReview = state.finalReview; + fullPlanContextDue ||= requirements(snapshot.text) !== requirements(lastWorkingSet); + const pendingFinalReview = ["supervising", "solo"].includes(state.mode) ? state.finalReview : undefined; if (pendingFinalReview) finalReviewTurnDigest = pendingFinalReview.planDigest; // Returned messages enter both Pi's prompt snapshot and saved history together. // Unlike nextTurn, retaining intent here lets a fresh plan resync supersede upkeep, // and drops obsolete reminders after edits, takeover, pause or session navigation. const message = pendingFinalReview ? { customType: "pi-goals-final-review", content: finalReview(state.plan!, snapshot.text), display: false } - : notice - ? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text, fullPlanContextDue ? "full" : "short"), display: false } + : notice || fullPlanContextDue + ? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, fullPlanContextDue ? snapshot.text : unfinishedGoals(snapshot.text), fullPlanContextDue ? "full" : "short"), display: false } : pendingUpkeep?.generation === generation && pendingUpkeep.workingSet === foldPlan(snapshot.text) - && ["supervising", "solo"].includes(state.mode) && goals(snapshot.text).some(g => g.status === "open" || g.status === "active") - ? { customType: "pi-goals-upkeep", content: upkeep(state.plan!, snapshot.text, state.mode === "supervising" ? upkeepRound : undefined), display: false } : undefined; - if (message?.customType === "pi-goals-upkeep" && state.mode === "supervising") upkeepRound++; + && ["supervising", "solo"].includes(state.mode) && unfinishedGoals(snapshot.text) + ? { customType: "pi-goals-upkeep", content: upkeep(state.plan!, unfinishedGoals(snapshot.text)), display: false } : undefined; if (message) turnsStale = 0; notice = false; fullPlanContextDue = false; + lastWorkingSet = foldPlan(snapshot.text); pendingUpkeep = undefined; return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(message ? { message } : {}) }; }); @@ -375,7 +394,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (!command) { const actions = [ ...({ - chat: ["new — New plan", "attach — Open plan…"], + chat: ["new — New plan…", "attach — Open plan…"], planning: ["edit — Edit plan…", "discuss — Discuss changes to the plan", "ready — Approve draft"], supervising: ["review — Check progress", "stop — Pause work"], paused: ["resume — Resume work"], @@ -388,6 +407,11 @@ export default function mainSupervisor(pi: ExtensionAPI) { const choice = await ctx.ui.select("Goal plan actions", actions); if (!choice || before !== generation) return; command = choice.split(" — ")[0]; + if (command === "new") { + const value = await ctx.ui.editor("Planning instructions (optional; blank uses this conversation)", ""); + if (value === undefined || before !== generation) return; + command += ` ${value.trim()}`; + } if (["attach", "model"].includes(command)) { const value = await ctx.ui.editor(command === "attach" ? "Plan path (optional: solo)" : "Worker model (provider/model)", ""); if (!value?.trim() || before !== generation) return; @@ -412,9 +436,9 @@ export default function mainSupervisor(pi: ExtensionAPI) { } if (command === "discuss") { if (state.mode !== "planning") { ctx.ui.notify("Discuss applies to a draft.", "warning"); return; } - send(discuss); return; + ctx.ui.notify(discuss, "info"); return; } - if (command === "review" && state.mode === "supervising") { notice = true; send(manualReview(state.plan ?? "", planText())); return; } + if (command === "review" && state.mode === "supervising") { send(manualReview(state.plan ?? "", unfinishedGoals(planText()))); return; } if (command === "edit" || command === "review" || command === "ready") { await ready(ctx, command === "review", command === "edit"); return; } if (command === "model" || command.startsWith("model ")) { if (!state.plan || !goals(planText()).length) { ctx.ui.notify("Register a goal plan first.", "warning"); return; } @@ -447,30 +471,31 @@ export default function mainSupervisor(pi: ExtensionAPI) { const retained = target === state.plan ? state.signoffs : {}; const worker = noted ? { sessionFile: resolve(ctx.cwd, noted) } : state.workerStopped ? state.worker : undefined; state = { mode: solo ? "solo" : "planning", plan: target, signoffs: retained, worker, helpers: [], workerStopped: solo || (!noted && state.workerStopped) }; - generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); + generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx); if (solo) enterSolo(ctx); else send(attachNotice(target, false, noted)); return; } if (command === "exit") { - const backup = state.plan && existsSync(state.plan) ? `${state.plan}.${randomUUID()}.bak` : undefined; - if (backup) writeFileSync(backup, readFileSync(state.plan!), { flag: "wx" }); const storage = new CronStorage(ctx.cwd); const session = ctx.sessionManager.getSessionId(); - for (const job of storage.getAllJobs().filter(j => j.name === `goals-${session}` && j.session === session)) { + const matching = storage.getAllJobs().filter(j => j.name === `goals-${session}`); + const skipped = matching.filter(j => j.session !== session); + if (skipped.length) ctx.ui.notify(`Goal check-ins left unchanged (session binding missing or different): ${skipped.map(j => j.id).join(", ")}. Inspect /schedule-prompt.`, "warning"); + for (const job of matching.filter(j => j.session === session)) { storage.removeJob(job.id); // Scheduler re-reads storage before firing; removed jobs cannot prompt. pi.events.emit("cron:change", { type: "remove", jobId: job.id }); } state = initial(); generation++; workerRevision++; pendingLaunches.clear(); pendingUpkeep = undefined; notice = true; save(); refresh(ctx); watchPlan(ctx); - ctx.ui.notify(`Goals cleared.${backup ? ` Plan backed up to ${backup}.` : ""}`, "info"); + ctx.ui.notify("Goals cleared; original plan file unchanged.", "info"); return; } if (command === "stop") { - if (state.mode === "planning") { ctx.ui.notify("A draft cannot pause; use /goals quit to back up and clear it.", "warning"); return; } + if (state.mode === "planning") { ctx.ui.notify("A draft cannot pause; use /goals quit to clear goal state and preserve the draft.", "warning"); return; } if (state.mode !== "solo" && state.mode !== "supervising") return; state.pausedFrom = state.mode; - state.mode = "paused"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); + state.mode = "paused"; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx); const pause = pauseExitNotice(state.worker, false); const requestCleanup = Boolean(state.worker) || hasScheduleTool(); if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt. @@ -481,7 +506,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (state.mode !== "paused" || !state.plan) { ctx.ui.notify("Only a paused approved plan can resume. A draft needs Ready.", "warning"); return; } if (state.pausedFrom === "solo") { enterSolo(ctx); return; } if (!compatible()) { ctx.ui.notify("edxeth tools unavailable; plan remains paused.", "error"); return; } - state.mode = "supervising"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); + state.mode = "supervising"; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx); send(`${checkIn(ctx)}\n\n${resumeNotice(WORKER, state.plan, state.worker)}`); return; } @@ -496,18 +521,18 @@ export default function mainSupervisor(pi: ExtensionAPI) { if ((state.worker && !state.workerStopped) || state.mode === "supervising") { ctx.ui.notify("Exit and resolve the existing worker before replacing the plan. The current plan is preserved.", "warning"); return; } const planDir = join(ctx.cwd, ".pi", "plan"); mkdirSync(planDir, { recursive: true }); - const timestamp = new Date().toISOString().replace("T", "-").replace(/:/g, "").replace(/\.\d{3}Z$/, "Z"); - const slug = (objective.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "goal-plan"); - let version = 1; + const suffix = ctx.sessionManager.getSessionId().slice(-6); + const pattern = new RegExp(`^${suffix}-v(\\d+)\\.md$`); + let version = 1 + Math.max(0, ...readdirSync(planDir).map(name => Number(pattern.exec(name)?.[1] ?? 0))); let path: string; for (;;) { - path = join(planDir, `${timestamp}-${slug}-v${version}.md`); + path = join(planDir, `${suffix}-v${version}.md`); try { writeFileSync(path, planDocument(objective), { flag: "wx" }); break; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; version++; } } - state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, helpers: state.helpers, workerStopped: state.workerStopped }; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); + state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, helpers: state.helpers, workerStopped: state.workerStopped }; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); watchPlan(ctx); send(planningSeed(objective, path)); } catch (error) { ctx.ui.notify(String(error), "error"); } }, @@ -522,7 +547,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { const items = goals(readFileSync(params.path, "utf8")); if (!items.length || items.some(g => !g.subject)) return result(messages.invalidAttachment); } catch { return result(messages.invalidAttachment); } - state.plan = params.path; generation++; notice = true; save(); refresh(ctx); + state.plan = params.path; generation++; notice = true; fullPlanContextDue = true; save(); refresh(ctx); return result(childPlanAttached(params.path)); }, }); @@ -553,8 +578,6 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (finalReviewTurnDigest !== digest(text)) { if (!state.finalReview) { state.finalReview = { planDigest: digest(text) }; - notice = true; - fullPlanContextDue = true; save(); send(finalReview(path, text)); } diff --git a/src/prompts.ts b/src/prompts.ts index 7dd4aa2..68d07e7 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -1,5 +1,13 @@ // Pi/OpenAI: Planning, approval, supervision, reminders, completion and recovery. -import { foldPlan, planContextView } from "./plan.js"; +import { createHash } from "node:crypto"; +import { foldPlan, GOAL_LINE } from "./plan.js"; + +// Quote the existing selection verbatim; a longer fence also contains nested Markdown fences. +function quotedPlan(path: string | undefined, text: string, selection: string): string { + const fence = "`".repeat(Math.max(3, ...Array.from(text.matchAll(/`+/g), match => match[0].length + 1))); + const label = selection === "full" ? "Full plan snapshot" : `Plan excerpt (${selection})`; + return `${label} from ${JSON.stringify(path ?? "not attached")}:\n${fence}md\n${text}\n${fence}`; +} 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. @@ -138,10 +146,10 @@ export function planning(planPath: string): string { return `Plan only in ${planPath}; do not implement or launch workers before Ready. Ask material unresolved questions, not a quota or confirmation of ordinary details. Record 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.`; } export function planningSeed(objective: string, planPath: string): string { - return `Enter a planning conversation focused on the user's goals. ${objective ? `Initial idea: ${objective}.` : "Ask what the user wants to achieve; they do not need to supply a finished objective."} 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}`; + 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) => `# Goal plan\n\n## Objective\n${objective}\n\n## Goals\n\n## Log\n`; -export const discuss = "Discuss the current draft in ordinary chat. Do not launch a worker. An unchanged draft does not reopen the review menu; a changed settled draft does."; +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 discuss = "Type your changes in chat; the draft stays open."; // Ready and explicit child attachment: stock lineage-only sessions do not inherit the shared plan. export const attachGoalPlanDescription = "Delegated goals-worker only: attach the absolute plan path explicitly supplied in your task. Read it without rewriting it. Restores plan context; grants no parent completion authority. No discovery or worker launch."; @@ -150,7 +158,7 @@ export function readyApproved(workerName: string, planPath: string, notedWorker: const launch = notedWorker ? `Inspect the recorded worker session ${notedWorker}; if still live, let it continue or message it. Only after confirming it stopped use subagent_resume with that sessionFile. Never restart completed work.` : `Delegate the first unfinished goal to agent '${workerName}' with subagent; provide name, title and a bounded task.`; - return `Ready approved this plan: ${planPath}. Stay here as supervisor. ${launch} Include the absolute plan path, require AttachGoalPlan, and first use intercom status/list to discover and confirm your own actual Intercom UUID, then give that address to the child. Your Pi session ID is ${supervisorId}; it is not necessarily your Intercom UUID. The child sends its completion report there and stays open. Require an initial worker report with its actual Intercom UUID, saved-session path and current provider/model; the async launch may return only a runtime ID. Record each distinct identity in plan preferences, marking child-reported fields as such until verified. Do not start a second writer. Inspect actual outputs when the child reports.\n\n${foldPlan(plan)}`; + return `[pi-goals: approval — Ready]\nReady approved this plan: ${planPath}. Stay here as supervisor. ${launch} Include the absolute plan path, require AttachGoalPlan, and first use intercom status/list to discover and confirm your own actual Intercom UUID, then give that address to the child. Your Pi session ID is ${supervisorId}; it is not necessarily your Intercom UUID. The child sends its completion report there and stays open. Require an initial worker report with its actual Intercom UUID, saved-session path and current provider/model; the async launch may return only a runtime ID. Record each distinct identity in plan preferences, marking child-reported fields as such until verified. Do not start a second writer. Inspect actual outputs when the child reports.\n\n${quotedPlan(planPath, foldPlan(plan), "working set before Log")}`; } // Supervision and turn-event upkeep (not a scheduled wake-up). @@ -164,45 +172,22 @@ Take uncertainty as an invitation to investigate, not something to hide. Have ro Use stock subagent for launch and subagent_resume with the returned sessionFile only after confirming the worker stopped. A stored handle is not proof of liveness; missing runtime state is not proof it stopped. Use pi-intercom list/status to identify the actual live child session before live steering; receipt alone does not prove action. Your Pi session ID is ${supervisorId}, not necessarily your Intercom UUID. Use intercom status/list to discover and confirm your own actual Intercom UUID before supplying the worker's report address. Require its completion report through Intercom while its pane stays open. A recap alone sends no instruction. Record '- worker session:' and '- worker intercom session:' in plan preferences from actual launch results and received-message identity; never confuse the runtime ID with the Intercom ID. Ensure the child calls AttachGoalPlan with the supplied path. Inspect results before CompleteGoal, then continue only unfinished goals. Use the worker model requested in plan preferences, verify the resolved model, and report unavailable choices instead of silently substituting. Keep normal tools, not edxeth's restricted orchestrator mode. After reload or compaction reread the plan. Failed compaction, exhausted credits or lost connection do not erase progress: diagnose the actual error, restore an available authorized model/credits and resume the same saved session; never restart long work. Stock edxeth can crash the parent when a worker exits after parent reload: preserve drafts and stop workers before /reload. If it already happened, restart the saved parent session; do not repeat completed work.`; } -// Pi/OpenAI: user nudges plus quotes/attributions from https://github.com/wassname/ml-debug/blob/main/fortune.txt. -export const upkeepNudges = [ - "is the worker stuck? (or are you)", - "Insufficient skepticism doesn't feel like insufficient skepticism from the inside. It just feels like doing research. -- Neel Nanda", - "take a breath, use a kamoji, how it going?", - "Don't let your instruments overwhelm your system. -- David J. Agans, *Debugging: The 9 Indispensable Rules*", - "is the worker being cheeky, does it need sheperding", - "The first step is just making time to stop and ask yourself: do I endorse what I'm doing, and could I be doing something better? -- Neel Nanda", - "It seems important to really commit yourself to always investigate whenever you notice confusion. -- Dan Rahtz", - "How reliable is my experiment? Ask yourself: How surprised would I be if it turned out to be complete bullshit due to a bug, error, noise, misunderstanding, etc.? Investigate the most uncertain bits. -- Neel Nanda", - "If it doesn’t work, assume there’s a bug. Spend a lot of effort searching for bugs before you resort to tweaking hyperparameters: usually it’s a bug. Bad hyperparameters can significantly degrade RL performance, but if you’re using hyperparameters similar to the ones in papers and standard implementations, those will probably not be the issue. -- Josh Achiam", - "You can't find typos in your own writing without a great deal of effort because you know what it's supposed to say. -- Gwern Branwen", - "Even a single anomaly, apparently trivial in itself, can indicate the everyday mental model is not just a little bit wrong, but fundamentally wrong. -- Gwern Branwen", - "The default state of the world is that your research is false, because doing research is hard. -- Neel Nanda", - "If you're new to RL, writing things from scratch is the most catastrophically self-sabotaging thing you can do. -- Andy Jones", - "QUIT THINKING AND LOOK. -- David J. Agans, *Debugging: The 9 Indispensable Rules*", - "Excitement is evidence of bullshit: generally, most true results are not exciting, but a fair amount of false results are. -- Neel Nanda", - "Read your data. Often, the quality of the data is a crucial driver of the results of your experiments. Often, it is quite bad. -- Neel Nanda", - "Visualize the model in action. Directly observing the machine learning model performing its task will help determine whether the quantitative performance numbers it achieves seem reasonable. -- Goodfellow, Bengio and Courville", - "The unambiguously correct place to visualize your data is immediately before y_hat = model(x). This is the only source of truth. -- Andrej Karpathy", - "Your misconfigured neural net will throw exceptions only if you're lucky; most of the time it will train but silently work a bit worse. -- Andrej Karpathy", - "The first step to training a neural net is to not touch any neural net code at all and instead begin by thoroughly inspecting your data. -- Andrej Karpathy", - "Write multiple competing hypotheses: consider the most likely failure but also some of: a subtle failure, a perverse failure, a possible bug, and an unknown. Put a rough credence on each. Finally write down what you expect to see differently for success vs each possibility and brainstorm the cheapest tests that may narrow them down. -- wassname", -]; -export function upkeep(planPath: string, text: string, supervisorRound?: number): string { - const nudge = supervisorRound === undefined ? "" : `${upkeepNudges[supervisorRound % upkeepNudges.length]}\n\n`; - return `${nudge}Plan upkeep: update task ticks, evidence and Log when you have new progress to record. Preserve agreed goals and discriminators. If already reviewing evidence, finish that review rather than repeat a status recap. This turn-event reminder does not resume paused work.\n\n${planContextView(text, "medium")}\n\nPlan file (audit or edit link): ${planPath}`; +// Routine notices quote only selected goal lines; full context stops at Log. +const goalLines = (text: string) => foldPlan(text).split("\n").filter(line => GOAL_LINE.test(line)).join("\n"); +export function upkeep(planPath: string, text: string): string { + return `[pi-goals: reminder — upkeep]\nEight unchanged turns: update task ticks, evidence or Log only for new progress. Finish any evidence review already underway; do not restart completed or paused work.\n\n${quotedPlan(planPath, goalLines(text), "unfinished or unreviewed goal lines")}`; } export function planContext(mode: string, path: string | undefined, text: string, tier: "short" | "medium" | "full" = "full"): string { - return `Current goal mode: ${mode}. Earlier role messages are historical; this current role governs.\n${planContextView(text, tier)}\n\nPlan file (audit or edit link): ${path ?? "not attached"}`; + return `[pi-goals: context resync]\nCurrent goal mode: ${mode}. Earlier role messages are historical; this current role governs. Read the plan file for details and earlier evidence; do not restart completed work.\n\n${quotedPlan(path, tier === "full" ? foldPlan(text) : goalLines(text), tier === "full" ? "active plan above Log" : "unfinished or unreviewed goal lines")}`; } -export function planChangedReview(planPath: string, text: string): string { - return `${supervisorJob}\nPlan changed. Inspect changed requirements, completion claims and evidence. Evidence-only edits do not revoke execution approval. After review, continue unfinished authorized implementation rather than another recap; respect explicit pauses and do not assume approval for changed scope.\n\n${planContextView(text, "short")}\n\nPlan file (audit or edit link): ${planPath}. Manual checkbox edits are claims, not proof. Do not weaken the agreed goal or start a duplicate writer.`; +export function planChangedReview(planPath: string, text = ""): string { + return `[pi-goals: reminder — plan changed]\nPlan changed: inspect current requirements, completion claims and evidence at ${planPath}. Evidence-only edits do not revoke execution approval. Continue only unfinished authorized work; respect pauses and do not assume approval for changed scope. Manual ticks are claims, not sign-off. Do not start a duplicate writer.${text ? `\n\n${quotedPlan(planPath, goalLines(text), "selected goal lines")}` : ""}`; } export function manualReview(planPath: string, text: string): string { - return `${supervisorJob}\nReview the current plan, worker progress and actual evidence.\n\n${planContextView(text, "short")}\n\nPlan file (audit or edit link): ${planPath}. Do not launch a duplicate writer.`; + return `[pi-goals: reminder — requested review]\nReview requested: inspect the plan and actual evidence. Do not launch a duplicate writer.\n\n${quotedPlan(planPath, goalLines(text), "unfinished or unreviewed goal lines")}`; } export function finalReview(planPath: string, text: string): string { - return `Final completion review. The preceding CompleteGoal request did not record approval. Read the complete embedded plan, including goal requirements, evidence and Log. Inspect the cited artifacts yourself. Only after this review, call CompleteGoal again with the exact remaining goal and evidence; if the plan changed, inspect the changed plan instead.\n\n${planContextView(text, "full")}\n\nPlan file (audit or edit link): ${planPath}.`; + return `[pi-goals: reminder — final completion review]\nFinal completion review: the preceding CompleteGoal request did not record approval. Read the complete file at ${planPath}, including requirements, evidence and Log, and inspect the cited artifacts yourself. Then call CompleteGoal again with the exact remaining goal and evidence. Changed requirements need a new review. Plan revision: ${createHash("sha256").update(text).digest("hex")}.\n\n${quotedPlan(planPath, goalLines(text), "selected goal lines")}`; } // Check-ins. The installed scheduler owns storage/timing/UI. Removal guidance must never add jobs. @@ -210,7 +195,7 @@ export function removeGoalSchedule(sessionId: string): string { return `With schedule_prompt, list jobs and read .pi/schedule-prompts.json to verify ownership; tool text omits session binding. Remove by jobId only the job named ${JSON.stringify(`goals-${sessionId}`)} bound to session ${JSON.stringify(sessionId)}. Never use cleanup; leave other jobs untouched. Do not add, enable or recreate any job. If unavailable or ownership is ambiguous, report it; /schedule-prompt opens the user controls.`; } export function scheduleCheckIn(sessionId: string, planPath: string): string { - return `Hourly check-in is one visible schedule_prompt job; plan-change and upkeep reviews are event hooks, not another timer. List first. If an owned job named ${JSON.stringify(`goals-${sessionId}`)} already exists, retain its human-edited prompt, interval and enabled/disabled state unchanged; never recreate, overwrite or re-enable it. Only while supervising unfinished non-cancelled goals, if missing on this explicit start/resume, add one session-bound interval '1h' job with no model override. Read .pi/schedule-prompts.json and verify that new job's session is ${JSON.stringify(sessionId)}; tool text does not expose binding. If the new job is unbound, remove that job by ID and report the scope error. Do not change other jobs. Its initial prompt: ${supervisorJob} Read ${planPath} and the current goal mode. If paused, exited, solo or all non-cancelled goals reviewed, remove only this owned job without resuming work. Otherwise inspect progress and evidence, give a brief assessment and keep authorized work moving without a duplicate writer. Do not reinstall a missing job from a scheduled check-in. Users inspect/toggle/remove jobs with /schedule-prompt and edit prompt/interval through schedule_prompt update. Never use cleanup. Retain their edits, but warn that this installed scheduler deletes disabled jobs on reload/shutdown; do not promise they persist. If schedule_prompt is unavailable, report hourly check-ins unavailable; do not build a timer.`; + return `Hourly check-in is one visible schedule_prompt job; plan-change and upkeep reviews are event hooks, not another timer. List first. If an owned job named ${JSON.stringify(`goals-${sessionId}`)} already exists, retain its human-edited prompt, interval and enabled/disabled state unchanged; never recreate, overwrite or re-enable it. Only while supervising unfinished non-cancelled goals, if missing on this explicit start/resume, add one session-bound interval '1h' job with no model override. Read .pi/schedule-prompts.json and verify that new job's session is ${JSON.stringify(sessionId)}; tool text does not expose binding. If the new job is unbound, remove that job by ID and report the scope error. Do not change other jobs. Its initial prompt: Hourly goal check-in: read ${planPath} and the current goal mode. If paused, exited, solo or all non-cancelled goals reviewed, remove only this owned job without resuming work. Otherwise inspect progress and evidence, give a brief assessment and keep authorized work moving without a duplicate writer. Do not reinstall a missing job from a scheduled check-in. Users inspect/toggle/remove jobs with /schedule-prompt and edit prompt/interval through schedule_prompt update. Never use cleanup. Retain their edits, but warn that this installed scheduler deletes disabled jobs on reload/shutdown; do not promise they persist. If schedule_prompt is unavailable, report hourly check-ins unavailable; do not build a timer.`; } // Completion and runtime errors. Tool returns are model-facing too. @@ -233,7 +218,7 @@ export function completionLog(goal: string, observation: string, evidence: strin return `- ${solo ? "Solo self-verification" : "Parent review"}: ${JSON.stringify(goal)}; ${JSON.stringify(observation)}; evidence ${JSON.stringify(evidence)}`; } export function finalReviewQueued(goal: string): string { - return `Final review queued for ${goal}; no sign-off recorded. Read the complete embedded plan and actual evidence in that review turn, then call CompleteGoal again with the exact goal and evidence.`; + return `Final review queued for ${goal}; no sign-off recorded. Read the complete plan file and actual evidence in that review run, then call CompleteGoal again with the exact goal and evidence.`; } export const finalReviewInvalidated = "The plan changed since the final review was queued; no sign-off recorded. Inspect the current plan and request completion again to queue a new final review."; export function completionResult(goal: string, sessionId: string, remaining: boolean, solo: boolean): string { diff --git a/test/goals.test.ts b/test/goals.test.ts index 1b14485..bfa9eed 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -1,11 +1,11 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join, relative } from "node:path"; import { createEditTool, type ExtensionAPI, SessionManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { afterEach, expect, it, vi } from "vitest"; import goalsExtension from "../src/index.js"; -import { upkeep, upkeepNudges } from "../src/prompts.js"; +import { upkeep } from "../src/prompts.js"; const roots: string[] = []; const shutdowns: Array<() => void> = []; @@ -23,7 +23,7 @@ function fixture(child = false) { const cwd = mkdtempSync(join(tmpdir(), "goals-main-test-")); roots.push(cwd); const entries: any[] = []; const hooks = new Map(); const commands = new Map(); const tools = new Map(); const messages: any[] = []; - const ctx = { cwd, sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only" }, hasUI: true, ui: { + const ctx = { cwd, sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only" }, hasUI: true, hasPendingMessages: vi.fn(() => false), ui: { theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async (_title: string, _options: string[]) => "Ready"), editor: vi.fn(), } }; const pi = { @@ -112,16 +112,44 @@ it("requires a model argument without clearing the preference", async () => { expect(readFileSync(f.path, "utf8")).toBe(before); }); -it.each(["menu", "command"])("enters planning conversation through %s without an objective box or worker launch", async (route) => { +it.each(["menu", "command"])("enters planning conversation through %s without a worker launch", async (route) => { const f = fixture(); - f.ctx.ui.select.mockResolvedValueOnce("new — New plan"); + f.ctx.ui.select.mockResolvedValueOnce("new — New plan…"); + f.ctx.ui.editor.mockResolvedValueOnce("supplied instructions"); await f.command(route === "menu" ? "" : "new"); expect(f.entries.at(-1).data.mode).toBe("planning"); - expect(f.ctx.ui.editor).not.toHaveBeenCalled(); - expect(f.messages.at(-1).message.content).toContain("Ask what the user wants to achieve"); + expect(f.ctx.ui.editor).toHaveBeenCalledTimes(route === "menu" ? 1 : 0); + expect(f.messages).toHaveLength(1); + expect(f.messages[0].message.content).toContain(route === "menu" ? "Initial idea: supplied instructions" : "Use the existing conversation"); expect(f.hooks.get("tool_call")({ toolName: "subagent" }).block).toBe(true); }); +it("cancelled menu New creates nothing and sends nothing", async () => { + const f = fixture(); + f.ctx.ui.select.mockResolvedValueOnce("new — New plan…"); + await f.command(""); // editor returns undefined on Cancel + expect(f.entries).toHaveLength(0); expect(f.messages).toHaveLength(0); + expect(existsSync(join(f.ctx.cwd, ".pi/plan"))).toBe(false); +}); + +it("new names use six session characters, skip deletion holes and suffix collisions, and preserve old files", async () => { + const f = fixture(); f.ctx.sessionManager.getSessionId = () => "first-abc123"; + const directory = join(f.ctx.cwd, ".pi/plan"); mkdirSync(directory, { recursive: true }); + const old = ["2026-09-14-000000Z-descriptive-plan-v1.md", "abc123-v1.md", "abc123-v2.md", "abc123-v10.md"]; + for (const name of old) writeFileSync(join(directory, name), name); + rmSync(join(directory, "abc123-v2.md")); + await f.command("new Preserve the descriptive title"); + const first = f.entries.at(-1).data.plan; + expect(basename(first)).toBe("abc123-v11.md"); + expect(readFileSync(first, "utf8")).toContain("# Preserve the descriptive title\n"); + f.ctx.sessionManager.getSessionId = () => "another-abc123"; + await f.command("new Different session with same suffix"); + expect(basename(f.entries.at(-1).data.plan)).toBe("abc123-v12.md"); + expect(readFileSync(first, "utf8")).toContain("# Preserve the descriptive title\n"); + for (const name of old.filter(name => name !== "abc123-v2.md")) expect(readFileSync(join(directory, name), "utf8")).toBe(name); + expect(readdirSync(directory)).toHaveLength(5); +}); + it("edits even an empty draft directly without a model call", async () => { const f = fixture(); await f.command("new"); const before = f.messages.length; f.ctx.ui.editor.mockResolvedValueOnce(f.plan); @@ -131,7 +159,7 @@ it("edits even an empty draft directly without a model call", async () => { expect(f.messages).toHaveLength(before); }); -it("clear backs up the plan, drops stale bindings and allows a separate new draft", async () => { +it("clear preserves the plan without a backup, warns for misbound jobs and allows a separate new draft", async () => { const f = fixture(); await f.draft(); await f.command("ready"); f.launch({ id: "stale", sessionFile: "/tmp/old-worker.jsonl" }); const jobs = [ @@ -145,16 +173,19 @@ it("clear backs up the plan, drops stale bindings and allows a separate new draf expect(f.messages).toHaveLength(before); expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} }); expect(JSON.parse(readFileSync(schedule, "utf8")).jobs).toEqual(jobs.slice(1)); - expect(f.pi.events.emit).toHaveBeenCalledWith("cron:change", { type: "remove", jobId: "owned" }); + expect(f.pi.events.emit).toHaveBeenCalledExactlyOnceWith("cron:change", { type: "remove", jobId: "owned" }); + expect(f.ctx.ui.notify).toHaveBeenCalledWith("Goal check-ins left unchanged (session binding missing or different): foreign, unbound. Inspect /schedule-prompt.", "warning"); const directory = join(f.ctx.cwd, ".pi/plan"); - const backup = readdirSync(directory).find(name => name.endsWith(".bak"))!; - expect(readFileSync(join(directory, backup), "utf8")).toBe(f.plan); + expect(readdirSync(directory)).toEqual([basename(f.path)]); + expect(readFileSync(f.path, "utf8")).toBe(f.plan); await f.command("new a different objective"); const next = f.entries.at(-1).data; expect(next.mode).toBe("planning"); expect(next.worker).toBeUndefined(); expect(next.plan).not.toBe(f.path); expect(readFileSync(next.plan, "utf8")).toContain("a different objective"); expect(readFileSync(next.plan, "utf8")).not.toContain("first output"); expect(readFileSync(f.path, "utf8")).toBe(f.plan); + expect(readdirSync(directory)).toHaveLength(2); + expect(f.messages).toHaveLength(before + 1); // New alone queues its normal planning turn. }); it.each(["missing", "empty"])("clear resets a %s plan without a model call", async kind => { @@ -167,9 +198,10 @@ it.each(["missing", "empty"])("clear resets a %s plan without a model call", asy it("discusses plan changes only during planning", async () => { const f = fixture(); await f.command("discuss"); expect(f.messages).toHaveLength(0); - await f.draft(); + await f.draft(); const sent = f.messages.length; f.ctx.ui.select.mockResolvedValueOnce("discuss — Discuss changes to the plan"); await f.command(""); - expect(f.messages.at(-1).message.content).toContain("Discuss the current draft"); + expect(f.messages).toHaveLength(sent); + expect(readFileSync(f.path, "utf8")).toBe(f.plan); expect(f.entries.at(-1).data.mode).toBe("planning"); await f.command("ready"); const before = f.messages.length; await f.command("discuss"); expect(f.messages).toHaveLength(before); @@ -274,14 +306,19 @@ it("requires actual nonempty evidence, distinguishes manual ticks, and retains s writeFileSync(f.path, readFileSync(f.path, "utf8").replace("[ ] goal: second", "[x] goal: second")); f.hooks.get("session_start")({}, f.ctx); expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("goals", "👀 1/2 goals"); - expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toContain("✔ G1: first output"); + expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toContain("✓ G1: first output"); + f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); + for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx); + const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content; + expect(reminder).toContain("[x] goal: second output"); + expect(reminder).not.toContain("first output"); writeFileSync(f.path, readFileSync(f.path, "utf8").replace("[x] goal: first", "[ ] goal: first")); f.hooks.get("agent_end")({}, f.ctx); expect(f.ctx.ui.setStatus).toHaveBeenLastCalledWith("goals", "👀 0/2 goals"); f.shutdown(); }); -it("reviews a plan replaced atomically with direct short context, and ignores writes that keep the same content", async () => { +it("reviews a plan replaced atomically with a current-file notice, and ignores writes that keep the same content", async () => { const f = fixture(); await f.draft(); const plan = `# Context title @@ -305,11 +342,11 @@ old progress`; await f.atomicWrite(revised); await waitFor(() => f.changed() === 1); const review = f.messages.find((m) => m.message.content.includes("Plan changed"))?.message.content; - expect(review).toContain("A short introduction for ordinary reminders."); - expect(review).toContain("A revised visible artifact."); + expect(review).toContain("inspect current requirements"); expect(review).toContain(f.path); expect(review).not.toContain("The full requirement must survive resync."); expect(review).not.toContain("run the detailed check"); + f.hooks.get("message_end")({ message: { role: "user", content: review } }); await f.atomicWrite(revised.replace("A revised", "A second revised")); await waitFor(() => f.changed() === 2); // Rewriting identical bytes must not retrigger the review event hook. @@ -319,13 +356,23 @@ old progress`; f.shutdown(); }); -it("coalesces duplicate plan-change notifications into one review", async () => { +it("delivers changed plans while coalescing only its own pending notice", async () => { const f = fixture(); await f.draft(); await f.command("ready"); + f.ctx.hasPendingMessages.mockReturnValue(true); // An unrelated queued prompt must not suppress the notice. await f.atomicWrite(f.plan.replace("## Log", "- discriminator: first burst edit\n## Log")); await f.atomicWrite(f.plan.replace("## Log", "- discriminator: second burst edit\n## Log")); await waitFor(() => f.changed() === 1); + f.hooks.get("message_end")({ message: { role: "user", content: "unrelated input" } }); + await f.atomicWrite(f.plan.replace("## Log", "- discriminator: later queued edit\n## Log")); await delay(200); expect(f.changed()).toBe(1); + f.hooks.get("message_end")({ message: { role: "user", content: f.messages.at(-1).message.content } }); + await f.atomicWrite(f.plan.replace("## Log", "- discriminator: after same-run delivery\n## Log")); + await waitFor(() => f.changed() === 2); + expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content).toContain("after same-run delivery"); + f.hooks.get("message_end")({ message: { role: "user", content: f.messages.at(-1).message.content } }); + await f.atomicWrite(f.plan.replaceAll("[ ] goal:", "[-] goal:")); + await waitFor(() => f.changed() === 3); // Cancelling the last goals must still notify an ongoing run. f.shutdown(); }); @@ -377,6 +424,7 @@ it("requires a full-plan review turn before recording the final goal", async () ## Log - worker evidence: keep this history in the final review`; writeFileSync(f.path, plan); await f.command("ready"); + f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); mkdirSync(join(f.ctx.cwd, "evidence")); writeFileSync(join(f.ctx.cwd, "evidence/pass.log"), "bytes\n"); const complete = (goal: string) => f.tools.get("CompleteGoal").execute("t", { goal, evidence: ["evidence/pass.log"], observation: "inspected" }, undefined, undefined, f.ctx); await complete("first output"); @@ -385,16 +433,18 @@ it("requires a full-plan review turn before recording the final goal", async () expect(readFileSync(f.path, "utf8")).toContain("- [ ] goal: second output"); const direct = f.messages.at(-1); expect(direct.savedPrompt).toBe(true); - expect(direct.message.content).toContain("second output has exact saved bytes"); - expect(direct.message.content).toContain("worker evidence: keep this history"); - const review = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message; - expect(review).toMatchObject({ customType: "pi-goals-final-review" }); - expect(review.content).toContain("first output has exact saved bytes"); - expect(review.content).toContain("worker evidence: keep this history"); + expect(direct.message.content).toContain("Read the complete file at"); + expect(direct.message.content).not.toContain("worker evidence: keep this history"); + // A queued follow-up may be consumed without another before_agent_start. + f.hooks.get("message_end")({ message: { role: "user", content: direct.message.content } }); + expect(readFileSync(f.path, "utf8")).toContain("second output has exact saved bytes"); + f.hooks.get("turn_end")({}, f.ctx); // Evidence-reading tool round must not invalidate this review. const finalText = (await complete("second output")).content[0].text; expect(finalText).toContain("All non-cancelled goals are reviewed."); expect(finalText).toContain('job named "goals-copy-only"'); expect(finalText).toContain("leave other jobs untouched"); + for (let i = 0; i < 10; i++) f.hooks.get("turn_end")({}, f.ctx); + expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined(); f.shutdown(); }); @@ -404,31 +454,34 @@ it("recovers a queued final review and invalidates it when the plan changes", as const complete = (goal: string) => f.tools.get("CompleteGoal").execute("t", { goal, evidence: ["proof.log"], observation: "inspected" }, undefined, undefined, f.ctx); await complete("first output"); await complete("second output"); + const oldPrompt = f.messages.at(-1).message.content; f.hooks.get("session_start")({}, f.ctx); const recovered = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message; expect(recovered).toMatchObject({ customType: "pi-goals-final-review" }); expect(recovered.content).toContain("- [ ] goal: second output"); - writeFileSync(f.path, readFileSync(f.path, "utf8").replace("second output", "revised second output")); - const invalidated = await complete("revised second output"); + writeFileSync(f.path, readFileSync(f.path, "utf8").replace("## Log", " - discriminator: changed exact bytes\n## Log")); + const invalidated = await complete("second output"); expect(invalidated.content[0].text).toContain("plan changed since the final review"); - const changed = await complete("revised second output"); + const changed = await complete("second output"); expect(changed.content[0].text).toContain("Final review queued"); - expect(readFileSync(f.path, "utf8")).toContain("- [ ] goal: revised second output"); - expect(f.messages.at(-1).message.content).toContain("revised second output"); + expect(readFileSync(f.path, "utf8")).toContain("- [ ] goal: second output"); + expect(f.messages.at(-1).message.content).toContain("second output"); + f.hooks.get("message_end")({ message: { role: "user", content: oldPrompt } }); + expect((await complete("second output")).content[0].text).toContain("Final review queued"); f.shutdown(); }); -it("restores the complete plan document after session restore", async () => { +it("restores the active plan above Log after session restore", async () => { const f = fixture(); await f.draft(); const plan = `${f.plan.replace("## Log", "## User voice\n- > \"Keep the user voice after restore.\"\n## Log")}old progress`; writeFileSync(f.path, plan); await f.command("ready"); f.hooks.get("session_start")({}, f.ctx); const restored = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); expect(restored.message.content).toContain("Keep the user voice after restore."); - expect(restored.message.content).toContain("old progress"); + expect(restored.message.content).not.toContain("old progress"); }); -it("restores the complete plan document after compaction without reinstalling or overriding scheduler jobs", async () => { +it("restores the active plan above Log after compaction without reinstalling or overriding scheduler jobs", async () => { const f = fixture(); await f.draft(); const plan = `${f.plan.replace("## Log", "## User voice\n- > \"Keep this exact requirement.\"\n - task detail\n## Log")}old progress`; writeFileSync(f.path, plan); await f.command("ready"); @@ -437,7 +490,7 @@ it("restores the complete plan document after compaction without reinstalling or expect(result.systemPrompt).not.toContain("add one session-bound"); expect(result.message.content).toContain("Keep this exact requirement."); expect(result.message.content).toContain("task detail"); - expect(result.message.content).toContain("old progress"); + expect(result.message.content).not.toContain("old progress"); expect(result.message.content).toContain(f.path); }); @@ -619,8 +672,10 @@ it("ignores post-completion maintenance but reviews evidence, requirement or man // contradictory evidence block through exactly this event (LUCID3, 2026-09-10). await f.atomicWrite(signed.replace("## Log", " - evidence: proof.log\n## Log\n- recap: finished")); await waitFor(() => f.changed() === 1); + f.hooks.get("message_end")({ message: { role: "user", content: f.messages.at(-1).message.content } }); await f.atomicWrite(signed.replace("## Log", "- discriminator: exact bytes and trailing newline\n## Log")); await waitFor(() => f.changed() === 2); + f.hooks.get("message_end")({ message: { role: "user", content: f.messages.at(-1).message.content } }); await f.atomicWrite(signed.replace("[x] goal: first", "[ ] goal: first")); await waitFor(() => f.changed() === 3); expect(f.entries.at(-1).data.signoffs["first output"]).toBeUndefined(); @@ -683,13 +738,13 @@ it("prioritizes unfinished goals and says when the widget list is truncated", as const f = fixture(); await f.draft(); writeFileSync(f.path, "- [x] goal: completed one\n- [x] goal: completed two\n- [/] goal: active work\n- [ ] goal: open one\n- [ ] goal: open two\n"); await f.command("ready"); - expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual(["◼ G3: active work", "◻ G4: open one", "◻ G5: open two", "… 2 ✔"]); + expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([relative(f.ctx.cwd, f.path), "◼ G3: active work", "◻ G4: open one", "◻ G5: open two", "… 2 ✓"]); f.shutdown(); }); it.each([ - ["[x]", "[ ]", "[ ]", "… 1 ✔, 2 ◻"], - ["[/]", "[x]", "[-]", "… 1 ✔, 1 ◼, 1 ✗"], + ["[x]", "[ ]", "[ ]", "… 1 ✓, 2 ◻"], + ["[/]", "[x]", "[-]", "… 1 ✓, 1 ◼, 1 ✗"], ["[ ]", "[ ]", "[ ]", "… 3 ◻"], ])("summarizes only hidden goal statuses: %s %s %s", async (first, second, third, summary) => { const f = fixture(); await f.draft(); @@ -697,7 +752,7 @@ it.each([ writeFileSync(f.path, marks.map((mark, index) => `- ${mark} goal: output ${index + 1}`).join("\n")); await f.command("ready"); expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([ - "◼ G1: output 1", "◼ G2: output 2", "◼ G3: output 3", summary, + relative(f.ctx.cwd, f.path), "◼ G1: output 1", "◼ G2: output 2", "◼ G3: output 3", summary, ]); f.shutdown(); }); @@ -708,7 +763,7 @@ it.each(["solo", "supervising"])("%s widget omits long tasks without altering th writeFileSync(f.path, text); if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); } else await f.command("ready"); - expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual(["◼ G1: first output", "◻ G2: second output"]); + expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual([relative(f.ctx.cwd, f.path), "◼ G1: first output", "◻ G2: second output"]); expect(readFileSync(f.path, "utf8")).toBe(text); }); @@ -736,13 +791,13 @@ it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, and joins writeFileSync(f.path, f.plan.replace("first output", "refined output")); f.hooks.get("turn_end")({}, f.ctx); for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx); - expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined(); + expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content).toContain("refined output"); await f.command("stop"); for (let i = 0; i < 10; i++) f.hooks.get("turn_end")({}, f.ctx); expect(reminders()).toHaveLength(0); }); -it("injects medium direct context after the bounded unchanged-turn reminder", async () => { +it("injects only unfinished goal lines after the bounded unchanged-turn reminder", async () => { const f = fixture(); await f.draft(); const plan = `# Context title @@ -763,19 +818,19 @@ A visible artifact. ## Log old progress`; writeFileSync(f.path, plan); await f.command("ready"); - // Consume startup full context before observing the medium reminder. -- PI/gpt-5.6-terra + // Consume active context before observing the routine reminder. f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx); const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message; expect(reminder.customType).toBe("pi-goals-upkeep"); - expect(reminder.content).toContain("Keep this exact user requirement."); + expect(reminder.content).not.toContain("Keep this exact user requirement."); expect(reminder.content).toContain("goal: produce the artifact"); expect(reminder.content).not.toContain("run the detailed check"); expect(reminder.content).not.toContain("proof.log"); expect(reminder.content).not.toContain("old progress"); }); -it.each(["supervising", "solo"])("%s repeats upkeep every eight unchanged turns and rotates only delivered supervisor nudges", async mode => { +it.each(["supervising", "solo"])("%s repeats concise upkeep every eight unchanged turns", async mode => { const f = fixture(); await f.draft(); if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); } else await f.command("ready"); @@ -785,14 +840,14 @@ it.each(["supervising", "solo"])("%s repeats upkeep every eight unchanged turns f.hooks.get("session_compact")(); expect(prepare().message.customType).toBe("pi-goals-plan"); const sent = f.messages.length; - for (let round = 0; round <= upkeepNudges.length; round++) { + for (let round = 0; round < 2; round++) { for (let turn = 0; turn < 7; turn++) f.hooks.get("turn_end")({}, f.ctx); expect(prepare().message).toBeUndefined(); f.hooks.get("turn_end")({}, f.ctx); expect(f.messages).toHaveLength(sent); expect(prepare().message).toMatchObject({ customType: "pi-goals-upkeep", - content: upkeep(f.path, f.plan, mode === "supervising" ? round : undefined), + content: upkeep(f.path, f.plan.split("\n").filter(line => line.includes("goal:")).join("\n")), }); expect(prepare().message).toBeUndefined(); } diff --git a/test/prompts.test.ts b/test/prompts.test.ts index 0463f63..a53c0ad 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { manualReview, planChangedReview, planContext, planning, planningSeed, readyApproved, upkeep, upkeepNudges } from "../src/prompts.js"; +import { manualReview, planChangedReview, planContext, planning, planningSeed, readyApproved, upkeep } from "../src/prompts.js"; const plan = `# Keep the user context @@ -38,28 +38,25 @@ it("keeps hindsight-judged user outcomes in initial and recurring planning instr expect(seed).not.toContain("imperative outcome"); }); -it("cycles the curated supervisor nudges without changing the direct medium reminder", () => { +it("keeps routine upkeep to its reason, goal lines and source path", () => { const base = upkeep("/plan.md", plan); - const variants = upkeepNudges.map((_, round) => upkeep("/plan.md", plan, round)); - expect(new Set(variants).size).toBe(upkeepNudges.length); - for (const text of variants) expect(text.endsWith(base)).toBe(true); - expect(upkeep("/plan.md", plan, upkeepNudges.length)).toBe(variants[0]); - expect(base).toContain("Preserve this requirement word for word."); + expect(base).toMatch(/^\[pi-goals: reminder — upkeep\]\n/); + expect(base).not.toContain("Preserve this requirement word for word."); + expect(base).toContain("Eight unchanged turns"); + expect(base).toContain("/plan.md"); expect(base).toContain("goal: verify output"); expect(base).not.toContain("run the full check"); }); -it("injects direct short, medium and full context tiers", () => { +it("separates routine goal lines from active context without historical Log", () => { const short = planContext("supervising", "/plan.md", plan, "short"); - expect(short).toContain("# Keep the user context"); - expect(short).toContain("Make the requested output easy to inspect."); - expect(short).toContain("A concrete artifact the user can read."); + expect(short).toContain("unfinished or unreviewed goal lines"); expect(short).toContain("/plan.md"); expect(short).not.toContain("Preserve this requirement"); - expect(short).not.toContain("goal: verify output"); + expect(short).toContain("goal: verify output"); const medium = planContext("supervising", "/plan.md", plan, "medium"); - expect(medium).toContain("Preserve this requirement word for word."); + expect(medium).not.toContain("Preserve this requirement word for word."); expect(medium).toContain("1. [/] goal: verify output"); expect(medium).not.toContain("run the full check"); expect(medium).not.toContain("proof.log"); @@ -68,13 +65,13 @@ it("injects direct short, medium and full context tiers", () => { const full = planContext("supervising", "/plan.md", plan, "full"); expect(full).toContain("run the full check"); expect(full).toContain("proof.log"); - expect(full).toContain("old progress report"); + expect(full).toContain("Preserve this requirement word for word."); + expect(full).not.toContain("old progress report"); }); -it("puts direct short context in plan-change and manual-review messages", () => { +it("puts selected goal lines in plan-change and manual-review messages", () => { for (const text of [planChangedReview("/plan.md", plan), manualReview("/plan.md", plan)]) { - expect(text).toContain("Make the requested output easy to inspect."); - expect(text).toContain("A concrete artifact the user can read."); + expect(text).toContain("goal: verify output"); expect(text).toContain("/plan.md"); expect(text).not.toContain("Preserve this requirement word for word."); expect(text).not.toContain("run the full check"); @@ -84,8 +81,8 @@ it("puts direct short context in plan-change and manual-review messages", () => it("keeps approved work moving after evidence review without overriding pauses or scope approval", () => { const text = planChangedReview("/plan.md", plan); expect(text).toContain("Evidence-only edits do not revoke execution approval"); - expect(text).toContain("continue unfinished authorized implementation rather than another recap"); - expect(text).toContain("respect explicit pauses and do not assume approval for changed scope"); + expect(text).toContain("Continue only unfinished authorized work"); + expect(text).toContain("respect pauses and do not assume approval for changed scope"); }); it("keeps the current working set in the ready message but omits history", () => { diff --git a/test/rpc-review.test.ts b/test/rpc-review.test.ts index e0f1d10..30d115e 100644 --- a/test/rpc-review.test.ts +++ b/test/rpc-review.test.ts @@ -114,7 +114,9 @@ describe("RPC review flow", () => { client.send({ type: "prompt", id: "goals", message: "/goals new work out the thing" }); const review = await client.waitFor(isSelect); expect(review.options).toEqual(["Ready", "Discuss", "Edit", "Cancel"]); - expect(basename(planPath)).toMatch(/^\d{4}-\d{2}-\d{2}-\d{6}Z-work-out-the-thing-v1\.md$/); + client.send({ type: "get_state", id: "session-name" }); + const state = await client.waitFor(message => message.type === "response" && message.id === "session-name"); + expect(basename(planPath)).toBe(`${(state.data as { sessionId: string }).sessionId.slice(-6)}-v1.md`); expect(review.title).toContain(planPath); const proposal = client.messages.find(message => message.type === "message_end" && (message.message as { customType?: string })?.customType === "goal-plan-proposal"); expect(proposal?.message).toMatchObject({ content: plan, display: true }); @@ -136,11 +138,18 @@ describe("RPC review flow", () => { expect(readFileSync(planPath, "utf8")).toBe(approvedPlan); expect(requests).toHaveLength(2); } else { - await client.waitFor(message => message.type === "agent_end", choiceStart); + await client.waitFor(message => message.type === "agent_settled", choiceStart); + client.send({ type: "get_state", id: "idle-discuss" }); + const idle = await client.waitFor(message => message.type === "response" && message.id === "idle-discuss"); + expect(idle.data).toMatchObject({ isStreaming: false, pendingMessageCount: 0 }); + expect(requests).toHaveLength(2); + expect(client.messages.slice(choiceStart).filter(message => message.type === "agent_start" || isEditor(message))).toEqual([]); + const userStart = client.messages.length; + client.send({ type: "prompt", id: "user-discussion", message: "Keep the output name, but explain the failure mode." }); + await client.waitFor(message => message.type === "agent_settled", userStart); expect(requests).toHaveLength(3); expect(systemText(requests[2])).toContain("Plan only in"); - expect(JSON.stringify(requests[2].messages.at(-1))).toContain("Discuss the current draft"); - expect(client.messages.slice(choiceStart).filter(isEditor)).toEqual([]); + expect(JSON.stringify(requests[2].messages)).toContain("Keep the output name, but explain the failure mode."); } const beforeReady = requests.length; const reopenStart = client.messages.length; @@ -155,6 +164,9 @@ describe("RPC review flow", () => { expect(systemText(supervisor)).toContain("You are the goal supervisor in the main chat"); expect(systemText(supervisor)).not.toContain("Plan only in"); expect(JSON.stringify(supervisor.messages)).toContain(JSON.stringify(foldPlan(approvedPlan)).slice(1, -1)); + const approval = supervisor.messages.filter(message => message.role === "user").map(message => messageText(message.content)).find(text => text.includes("Ready approved this plan:"))!; + expect(approval).toContain("[pi-goals: approval — Ready]"); + expect(approval).toContain(`Plan excerpt (working set before Log) from ${JSON.stringify(planPath)}:\n\x60\x60\x60md\n${foldPlan(approvedPlan)}\n\x60\x60\x60`); expect(client.messages.filter(message => message.type === "tool_execution_start").map(message => message.toolName)).toEqual(["write"]); expect(client.messages.filter(message => message.type === "extension_error")).toEqual([]); const notices = client.messages.filter(message => message.type === "entry_appended" && (message.entry as { customType?: string })?.customType === "pi-goals-notice");