diff --git a/src/index.ts b/src/index.ts index 56a72bd..ff8d1a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import { Type } from "@sinclair/typebox"; -import { counts, findGoal, type Goal, type PlanDoc, parse, recordSignOff, type SignOff } from "./plan-file.js"; +import { counts, findGoal, type Goal, type PlanDoc, parse, pruneCompleted, recordSignOff, type SignOff } from "./plan-file.js"; import { completeGoalDescription, completeGoalParamDescription, @@ -124,12 +124,15 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } const c = counts(doc); ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${c.done}/${doc.goals.length} goals`)); - ctx.ui.setWidget(WIDGET_KEY, [...goalWidgetLines(doc), ctx.ui.theme.fg("muted", PLAN_REL)]); + ctx.ui.setWidget(WIDGET_KEY, goalWidgetLines(doc, ctx)); } - function goalWidgetLines(doc: PlanDoc): string[] { + function goalWidgetLines(doc: PlanDoc, ctx: ExtensionContext): string[] { const mark: Record = { done: "✔", active: "▸", open: "◻", cancelled: "✗" }; - const lines = [`Goals: ${doc.title || "(untitled)"}`]; + // Header doubles as the file path (clickable) so we don't spend a second line on a "Goals:" label + // plus a footer path -- one line carries both. Title trails the path when set. + const header = ctx.ui.theme.fg("muted", doc.title ? `${PLAN_REL}: ${doc.title}` : PLAN_REL); + const lines = [header]; for (const g of doc.goals) { // Show every goal with its status glyph (✔ done, ▸ active, ◻ open, ✗ cancelled) so finished // goals read as checked off rather than vanishing. Plans are small, so this stays readable. @@ -143,7 +146,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // --- plan mode: setup ------------------------------------------------------------------------- pi.registerCommand("goals", { - description: "Plan mode: set up goals (with evidence) in goals.md, then work them. /goals ", + description: "Plan mode: set up goals (with evidence) in goals.md, then work them. /goals | /goals clear", handler: async (args, ctx) => { savedCmdCtx = ctx; // ctx here is an ExtensionCommandContext (has newSession); keep it for later const arg = args.trim(); @@ -182,8 +185,18 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { return; } if (ctx.hasUI) { - const ok = await ctx.ui.select(`Clear ${PLAN_REL}?`, ["Cancel", "Clear goals.md"]); - if (ok !== "Clear goals.md") return; + const choice = await ctx.ui.select(`Clear ${PLAN_REL}?`, [ + "Cancel", + "Prune completed goals (keep active/open + log)", + "Clear everything", + ]); + if (!choice || choice.startsWith("Cancel")) return; + if (choice.startsWith("Prune")) { + writePlan(ctx, pruneCompleted(readPlan(ctx))); + updateWidget(ctx); + ctx.ui.notify(`Pruned completed goals from ${PLAN_REL}.`, "info"); + return; + } } writePlan(ctx, ""); state = { ...state, isPlanMode: false, objective: null }; diff --git a/src/plan-file.ts b/src/plan-file.ts index 173a05a..4a14466 100644 --- a/src/plan-file.ts +++ b/src/plan-file.ts @@ -228,6 +228,43 @@ export function setGoalStatus(text: string, subject: string, status: GoalStatus) throw new Error(`Goal "${subject}" not found`); } +/** + * Drop done+cancelled goal blocks (their header line through the last of their subtask/section lines) + * from goals.md, keeping the title, context, active/open goals, and the ## Log. Pure. Lets the human + * prune old finished goals so the widget/file stay short across sessions, without losing the log trail. + */ +export function pruneCompleted(text: string): string { + const lines = text.split("\n"); + const out: string[] = []; + let inGoals = false; + let dropping = false; + for (const line of lines) { + if (GOALS_HEADER.test(line)) { + inGoals = true; + dropping = false; + out.push(line); + continue; + } + if (ANY_HEADER.test(line)) { + // Any other header (## Log, "# Future work") ends the goals section. + inGoals = false; + dropping = false; + out.push(line); + continue; + } + if (inGoals) { + const m = GOAL_ITEM.exec(line); + if (m) { + const status = CHAR_TO_STATUS[m[1].toLowerCase()] ?? "open"; + dropping = status === "done" || status === "cancelled"; + } + if (dropping) continue; // skip the goal line and everything under it until the next goal/header + } + out.push(line); + } + return out.join("\n"); +} + /** * The outcome of a sign-off attempt, decided by CompleteGoal (which runs verify + the judge). Kept * separate from the I/O so the record logic below is pure and testable. diff --git a/test/plan-file.test.ts b/test/plan-file.test.ts index 01e62d4..d24a95b 100644 --- a/test/plan-file.test.ts +++ b/test/plan-file.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { appendLog, counts, findGoal, parse, recordSignOff, setGoalStatus } from "../src/plan-file.js"; +import { appendLog, counts, findGoal, parse, pruneCompleted, recordSignOff, setGoalStatus } from "../src/plan-file.js"; const SAMPLE = `# papers audit @@ -169,3 +169,40 @@ describe("recordSignOff (CompleteGoal's pure record logic)", () => { expect(r.content).toBe(SAMPLE); }); }); + +describe("pruneCompleted (drop finished goals, keep the rest)", () => { + // SAMPLE has one active + one open goal; add a done and a cancelled one to prune. + const WITH_FINISHED = `# papers audit + +Context line kept. + +## Goals + +1. [x] goal: Old finished thing + - discriminator: shipped + - tasks: + 1. [x] did it + - evidence: + - > done.log +2. [/] goal: Implement cache layer + - discriminator: hit-rate > 0.8 +3. [-] goal: Abandoned idea + - discriminator: n/a +4. [ ] goal: Document the API + - discriminator: docstrings + +## Log +- 2026-06-15 14:02 note +`; + + it("removes done and cancelled goals, keeps active/open + title + log", () => { + const out = parse(pruneCompleted(WITH_FINISHED)); + expect(out.goals.map((g) => g.subject)).toEqual(["Implement cache layer", "Document the API"]); + expect(out.title).toBe("papers audit"); + expect(out.log.at(-1)).toBe("- 2026-06-15 14:02 note"); + }); + + it("is a no-op when nothing is finished", () => { + expect(pruneCompleted(SAMPLE)).toBe(SAMPLE); + }); +});