diff --git a/README.md b/README.md index a28f240..bf47812 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,9 @@ pi ## Context delivery -Startup and successful compaction refresh the current plan above the Log at the next ordinary prompt (`before_agent_start`); history stays in the file for reading when needed. A Log heading at any Markdown heading level starts history. Other headings, such as Interview, do not end the current plan. Compaction uses Pi's configured threshold; this plugin does not set a separate 150k limit. Upkeep becomes due after each eight unchanged turns, but waits for that same prompt boundary. Supervisor upkeep cycles through curated nudges, advancing only when delivered; the editable hourly prompt 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, 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. 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. -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. +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. ## Prompts diff --git a/src/index.ts b/src/index.ts index 720fbcb..f375d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { let workerRevision = 0; const pendingLaunches = new Map(); let notice = true; + let fullPlanContextDue = true; let planWatcher: FSWatcher | undefined; let planEditTimer: ReturnType | undefined; let planHash = ""; @@ -155,7 +156,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (hash === planHash) return; planHash = hash; notice = true; - send(planChangedReview(state.plan!)); + send(planChangedReview(state.plan!, snapshot.text)); }, 150); }); planWatcher.on("error", (error) => { planWatcher?.close(); planWatcher = undefined; ctx.ui.notify(`Plan monitoring failed: ${error.message}`, "error"); }); @@ -179,6 +180,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { upkeepRound = 0; lastWorkingSet = ""; pendingUpkeep = undefined; + fullPlanContextDue = true; refresh(ctx); watchPlan(ctx); } @@ -239,7 +241,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { pi.on("session_shutdown", () => { generation++; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; }); // Only successful compaction needs resync; failed/cancelled attempts leave pending context alone. // Defer to prompt preparation: same-run continuation retains Pi's current role/context. - pi.on("session_compact", () => { notice = true; }); + pi.on("session_compact", () => { notice = true; fullPlanContextDue = true; }); pi.on("turn_end", (_event, ctx) => { if (!["supervising", "solo"].includes(state.mode)) return; const snapshot = readPlan(); @@ -284,13 +286,14 @@ export default function mainSupervisor(pi: ExtensionAPI) { // 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 = notice - ? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text), display: false } + ? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, 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!, state.mode === "supervising" ? upkeepRound : undefined), display: false } : undefined; + ? { 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++; if (message) turnsStale = 0; notice = false; + fullPlanContextDue = false; pendingUpkeep = undefined; return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(message ? { message } : {}) }; }); @@ -379,7 +382,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (state.mode !== "planning") { ctx.ui.notify("Discuss applies to a draft.", "warning"); return; } send(discuss); return; } - if (command === "review" && state.mode === "supervising") { notice = true; send(manualReview(state.plan ?? "")); return; } + if (command === "review" && state.mode === "supervising") { notice = true; send(manualReview(state.plan ?? "", 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; } @@ -459,12 +462,18 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (command !== "new" && !command.startsWith("new ")) { ctx.ui.notify(`Unknown or incomplete command. ${help}`, "warning"); return; } const objective = command.slice(4).trim(); 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; } - let path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`); - mkdirSync(dirname(path), { recursive: true }); - try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - path = join(dirname(path), `${ctx.sessionManager.getSessionId()}-${randomUUID()}.md`); - writeFileSync(path, planDocument(objective), { flag: "wx" }); + 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; + let path: string; + for (;;) { + path = join(planDir, `${timestamp}-${slug}-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); send(planningSeed(objective, path)); diff --git a/src/plan.ts b/src/plan.ts index ace9d51..dac9ea4 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -8,6 +8,42 @@ export function foldPlan(plan: string): string { return (match ? plan.slice(0, match.index) : plan).trimEnd(); } +const heading = /^(#{1,6})[ \t]+(.+?)[ \t]*$/; + +function namedSection(lines: string[], name: string): string[] { + const start = lines.findIndex((line) => { + const match = heading.exec(line); + return match?.[2].toLowerCase() === name.toLowerCase(); + }); + if (start === -1) return []; + const level = heading.exec(lines[start])![1].length; + const end = lines.findIndex((line, index) => index > start && (heading.exec(line)?.[1].length ?? Infinity) <= level); + return lines.slice(start, end === -1 ? undefined : end).join("\n").trimEnd().split("\n"); +} + +// Context tiers retain direct requirements while keeping normal reminders small. -- PI/gpt-5.6-terra +export function planContextView(plan: string, tier: "short" | "medium" | "full"): string { + if (tier === "full") return plan.trimEnd(); + const workingSet = foldPlan(plan); + const lines = workingSet.split("\n"); + const titleIndex = lines.findIndex(line => /^#(?!#)[ \t]+/.test(line)); + const title = titleIndex === -1 ? [] : [lines[titleIndex]]; + const introStart = titleIndex === -1 ? 0 : titleIndex + 1; + let firstContent = introStart; + while (firstContent < lines.length && !lines[firstContent].trim()) firstContent++; + const intro: string[] = []; + if (firstContent < lines.length && !heading.test(lines[firstContent])) { + for (let index = firstContent; index < lines.length && lines[index].trim(); index++) intro.push(lines[index]); + } + const result = namedSection(lines, "User-visible result"); + const short = [...title, ...(intro.length ? ["", ...intro] : []), ...(result.length ? ["", ...result] : [])].join("\n").trimEnd(); + if (tier === "short") return short; + const userVoice = namedSection(lines, "User voice"); + const goalsHeading = namedSection(lines, "Goals")[0]; + const goalLines = lines.filter(line => GOAL_LINE.test(line)); + return [short, ...(userVoice.length ? ["", ...userVoice] : []), ...(goalsHeading && goalLines.length ? ["", goalsHeading, ...goalLines] : [])].join("\n").trimEnd(); +} + // Pi/OpenAI: Approval covers shared requirements and this goal, not checkbox/task/evidence maintenance. export function goalAcceptanceSignature(plan: string, goal: string): string | undefined { const lines = foldPlan(plan).split("\n"); diff --git a/src/prompts.ts b/src/prompts.ts index 42a94f2..fc58ba5 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -1,5 +1,5 @@ // Pi/OpenAI: Planning, approval, supervision, reminders, completion and recovery. -import { foldPlan } from "./plan.js"; +import { foldPlan, planContextView } from "./plan.js"; 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. @@ -180,18 +180,18 @@ export const upkeepNudges = [ "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, supervisorRound?: number): string { +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 in ${planPath} 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.`; + 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}`; } -export function planContext(mode: string, path: string | undefined, text: string): string { - return `Current goal mode: ${mode}. Earlier role messages are historical; this current role governs.\nPlan: ${path ?? "not attached"}\n${foldPlan(text)}\n\nRead historical Log entries from the plan file when needed.`; +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"}`; } -export function planChangedReview(planPath: string): string { - return `${supervisorJob}\nPlan changed: ${planPath}. Read the current working set and inspect changed requirements, completion claims and evidence. 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): string { + return `${supervisorJob}\nPlan changed. Inspect changed requirements, completion claims and evidence.\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 manualReview(planPath: string): string { - return `${supervisorJob}\nReview the current plan ${planPath}, worker progress and actual evidence. Do not launch a duplicate writer.`; +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.`; } // Check-ins. The installed scheduler owns storage/timing/UI. Removal guidance must never add jobs. diff --git a/test/goals.test.ts b/test/goals.test.ts index 6f07880..4729883 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +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"; @@ -42,8 +42,15 @@ function fixture(child = false) { }; goalsExtension(pi as unknown as ExtensionAPI); hooks.get("session_start")({}, ctx); - const command = (value: string) => commands.get("goals").handler(value, ctx); - const path = join(cwd, ".pi/plan/copy-only-main.md"); + let path = ""; + const command = async (value: string) => { + await commands.get("goals").handler(value, ctx); + const planDir = join(cwd, ".pi", "plan"); + if (!path && existsSync(planDir)) { + const firstPlan = readdirSync(planDir).find(name => name.endsWith(".md")); + if (firstPlan) path = join(planDir, firstPlan); + } + }; const plan = "# Plan\n- [ ] goal: first output\n- [ ] goal: second output\n\n## Log\n"; const draft = async () => { await command("new two outputs"); writeFileSync(path, plan); }; const shutdown = () => hooks.get("session_shutdown")(); @@ -61,7 +68,7 @@ function fixture(child = false) { start(details.id, { agent, title: "Work", sessionFile: details.sessionFile }, toolName); finish(details.id, details, toolName); }; - return { ctx, pi, hooks, tools, commands, messages, command, path, plan, draft, shutdown, changed, atomicWrite, entries, start, finish, launch }; + return { ctx, pi, hooks, tools, commands, messages, command, get path() { return path; }, plan, draft, shutdown, changed, atomicWrite, entries, start, finish, launch }; } it.each([ @@ -267,17 +274,39 @@ it("requires actual nonempty evidence, distinguishes manual ticks, and retains s f.shutdown(); }); -it("reviews a plan replaced atomically, and ignores writes that keep the same content", async () => { - const f = fixture(); await f.draft(); await f.command("ready"); - await f.atomicWrite(f.plan.replace("## Log", "- discriminator: changed requirement\n## Log")); +it("reviews a plan replaced atomically with direct short context, and ignores writes that keep the same content", async () => { + const f = fixture(); await f.draft(); + const plan = `# Context title + +A short introduction for ordinary reminders. + +## User-visible result +A visible artifact. + +## User voice +- > "The full requirement must survive resync." + +## Goals +- [ ] goal: produce the artifact + - tasks: + - [ ] run the detailed check + +## Log +old progress`; + writeFileSync(f.path, plan); await f.command("ready"); + const revised = plan.replace("A visible artifact.", "A revised visible artifact."); + 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("Plan changed: "); + expect(review).toContain("A short introduction for ordinary reminders."); + expect(review).toContain("A revised visible artifact."); expect(review).toContain(f.path); - await f.atomicWrite(f.plan.replace("## Log", "- discriminator: same requirement again\n## Log")); + expect(review).not.toContain("The full requirement must survive resync."); + expect(review).not.toContain("run the detailed check"); + 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. - const same = f.plan.replace("## Log", "- discriminator: same requirement again\n## Log"); + const same = revised.replace("A revised", "A second revised"); writeFileSync(f.path, same); await delay(300); expect(f.changed()).toBe(2); f.shutdown(); @@ -343,11 +372,26 @@ it("tells the model to remove only its own job after the final review", async () f.shutdown(); }); -it("restores context after compaction without reinstalling or overriding scheduler jobs", async () => { - const f = fixture(); await f.draft(); await f.command("ready"); +it("restores the complete plan document 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"); +}); + +it("restores the complete plan document 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"); f.hooks.get("session_compact")(); const result = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); 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).toContain(f.path); }); @@ -621,7 +665,7 @@ it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, and joins const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message; expect(reminder.customType).toBe("pi-goals-upkeep"); expect(reminder.content).toContain(f.path); - expect(reminder.content).not.toContain("first output"); + expect(reminder.content).toContain("first output"); expect(reminder.content).not.toContain("historical recap"); expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined(); writeFileSync(f.path, f.plan.replace("first output", "refined output")); @@ -633,6 +677,39 @@ it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, and joins expect(reminders()).toHaveLength(0); }); +it("injects medium direct context after the bounded unchanged-turn reminder", async () => { + const f = fixture(); await f.draft(); + const plan = `# Context title + +A short introduction. + +## User-visible result +A visible artifact. + +## User voice +- > "Keep this exact user requirement." + +## Goals +- [/] goal: produce the artifact + - tasks: + - [ ] run the detailed check + - evidence: proof.log + +## 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 + 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).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 => { const f = fixture(); await f.draft(); if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); } @@ -650,7 +727,7 @@ it.each(["supervising", "solo"])("%s repeats upkeep every eight unchanged turns expect(f.messages).toHaveLength(sent); expect(prepare().message).toMatchObject({ customType: "pi-goals-upkeep", - content: upkeep(f.path, mode === "supervising" ? round : undefined), + content: upkeep(f.path, f.plan, mode === "supervising" ? round : undefined), }); expect(prepare().message).toBeUndefined(); } diff --git a/test/prompts.test.ts b/test/prompts.test.ts index c84d80c..4811d9b 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -1,23 +1,71 @@ import { expect, it } from "vitest"; -import { planContext, readyApproved, upkeep, upkeepNudges } from "../src/prompts.js"; +import { manualReview, planChangedReview, planContext, readyApproved, upkeep, upkeepNudges } from "../src/prompts.js"; -it("cycles the curated supervisor nudges without changing the shared upkeep instructions", () => { - const base = upkeep("/plan.md"); - const variants = upkeepNudges.map((_, round) => upkeep("/plan.md", round)); +const plan = `# Keep the user context + +Make the requested output easy to inspect. + +## User-visible result +A concrete artifact the user can read. + +## User voice +- > "Preserve this requirement word for word." + +## Goals +1. [/] goal: verify output + - tasks: + 1. [ ] run the full check + - evidence: proof.log + +## Log +old progress report`; + +it("cycles the curated supervisor nudges without changing the direct medium reminder", () => { + 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", upkeepNudges.length)).toBe(variants[0]); - expect(base).toContain("/plan.md"); + expect(upkeep("/plan.md", plan, upkeepNudges.length)).toBe(variants[0]); + expect(base).toContain("Preserve this requirement word for word."); + expect(base).toContain("goal: verify output"); + expect(base).not.toContain("run the full check"); }); -it.each(["## Log", "### Log"])("keeps all current requirements but omits %s history from refreshed and approved context", heading => { - const requirements = "- > The human's full requested output and conditions.\n".repeat(80); - const plan = `# Plan\n## User voice\n${requirements}\n- [ ] goal: verify output\n${heading}\nold progress report`; - const refreshed = planContext("supervising", "/plan.md", plan); - const approved = readyApproved("goals-worker", "/plan.md", undefined, plan, "pi-session"); - for (const text of [refreshed, approved]) { - expect(text).toContain(requirements); - expect(text).toContain("goal: verify output"); - expect(text).not.toContain("old progress report"); +it("injects direct short, medium and full context tiers", () => { + 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("/plan.md"); + expect(short).not.toContain("Preserve this requirement"); + expect(short).not.toContain("goal: verify output"); + + const medium = planContext("supervising", "/plan.md", plan, "medium"); + expect(medium).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"); + expect(medium).not.toContain("old progress report"); + + 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"); +}); + +it("puts direct short context 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("/plan.md"); + expect(text).not.toContain("Preserve this requirement word for word."); + expect(text).not.toContain("run the full check"); } }); + +it("keeps the current working set in the ready message but omits history", () => { + const approved = readyApproved("goals-worker", "/plan.md", undefined, plan, "pi-session"); + expect(approved).toContain("Preserve this requirement word for word."); + expect(approved).toContain("goal: verify output"); + expect(approved).not.toContain("old progress report"); +}); diff --git a/test/rpc-review.test.ts b/test/rpc-review.test.ts index 57590e3..39dd4f6 100644 --- a/test/rpc-review.test.ts +++ b/test/rpc-review.test.ts @@ -3,7 +3,7 @@ import { once } from "node:events"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { describe, expect, it } from "vitest"; import { foldPlan } from "../src/plan.js"; @@ -73,8 +73,12 @@ describe("RPC review flow", () => { const server = createServer(async (request, response) => { let body = ""; for await (const chunk of request) body += chunk; - requests.push(JSON.parse(body)); + const modelRequest = JSON.parse(body) as ModelRequest; + requests.push(modelRequest); if (requests.length === 1) { + const pathMatch = systemText(modelRequest).match(/Plan only in (.+?);/); + if (!pathMatch) throw new Error("Planning prompt did not name its plan file"); + planPath = pathMatch[1]; streamResponse(response, { tool_calls: [{ index: 0, id: "write-plan", type: "function", @@ -106,14 +110,10 @@ describe("RPC review flow", () => { const client = new RpcClient(pi); const exited = once(pi, "exit"); try { - client.send({ type: "get_state", id: "state" }); - const state = await client.waitFor((message) => message.type === "response" && message.id === "state"); - const sessionId = (state.data as { sessionId: string }).sessionId; - planPath = join(cwd, ".pi", "plan", `${sessionId}-main.md`); - 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$/); 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 });