From 07510d96bd1e88cdbfe28e54b951f2b543cd4e51 Mon Sep 17 00:00:00 2001 From: wassname Date: Wed, 5 Aug 2026 12:08:16 +0800 Subject: [PATCH] fold the plan at ## Log: re-send the working set on a stale cadence, the whole file only on resync v2 injected the entire plan.md on every turn. pi-tasks tried that and deleted it -- "wallpaper noise that trains the model to ignore the task block" (CHANGELOG.md:149) -- so follow them: one transient user message via the context hook, never persisted, and only when the plan went untouched for 2 turns. Editing the plan resets the clock, the way a task tool call resets theirs. Session start and session_compact push the WHOLE file back instead, which is where the settled context is actually needed (pi-goal-x does the same with its post-compaction resync). That makes an unlimited appendix free: everything under ## Log is durable memory, not working set. Also: the drafting prompt is sent once with the /goals seed instead of every turn (that re-arming is why plan mode read as never-ending), the review menu gains "Open in $EDITOR" and loops like pi-plan's, and the widget shows the active goal's open subtasks so the plan is visibly the task list. Drops the stale-copy stripping hook, which a non-persisted injection doesn't need. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- src/index.ts | 195 +++++++++++++++++++++++++++++++--------------- src/prompts.ts | 95 ++++++++++++++++++---- test/fold.test.ts | 63 +++++++++++++++ 3 files changed, 272 insertions(+), 81 deletions(-) create mode 100644 test/fold.test.ts diff --git a/src/index.ts b/src/index.ts index 4066536..58e8ba9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,12 @@ * The v1 lesson: the parser existed so TypeScript could read plan.md, but almost every reader is a * model. So v2 has NO parser and no schema. The harness does exactly three things for a * cooperative-but-confused model: - * 1. memory — inject plan.md verbatim every turn (survives compaction; byte-identical when - * unchanged so the KV cache holds; stale copies stripped by the context hook) + * 1. memory — a transient re-send of the plan, never persisted, on two triggers: the plan went + * stale for STALE_TURNS turns (send the working set above ## Log), or the session + * started / compacted (send the whole file, appendix included). v2 sent the whole + * file every turn; pi-tasks tried that and deleted it as "wallpaper noise that + * trains the model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149), + * and the always-present CompleteGoal description carries the contract instead. * 2. format — a skeleton convention taught in planDrafting (prompts.ts), not validated * 3. eyes — CompleteGoal spawns a strictly read-only pi subprocess (--no-session, no bash) * that gets the whole plan file plus the claimed goal, finds the goal itself @@ -28,15 +32,14 @@ * All model-facing text lives in prompts.ts, in flow order. */ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "@sinclair/typebox"; -import { completeGoalDescription, completeGoalParamDescription, judgeSystem, judgeUser, planDrafting, reminder } from "./prompts.js"; +import { completeGoalDescription, completeGoalParamDescription, judgeSystem, judgeUser, planDrafting, reminder, resync } from "./prompts.js"; const STATE = "pi-goals-state"; -const PLAN_CONTEXT = "pi-goals-context"; // injected plan/guidance, stale copies stripped by the context hook const STATUS_KEY = "pi-goals"; const WIDGET_KEY = "pi-goals-widget"; const PLAN_REL = ".pi/plan.md"; @@ -49,22 +52,49 @@ const JUDGE_TIMEOUT_MS = 600_000; // Plan mode is read-only by convention AND a light gate: edit/write are blocked (except plan.md, // the deliverable). bash stays open — the prompt says don't mutate; guide, don't gate (spec D3). const PLAN_MODE_BLOCKED_TOOLS = ["edit", "write"]; +// Turns the plan may go untouched before it is re-sent. pi-tasks uses 4, or 2 while something is in +// progress; here every goal is "in progress", so 2. +const STALE_TURNS = 2; -// The one regex in the whole extension: a checkbox line beginning "goal:", for the widget and the -// "any goals open?" reminder condition. Everything else reads the file as prose. +// A checkbox line beginning "goal:", for the widget and the "any goals open?" reminder condition. +// Everything else reads the file as prose. const GOAL_LINE = /^\s*(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*goal:\s*(.*)$/i; +// An indented checkbox line that isn't a goal: a subtask. Only the widget reads these, so the human +// sees the next action and not just the goal -- this file IS the task list. +const SUBTASK_LINE = /^\s+(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*(.*)$/; +// The fold. Above it: the working set that gets re-sent. Below it: durable memory. +const FOLD_LINE = /^##\s+Log\s*$/im; type GoalStatus = "open" | "active" | "done" | "cancelled"; const CHAR_TO_STATUS: Record = { " ": "open", "/": "active", x: "done", "-": "cancelled" }; -function scanGoals(plan: string): Array<{ status: GoalStatus; subject: string }> { - const goals: Array<{ status: GoalStatus; subject: string }> = []; - for (const line of plan.split("\n")) { +function scanGoals(plan: string): Array<{ status: GoalStatus; subject: string; line: number }> { + const goals: Array<{ status: GoalStatus; subject: string; line: number }> = []; + plan.split("\n").forEach((line, i) => { const m = GOAL_LINE.exec(line); - if (m) goals.push({ status: CHAR_TO_STATUS[m[1].toLowerCase()] ?? "open", subject: m[2].trim() }); - } + if (m) goals.push({ status: CHAR_TO_STATUS[m[1].toLowerCase()] ?? "open", subject: m[2].trim(), line: i }); + }); return goals; } +/** The working set: everything above "## Log". Log, Learnings and Appendix below it are durable + * memory -- unlimited, read on demand, pushed back only by a resync. Exported for the unit test. */ +export function foldPlan(plan: string): string { + const m = FOLD_LINE.exec(plan); + return (m ? plan.slice(0, m.index) : plan).trimEnd(); +} + +/** Open subtasks under the goal on line `goalLine`, up to the next goal line. */ +export function openSubtasks(plan: string, goalLine: number): string[] { + const lines = plan.split("\n"); + const out: string[] = []; + for (let i = goalLine + 1; i < lines.length; i++) { + if (GOAL_LINE.test(lines[i])) break; + const m = SUBTASK_LINE.exec(lines[i]); + if (m && (m[1] === " " || m[1] === "/")) out.push(m[2].trim()); + } + return out; +} + interface PlanState { isPlanMode: boolean; /** Optional model ref for the sign-off judge; unset => current session model, else pi's default. */ @@ -73,8 +103,14 @@ interface PlanState { export default function piGoalsExtension(pi: ExtensionAPI): void { let state: PlanState = { isPlanMode: false, judgeModel: null }; - // Reminder cadence: fire when goals are open but plan.md was untouched since the last turn. - let lastInjectedPlan = ""; + // Reminder cadence (pi-tasks style): the plan is re-sent only after it has gone untouched for + // STALE_TURNS turns, and editing it resets the clock -- an agent that is maintaining the file + // doesn't need to be told to. In-memory, like pi-tasks: a new session starts fresh. + let turnsStale = 0; + let lastSeenPlan = ""; + // Set on session start and after a compaction; drained by the next LLM call, which then carries + // the WHOLE file (appendix included) instead of just the working set. + let resyncReason: string | null = "New session."; const planPath = (ctx: ExtensionContext) => join(ctx.cwd, ".pi", "plan.md"); const readPlan = (ctx: ExtensionContext): string => (existsSync(planPath(ctx)) ? readFileSync(planPath(ctx), "utf-8") : ""); @@ -102,9 +138,15 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { const done = goals.filter((g) => g.status === "done").length; ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals`)); const mark: Record = { done: "✔", active: "▸", open: "◻", cancelled: "✗" }; - // Only live goals get lines so finished work never pushes current work off screen. - const live = goals.filter((g) => g.status === "active" || g.status === "open"); - ctx.ui.setWidget(WIDGET_KEY, [ctx.ui.theme.fg("muted", PLAN_REL), ...live.map((g) => `${mark[g.status]} ${g.subject}`)]); + // Only live goals get lines so finished work never pushes current work off screen. The active + // goal also shows its open subtasks: this file is the task list, so the widget is the task list. + const plan = readPlan(ctx); + const lines = [ctx.ui.theme.fg("muted", PLAN_REL)]; + for (const g of goals.filter((g) => g.status === "active" || g.status === "open")) { + lines.push(`${mark[g.status]} ${g.subject}`); + if (g.status === "active") lines.push(...openSubtasks(plan, g.line).slice(0, 3).map((s) => ctx.ui.theme.fg("muted", ` ◦ ${s}`))); + } + ctx.ui.setWidget(WIDGET_KEY, lines); } // --- /goals: enter plan mode (or clear / set judge) -------------------------------------------- @@ -131,40 +173,67 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { state = { ...state, isPlanMode: true }; persist(); updateWidget(ctx); + // The drafting rules are sent ONCE, with the seed. v2 re-injected them every turn, which is + // why plan mode read as never-ending: every reply re-armed it. They come back only on a + // resync (session start / compaction), when the model has genuinely lost them. const seed = arg - ? `We're in plan mode. Objective: ${arg}\n\nExplore the repo read-only and ask me anything unclear. When the objective is nailed down, draft (or replace) the plan in ${planPath(ctx)}, then stop for review.` - : `We're in plan mode. Tell me what you want to plan. Explore read-only and ask questions as needed; when the objective is clear, draft the plan in ${planPath(ctx)} and stop for review.`; + ? `We're in plan mode. Objective: ${arg}\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.` + : `We're in plan mode. Tell me what you want to plan.\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.`; pi.sendUserMessage(seed, { deliverAs: "followUp" }); }, }); // --- hooks -------------------------------------------------------------------------------------- - pi.on("before_agent_start", async (_event, ctx) => { + /** What this LLM call should carry, if anything: a one-shot resync, or a staleness reminder. */ + function dueInjection(ctx: ExtensionContext, plan: string): string | null { + const drainResync = (): string | null => { + const why = resyncReason; + resyncReason = null; + return why; + }; if (state.isPlanMode) { - return { message: { customType: PLAN_CONTEXT, content: `${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.`, display: false } }; + const why = drainResync(); + return why ? `\n${why} You are still in plan mode.\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.\n` : null; } - const plan = readPlan(ctx); + if (!plan.trim()) return null; + const why = drainResync(); + if (why) return resync(plan, PLAN_REL, why); + if (turnsStale < STALE_TURNS) return null; const goals = scanGoals(plan); if (goals.length === 0) { // Non-empty plan but no recognizable goal line: the harness would go silently inert (no - // widget, no injection, no reminders). Say so once instead -- cooperative but confused. - if (!plan.trim()) return; - return { - message: { - customType: PLAN_CONTEXT, - content: `${PLAN_REL} exists but has no goal line pi-goals recognizes. A goal is a checkbox list line starting "goal:", e.g. "1. [ ] goal: " ([ ] open, [/] active, [x] done, [-] cancelled). Reformat if it's meant to be the plan.`, - display: false, - }, - }; + // widget, no injection, no reminders). Say so instead -- cooperative but confused. + return `\n${PLAN_REL} exists but has no goal line pi-goals recognizes. A goal is a checkbox list line starting "goal:", e.g. "1. [ ] goal: " ([ ] open, [/] active, [x] done, [-] cancelled). Reformat it if it's meant to be the plan.\n`; } - // The plan file itself IS the injection: no parsing, no summarizing, the model sees the - // literal file it edits. Byte-identical when unchanged, so the prefix cache holds. - let body = `Current plan (${PLAN_REL}; keep it updated with your edit tool):\n\n${plan}`; - const live = goals.some((g) => g.status === "active" || g.status === "open"); - if (live && plan === lastInjectedPlan) body += `\n\n${reminder}`; - lastInjectedPlan = plan; - return { message: { customType: PLAN_CONTEXT, content: body, display: false } }; + if (!goals.some((g) => g.status === "active" || g.status === "open")) return null; + return reminder(foldPlan(plan), PLAN_REL); + } + + // The one injection point: a transient user message on this LLM call only, never persisted. So + // there are no stale copies to strip, and an untouched turn costs nothing. + pi.on("context", async (event, ctx) => { + const text = dueInjection(ctx, readPlan(ctx)); + if (!text) return; + turnsStale = 0; + return { messages: [...event.messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] }; + }); + + // The staleness clock: editing the plan resets it, the way a task tool call resets pi-tasks'. + pi.on("turn_end", async (_event, ctx) => { + const plan = readPlan(ctx); + if (plan === lastSeenPlan) { + turnsStale++; + return; + } + lastSeenPlan = plan; + turnsStale = 0; + updateWidget(ctx); + }); + + // A compaction is exactly when the settled context is gone, so push the whole file back once. + pi.on("session_compact", async () => { + resyncReason = "The session was just compacted."; }); // Plan mode gate: block edit/write except on plan.md itself. bash stays open (guide, not gate). @@ -177,34 +246,30 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } }); - // After a plan-mode turn: if goals were drafted, offer Ready. No edit menus, no fresh-session - // dance — the human reads the file and says go (or keeps talking to revise it). + // After a plan-mode turn: if goals were drafted, offer Ready. The human reads the file and says + // go, edits it in $EDITOR, or keeps talking to revise it (menu shape borrowed from pi-plan). pi.on("agent_end", async (_event, ctx) => { if (!state.isPlanMode || !ctx.hasUI) return; - if (scanGoals(readPlan(ctx)).length === 0) return; // still exploring/asking; nothing to review yet - const choice = await ctx.ui.select(`Plan drafted in ${PLAN_REL}. Ready?`, [ - "Ready — start working the plan", - "Keep planning (reply to revise)", - ]); - if (!choice?.startsWith("Ready")) return; - state = { ...state, isPlanMode: false }; - persist(); - updateWidget(ctx); - pi.sendUserMessage( - `Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`, - { deliverAs: "followUp" }, - ); - }); - - // Keep only the freshest injected plan; strip stale ones so history doesn't bloat and the model - // never sees an out-of-date plan. - pi.on("context", async (event) => { - const isCtx = (m: unknown) => (m as { customType?: string }).customType === PLAN_CONTEXT; - let lastIdx = -1; - event.messages.forEach((m, i) => { - if (isCtx(m)) lastIdx = i; - }); - return { messages: event.messages.filter((m, i) => !isCtx(m) || i === lastIdx) }; + while (scanGoals(readPlan(ctx)).length > 0) { + const choice = await ctx.ui.select(`Plan drafted in ${PLAN_REL}. Ready?`, [ + "Ready — start working the plan", + "Open in $EDITOR — edit it myself", + "Keep planning (reply to revise)", + ]); + if (choice?.startsWith("Open")) { + spawnSync(process.env.EDITOR || process.env.VISUAL || "vi", [planPath(ctx)], { stdio: "inherit" }); + continue; + } + if (!choice?.startsWith("Ready")) return; + state = { ...state, isPlanMode: false }; + persist(); + updateWidget(ctx); + pi.sendUserMessage( + `Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`, + { deliverAs: "followUp" }, + ); + return; + } }); pi.on("session_start", async (_event, ctx) => { @@ -220,6 +285,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { .filter((e: { type?: string; customType?: string }) => e.type === "custom" && e.customType === STATE) .pop() as { data?: PlanState } | undefined; if (last?.data) state = { ...state, ...last.data }; + lastSeenPlan = readPlan(ctx); + resyncReason = "New session."; updateWidget(ctx); }); diff --git a/src/prompts.ts b/src/prompts.ts index 0f5d740..c05158e 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -4,13 +4,18 @@ * Design: plan.md is for LLMs and the human, not for TypeScript. There is no parser and no schema; * the skeleton below is a convention the drafting prompt teaches, the working agent maintains with * its normal Edit tool, and the judge reads natively. The harness does three things for a - * cooperative-but-confused model: memory (inject the file verbatim every turn), format guidance - * (the skeleton), and fresh eyes (the read-only judge in CompleteGoal). + * cooperative-but-confused model: memory (a transient re-send of the plan when it goes stale), + * format guidance (the skeleton), and fresh eyes (the read-only judge in CompleteGoal). + * + * THE FOLD: everything above "## Log" is the working set (title, user voice, goals, + * discriminators) and is what gets re-sent on the reminder cadence. Everything below it (Log, + * Learnings, Appendix) is durable memory: unlimited, read on demand, and re-sent in full only at + * session start and after a compaction, which is where the settled context is actually needed. * * Flow: - * SETUP (plan mode) 1. planDrafting — draft goals into plan.md (read-only phase) - * EXEC, each turn start 2. (the plan.md file itself, injected verbatim by index.ts) - * EXEC, periodic 3. reminder — upkeep + autonomy nudge when plan.md went untouched + * SETUP (plan mode) 1. planDrafting — draft goals into plan.md (read-only phase), sent once + * EXEC, on cadence 2. reminder — the folded plan + upkeep nudge when plan.md went stale + * EXEC, after compact 3. resync — the WHOLE file back, once * SIGN-OFF, agent-side 4. completeGoal* — the one blessed tool's description * SIGN-OFF, judge-side 5. judgeSystem/judgeUser — the one rigorous check * @@ -26,20 +31,37 @@ You are in plan mode. The objective may arrive through conversation, not as one Explore the repository read-only first: resolve discoverable facts by looking them up, and only ask the human when the answer is a genuine intent or preference choice. Do not write or run code in this phase (edit/write are blocked except for the plan file; don't mutate state via bash either). When -the objective is clear, draft the plan file and stop for review. +the objective is clear, draft the plan file and present it. + +How this mode ends: after each of your turns the human gets a menu (Ready / open in $EDITOR / keep +planning). Plan mode ends when they pick Ready. So close every draft with one line -- the plan is +final, pick Ready to start or reply to revise -- and do not redraft in silence. When a new +requirement arrives, fold it in, say what changed, and say the plan is final again. Detail that +doesn't change a goal or a discriminator belongs in the appendix, not in the goals. Right-size it: - Default to ONE goal. Add another only when it's a genuinely separate checkpoint that can pass or fail on its own. Most objectives are 1-2 goals. - Subtasks are the steps inside a goal; add them when a goal has 3+ distinct steps, skip otherwise. - Don't invent goals to look thorough. When in doubt, merge. +- Everything above "## Log" is the part the model carries while it works. Keep it under 50 lines, + reviewable in one pass. Everything below "## Log" is unlimited. + +Style: ASD-STE100 Simplified Technical English. Active voice, one idea per sentence, common words, +the same word for the same thing, and define a new term at first use. This covers the context +paragraph and the appendix too, not just the checklist. No all-caps headers and no bold spam; the +checklist is already the structure. Write the plan file in roughly this shape (it's a convention, not a schema -- the file is read directly by the human and a judge model, so clarity beats conformance; small deviations are fine): # - + + +## User voice + +- > "" ## Goals @@ -52,10 +74,16 @@ directly by the human and a judge model, so clarity beats conformance; small dev 1. [ ] - evidence: (empty until sign-off) -# Future work / out of scope +## Future work / out of scope + +<-- the fold: everything below here is durable memory, not the working set --> ## Log +## Learnings + +## Appendix (context, not approved) + Conventions: - A goal is a checkbox line beginning "goal:". Checkbox state: [ ] open, [/] active, [x] done, [-] cancelled. Leave goals [ ] at planning. @@ -69,24 +97,57 @@ Conventions: - evidence stays empty at planning; you fill it at sign-off and a fresh read-only judge checks it. Cite durable artifacts a future reader can open: committed files, test names, git diffs. .pi/ is usually gitignored, so files there prove things only at judge time, not in history. +- User voice: quote the human word for word, one line per requirement, as they say it. Never + paraphrase there -- a paraphrase drifts, and then the goals churn on the next reply. +- Rejected options stay visible: ~~struck through~~ with who rejected them and why, so nobody + relitigates them. +- Learnings: one line per gotcha that a future reader would otherwise rediscover. Write down what + you saw from a source that does not persist (a browser page, an image, a long log tail) before + you do anything else with it. +- Appendix: unlimited and unverified. Alternatives, links, dead ends, and the settled detail that + is not part of the approved goals. Nothing here is approved and nothing here is checked. -When the goals are drafted, present them and stop for review. Do not begin execution.`; +When the goals are drafted, present them and say the plan is final. Do not begin execution.`; /* ───────────────────────────────────────────────────────────────────────── - * 3. reminder — EXEC, appended to the injected plan when plan.md went - * untouched for a whole turn while goals are open. Wording stable (cache). + * 3. reminder — EXEC. Transient, never persisted, and only when the plan went stale for a couple of + * turns. pi-tasks tried a per-turn injection and deleted it: "wallpaper noise that trains the + * model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149). Carries the folded plan + * (above ## Log), because a nudge with no plan in it makes the model go read the file anyway. * ──────────────────────────────────────────────────────────────────────── */ -export const reminder = `\ +export function reminder(foldedPlan: string, planRel: string): string { + return `\ -Keep the plan file current as you work (edit it directly): +Your plan (${planRel}, above the fold; the log, learnings and appendix are in the file): + +${foldedPlan} + +Keep it current as you work, with your normal edit tool: - tick finished subtasks ([/] in progress), add discovered ones -- append ONE short line to ## Log (append, don't rewrite earlier lines) +- append ONE short line to ## Log, and a line to ## Learnings for a gotcha worth keeping - when the active goal's discriminator is satisfied, fill its evidence: list (each item = a durable - artifact + a verbatim quote you actually observed + a short read of it), then call CompleteGoal. Don't tick a goal [x] before CompleteGoal - accepts; the sign-off log line is the audit trail. -- if the file has grown long, prune finished goals (their evidence lives in git history and ## Log) + artifact + a verbatim quote you actually observed + a short read of it), then call CompleteGoal. + Don't tick a goal [x] before CompleteGoal accepts; the sign-off log line is the audit trail. +- if the working set has grown long, prune finished goals (their evidence lives in git history and + ## Log) and move settled detail down to ## Appendix, which is unlimited - otherwise keep working toward the active goal; don't stop to ask unless genuinely blocked `; +} + +/* ───────────────────────────────────────────────────────────────────────── + * 3b. resync — EXEC, one-shot at session start and after a compaction: the WHOLE file back, + * appendix included. Modelled on pi-goal-x's [POST-COMPACTION RESYNC] one-shot. This is the + * only place the below-the-fold sections are pushed; otherwise the agent reads them on demand. + * ──────────────────────────────────────────────────────────────────────── */ +export function resync(plan: string, planRel: string, why: string): string { + return `\ + +${why} This is the whole plan file (${planRel}), appendix included, so you don't re-litigate what +was already settled. Keep working the active goal; edit the file directly as you go. + +${plan} +`; +} /* ───────────────────────────────────────────────────────────────────────── * 4. completeGoal — SIGN-OFF, agent-side: the one blessed tool diff --git a/test/fold.test.ts b/test/fold.test.ts new file mode 100644 index 0000000..1579602 --- /dev/null +++ b/test/fold.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { foldPlan, openSubtasks } from "../src/index.js"; + +const plan = `# Plan + +## User voice + +- > "keep it under 50 lines" + +## Goals + +1. [/] goal: Implement the cache layer + - discriminator: hit-rate > 0.8 in load-test.log + - tasks: + 1. [x] wire client + 2. [/] eviction policy + 3. [ ] bench p95 +2. [ ] goal: Ship the docs + - tasks: + 1. [ ] write the readme + +## Log +- 2026-08-05 12:00 wired the client + +## Learnings +- the tokenizer pads left, which silently shifted every offset + +## Appendix (context, not approved) +${"filler line\n".repeat(200)}`; + +describe("foldPlan (the working set is what gets re-sent; below ## Log is durable memory)", () => { + it("keeps the title, user voice and goals", () => { + const folded = foldPlan(plan); + expect(folded).toContain("keep it under 50 lines"); + expect(folded).toContain("goal: Implement the cache layer"); + expect(folded).toContain("discriminator: hit-rate > 0.8"); + }); + + it("drops the log, the learnings and the unlimited appendix", () => { + const folded = foldPlan(plan); + expect(folded).not.toContain("wired the client"); + expect(folded).not.toContain("tokenizer pads left"); + expect(folded).not.toContain("filler line"); + expect(folded.length).toBeLessThan(plan.length / 4); + }); + + it("returns the whole plan when there is no ## Log yet (a fresh draft)", () => { + const draft = "# Plan\n\n## Goals\n\n1. [ ] goal: do the thing\n"; + expect(foldPlan(draft)).toBe(draft.trimEnd()); + }); +}); + +describe("openSubtasks (the widget shows the next action, so the plan IS the task list)", () => { + const active = plan.split("\n").findIndex((l) => l.includes("goal: Implement the cache layer")); + + it("lists the active goal's open and in-progress subtasks, stopping at the next goal", () => { + expect(openSubtasks(plan, active)).toEqual(["eviction policy", "bench p95"]); + }); + + it("does not leak subtasks from the goal below", () => { + expect(openSubtasks(plan, active)).not.toContain("write the readme"); + }); +});