@pify/plan-mode 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,9 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
24
24
  /plan # toggle plan mode
25
25
  /plan add oauth login # enter + start planning this
26
26
  /plan off # leave without approval
27
+ /plan list # saved plans in .pi/plans/ (v0.2)
28
+ /plan steps # progress through the approved plan (v0.3)
29
+ /plan export [file] # standalone HTML next to the plan (v0.3)
27
30
  pi --plan # start a session already in plan mode
28
31
  ```
29
32
 
@@ -36,6 +39,16 @@ pi remove npm:@narumitw/pi-plan-mode
36
39
  pify install plan-mode
37
40
  ```
38
41
 
42
+ ## After approval (v0.3)
43
+
44
+ An approved plan becomes a tracked step list rather than a document the agent re-reads each turn — which is how plans get quietly abandoned halfway. Steps are parsed from the markdown the agent already wrote (numbered list, or the bullets under a *Steps*-ish heading), so there is no second source of truth.
45
+
46
+ - `plan_step_done(index, evidence)` — the agent ticks off one step at a time, with evidence, and gets the next one back. Completing out of order is allowed but reported: the answer names the steps still open before it.
47
+ - The status badge follows execution — `📋 2/7 steps` — instead of disappearing at approval.
48
+ - `/plan steps` shows the list; progress survives `/reload` and branch switches with the rest of the plan state.
49
+
50
+ `/plan export` writes a self-contained HTML file next to the plan: no assets, no network, everything escaped before rendering — a plan containing HTML is shown as text, not executed.
51
+
39
52
  ## License
40
53
 
41
54
  MIT © [Pify maintainers](https://github.com/pifydev)
@@ -26,7 +26,20 @@ import type {
26
26
  import { Type } from "typebox";
27
27
 
28
28
  import { classifyToolCall } from "../src/policy.ts";
29
- import { createPlanFile } from "../src/plans.ts";
29
+ import { readFileSync, writeFileSync } from "node:fs";
30
+ import { basename, isAbsolute, join } from "node:path";
31
+
32
+ import { htmlPathFor, renderPlanHtml } from "../src/export.ts";
33
+ import { createPlanFile, listPlanFiles, plansDir } from "../src/plans.ts";
34
+ import {
35
+ completeStep,
36
+ formatSteps,
37
+ nextStep,
38
+ parseSteps,
39
+ progressLine,
40
+ skippedBefore,
41
+ type PlanStep,
42
+ } from "../src/steps.ts";
30
43
  import {
31
44
  ENTER_REMINDER,
32
45
  EXIT_REMINDER,
@@ -38,6 +51,16 @@ import { INITIAL_STATE, PLAN_THINKING, type PlanState } from "../src/types.ts";
38
51
 
39
52
  const REMINDER_TYPE = "plan-mode-reminder";
40
53
 
54
+ /** Read a file, or "" when it is missing/unreadable. */
55
+ function readFileSafe(file: string | null): string {
56
+ if (!file) return "";
57
+ try {
58
+ return readFileSync(file, "utf8");
59
+ } catch {
60
+ return "";
61
+ }
62
+ }
63
+
41
64
  type UiContext = ExtensionContext;
42
65
 
43
66
  export default function planMode(pi: ExtensionAPI) {
@@ -54,7 +77,14 @@ export default function planMode(pi: ExtensionAPI) {
54
77
 
55
78
  function updateBadge(ctx: UiContext): void {
56
79
  if (!ctx.hasUI) return;
57
- ctx.ui.setStatus("plan", state.active ? "📋 plan" : undefined);
80
+ if (state.active) {
81
+ ctx.ui.setStatus("plan", "📋 plan");
82
+ return;
83
+ }
84
+ // After approval the badge follows execution instead of disappearing —
85
+ // that is exactly when a plan gets quietly abandoned halfway.
86
+ const open = state.steps.filter((s) => !s.done).length;
87
+ ctx.ui.setStatus("plan", open > 0 ? `📋 ${progressLine(state.steps)}` : undefined);
58
88
  }
59
89
 
60
90
  function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
@@ -75,6 +105,7 @@ export default function planMode(pi: ExtensionAPI) {
75
105
  planFile: null,
76
106
  buildThinking,
77
107
  enteredAt: Date.now(),
108
+ steps: [],
78
109
  });
79
110
  try {
80
111
  // Planning earns deeper thought (bacnh85); restored on exit.
@@ -87,10 +118,15 @@ export default function planMode(pi: ExtensionAPI) {
87
118
  return true;
88
119
  }
89
120
 
90
- function leavePlanMode(ctx: UiContext): void {
121
+ /**
122
+ * Leave plan mode. `keepSteps` carries the approved plan's step list into
123
+ * execution — the tracker only exists after an approval, never after a
124
+ * discard.
125
+ */
126
+ function leavePlanMode(ctx: UiContext, keepSteps: PlanStep[] = []): void {
91
127
  if (!state.active) return;
92
128
  const restore = state.buildThinking;
93
- commit(ctx, { ...INITIAL_STATE });
129
+ commit(ctx, { ...INITIAL_STATE, steps: keepSteps });
94
130
  approvedTools.clear();
95
131
  if (restore) {
96
132
  try {
@@ -210,6 +246,42 @@ export default function planMode(pi: ExtensionAPI) {
210
246
  },
211
247
  });
212
248
 
249
+ pi.registerTool({
250
+ name: "plan_step_done",
251
+ label: "Plan step done",
252
+ description:
253
+ "Mark one step of the approved plan complete and get the next one. Call it as you finish each " +
254
+ "step, with evidence of what you verified — not at the end for all steps at once. Only available " +
255
+ "after a plan was approved.",
256
+ parameters: Type.Object({
257
+ index: Type.Number({ description: "1-based step number from the plan" }),
258
+ evidence: Type.String({ description: "What you verified for this step (command output, file state)" }),
259
+ }),
260
+ async execute(_id, params: { index: number; evidence: string }, _signal, _onUpdate, ctx) {
261
+ if (state.steps.length === 0) {
262
+ throw new Error("No approved plan is being tracked. plan_step_done only works after exit_plan_mode approval.");
263
+ }
264
+ if (!params.evidence.trim()) {
265
+ throw new Error("plan_step_done requires evidence: what you verified for this step.");
266
+ }
267
+ const result = completeStep(state.steps, params.index);
268
+ if (result.error) throw new Error(result.error);
269
+
270
+ const skipped = skippedBefore(state.steps, params.index);
271
+ commit(ctx as UiContext, { ...state, steps: result.steps });
272
+
273
+ const next = nextStep(result.steps);
274
+ const lines = [
275
+ `Step #${params.index} done (${progressLine(result.steps)}).`,
276
+ skipped.length > 0
277
+ ? `Still open before it: ${skipped.map((s) => `#${s.index}`).join(", ")} — go back unless they no longer apply.`
278
+ : "",
279
+ next ? `Next: #${next.index} ${next.text}` : "All steps complete. Report the result to the user.",
280
+ ].filter(Boolean);
281
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { steps: result.steps } };
282
+ },
283
+ });
284
+
213
285
  const APPROVE_HERE = "Approve — implement here";
214
286
  const APPROVE_FRESH = "Approve — implement in a fresh session";
215
287
  const REVISE = "Revise the plan";
@@ -309,7 +381,13 @@ export default function planMode(pi: ExtensionAPI) {
309
381
  }
310
382
 
311
383
  const planFile = state.planFile;
312
- leavePlanMode(uiCtx);
384
+ // v0.3: the approved plan becomes a tracked step list, so execution has
385
+ // a cursor instead of the agent re-reading the markdown each turn.
386
+ const steps = planFile ? parseSteps(readFileSafe(planFile)) : [];
387
+ leavePlanMode(uiCtx, steps);
388
+ if (steps.length > 0) {
389
+ notify(uiCtx, `Tracking ${steps.length} steps — /plan steps to view.`, "info");
390
+ }
313
391
 
314
392
  if (decision === APPROVE_FRESH) {
315
393
  try {
@@ -347,9 +425,59 @@ export default function planMode(pi: ExtensionAPI) {
347
425
  // ── Command & shortcut ───────────────────────────────────────────────
348
426
 
349
427
  pi.registerCommand("plan", {
350
- description: "Toggle read-only plan mode: /plan [off | <first planning prompt>]",
428
+ description: "Plan mode: /plan [off | list | steps | export [file] | <first planning prompt>]",
351
429
  handler: async (args, ctx) => {
352
430
  const text = (args ?? "").trim();
431
+ if (text.toLowerCase() === "steps") {
432
+ notify(
433
+ ctx,
434
+ state.steps.length === 0
435
+ ? "No plan is being tracked. Steps appear after a plan is approved with exit_plan_mode."
436
+ : formatSteps(state.steps),
437
+ "info",
438
+ );
439
+ return;
440
+ }
441
+ if (text.toLowerCase() === "export" || text.toLowerCase().startsWith("export ")) {
442
+ const target = text.slice("export".length).trim() || state.planFile;
443
+ if (!target) {
444
+ notify(ctx, "Nothing to export — no current plan. Usage: /plan export [file.md]", "warning");
445
+ return;
446
+ }
447
+ const file = isAbsolute(target) ? target : join(plansDir(ctx.cwd), target);
448
+ const markdown = readFileSafe(file);
449
+ if (!markdown) {
450
+ notify(ctx, `Could not read ${file}.`, "error");
451
+ return;
452
+ }
453
+ const out = htmlPathFor(file);
454
+ try {
455
+ writeFileSync(
456
+ out,
457
+ renderPlanHtml(markdown, {
458
+ title: basename(file, ".md").replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/-/g, " "),
459
+ generatedAt: new Date().toISOString().replace("T", " ").slice(0, 16),
460
+ sourceFile: basename(file),
461
+ progress: state.steps.length > 0 ? progressLine(state.steps) : undefined,
462
+ }),
463
+ );
464
+ notify(ctx, `Exported ${out}`, "info");
465
+ } catch (err) {
466
+ notify(ctx, `Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
467
+ }
468
+ return;
469
+ }
470
+ if (text.toLowerCase() === "list") {
471
+ const plans = listPlanFiles(ctx.cwd);
472
+ notify(
473
+ ctx,
474
+ plans.length > 0
475
+ ? `${plans.map((p) => `${p.file} (${p.size} chars)`).join("\n")}\nLocation: .pi/plans/`
476
+ : "No saved plans yet (.pi/plans/ is empty).",
477
+ "info",
478
+ );
479
+ return;
480
+ }
353
481
  if (text.toLowerCase() === "off") {
354
482
  if (!state.active) {
355
483
  notify(ctx, "Plan mode is not active.", "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/plan-mode",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Read-only planning mode for pi with an explicit approve-then-execute gate: enforced tool policy, plan files, approach options, fresh-session handoff",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,8 +31,12 @@
31
31
  "LICENSE"
32
32
  ],
33
33
  "pi": {
34
- "extensions": ["./extensions/plan-mode.ts"],
35
- "skills": ["./skills"]
34
+ "extensions": [
35
+ "./extensions/plan-mode.ts"
36
+ ],
37
+ "skills": [
38
+ "./skills"
39
+ ]
36
40
  },
37
41
  "scripts": {
38
42
  "typecheck": "tsc --noEmit",
@@ -44,8 +48,12 @@
44
48
  "typebox": "*"
45
49
  },
46
50
  "peerDependenciesMeta": {
47
- "@earendil-works/pi-coding-agent": { "optional": true },
48
- "typebox": { "optional": true }
51
+ "@earendil-works/pi-coding-agent": {
52
+ "optional": true
53
+ },
54
+ "typebox": {
55
+ "optional": true
56
+ }
49
57
  },
50
58
  "devDependencies": {
51
59
  "@earendil-works/pi-coding-agent": "^0.84.4",
package/src/export.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Standalone HTML export of a plan (v0.3, narumiruna's plan-export). Plans
3
+ * get shared with people who do not have the repository — a self-contained
4
+ * file with no assets and no network is what actually travels.
5
+ *
6
+ * The renderer is a small markdown subset on purpose: plans are headings,
7
+ * lists, code, and emphasis. Everything is escaped first, so a plan
8
+ * containing HTML (or a prompt-injection attempt aimed at the reader) is
9
+ * shown as text rather than executed.
10
+ */
11
+
12
+ export function escapeHtml(text: string): string {
13
+ return text
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#39;");
19
+ }
20
+
21
+ /** Inline spans, applied to already-escaped text. */
22
+ function renderInline(escaped: string): string {
23
+ return escaped
24
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
25
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
26
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
27
+ }
28
+
29
+ const STYLE = [
30
+ ":root{color-scheme:light dark}",
31
+ "body{max-width:46rem;margin:2.5rem auto;padding:0 1.25rem;",
32
+ "font:16px/1.65 ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif}",
33
+ "h1,h2,h3,h4{line-height:1.25;margin:1.8em 0 .6em}",
34
+ "h1{font-size:1.7rem}h2{font-size:1.3rem}h3{font-size:1.1rem}",
35
+ "code{background:rgba(127,127,127,.16);padding:.12em .35em;border-radius:4px;font-size:.9em}",
36
+ "pre{background:rgba(127,127,127,.12);padding:.9rem 1rem;border-radius:8px;overflow:auto}",
37
+ "pre code{background:none;padding:0}",
38
+ "li{margin:.3em 0}",
39
+ "footer{margin-top:3rem;font-size:.85rem;opacity:.65;border-top:1px solid rgba(127,127,127,.3);padding-top:.8rem}",
40
+ ".done{opacity:.55;text-decoration:line-through}",
41
+ ].join("");
42
+
43
+ interface ListState {
44
+ open: "ul" | "ol" | null;
45
+ }
46
+
47
+ function closeList(state: ListState, out: string[]): void {
48
+ if (state.open) {
49
+ out.push(`</${state.open}>`);
50
+ state.open = null;
51
+ }
52
+ }
53
+
54
+ function openList(state: ListState, kind: "ul" | "ol", out: string[]): void {
55
+ if (state.open !== kind) {
56
+ closeList(state, out);
57
+ out.push(`<${kind}>`);
58
+ state.open = kind;
59
+ }
60
+ }
61
+
62
+ /** Render the markdown subset plans are written in. */
63
+ export function renderMarkdown(markdown: string): string {
64
+ const out: string[] = [];
65
+ const state: ListState = { open: null };
66
+ let inCode = false;
67
+
68
+ for (const rawLine of (markdown ?? "").split("\n")) {
69
+ const line = rawLine.replace(/\r$/, "");
70
+
71
+ if (/^\s*```/.test(line)) {
72
+ closeList(state, out);
73
+ out.push(inCode ? "</code></pre>" : "<pre><code>");
74
+ inCode = !inCode;
75
+ continue;
76
+ }
77
+ if (inCode) {
78
+ out.push(escapeHtml(line));
79
+ continue;
80
+ }
81
+
82
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
83
+ if (heading) {
84
+ closeList(state, out);
85
+ const level = Math.min(heading[1]!.length, 6);
86
+ out.push(`<h${level}>${renderInline(escapeHtml(heading[2]!))}</h${level}>`);
87
+ continue;
88
+ }
89
+
90
+ const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
91
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
92
+ if (ordered || bullet) {
93
+ openList(state, ordered ? "ol" : "ul", out);
94
+ const body = (ordered ?? bullet)![1]!;
95
+ const checked = /^\[[xX]\]\s*/.test(body);
96
+ const text = renderInline(escapeHtml(body.replace(/^\[[ xX]\]\s*/, "")));
97
+ out.push(checked ? `<li class="done">${text}</li>` : `<li>${text}</li>`);
98
+ continue;
99
+ }
100
+
101
+ if (!line.trim()) {
102
+ closeList(state, out);
103
+ continue;
104
+ }
105
+ closeList(state, out);
106
+ out.push(`<p>${renderInline(escapeHtml(line))}</p>`);
107
+ }
108
+
109
+ if (inCode) out.push("</code></pre>");
110
+ closeList(state, out);
111
+ return out.join("\n");
112
+ }
113
+
114
+ export interface ExportOptions {
115
+ title: string;
116
+ /** Rendered into the footer; passed in so the module stays pure. */
117
+ generatedAt: string;
118
+ sourceFile?: string;
119
+ progress?: string;
120
+ }
121
+
122
+ export function renderPlanHtml(markdown: string, options: ExportOptions): string {
123
+ const title = escapeHtml(options.title);
124
+ const footer = [
125
+ options.sourceFile ? escapeHtml(options.sourceFile) : "",
126
+ options.progress ? escapeHtml(options.progress) : "",
127
+ `exported ${escapeHtml(options.generatedAt)} by @pify/plan-mode`,
128
+ ]
129
+ .filter(Boolean)
130
+ .join(" · ");
131
+
132
+ return [
133
+ "<!doctype html>",
134
+ '<html lang="en">',
135
+ "<head>",
136
+ '<meta charset="utf-8">',
137
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
138
+ `<title>${title}</title>`,
139
+ `<style>${STYLE}</style>`,
140
+ "</head>",
141
+ "<body>",
142
+ renderMarkdown(markdown),
143
+ `<footer>${footer}</footer>`,
144
+ "</body>",
145
+ "</html>",
146
+ "",
147
+ ].join("\n");
148
+ }
149
+
150
+ /** Sibling .html path for a plan file. */
151
+ export function htmlPathFor(planFile: string): string {
152
+ return planFile.replace(/\.md$/i, "") + ".html";
153
+ }
package/src/plans.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, existsSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  /** Plan files live in .pi/plans/, reviewable and committable. */
@@ -25,6 +25,22 @@ export function localDateStr(d: Date = new Date()): string {
25
25
  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
26
26
  }
27
27
 
28
+ /** List saved plan files, newest first (v0.2: saved-plan library, minimal form). */
29
+ export function listPlanFiles(cwd: string): Array<{ file: string; size: number }> {
30
+ const dir = plansDir(cwd);
31
+ try {
32
+ return readdirSync(dir)
33
+ .filter((f) => f.endsWith(".md"))
34
+ .map((f) => {
35
+ const full = join(dir, f);
36
+ return { file: f, size: statSync(full).size };
37
+ })
38
+ .sort((a, b) => b.file.localeCompare(a.file));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
28
44
  /** Create the plan file, uniquified when the slug collides on the same day. */
29
45
  export function createPlanFile(cwd: string, title: string, content: string): string {
30
46
  const dir = plansDir(cwd);
package/src/state.ts CHANGED
@@ -1,7 +1,19 @@
1
1
  import { INITIAL_STATE, isRecord, type BranchEntryLike, type PlanState } from "./types.ts";
2
+ import type { PlanStep } from "./steps.ts";
2
3
 
3
4
  export const PLAN_STATE = "plan-mode-state";
4
5
 
6
+ /** Steps come back from an untrusted snapshot; drop anything malformed. */
7
+ function sanitizeSteps(raw: unknown): PlanStep[] {
8
+ if (!Array.isArray(raw)) return [];
9
+ const steps: PlanStep[] = [];
10
+ for (const item of raw) {
11
+ if (!isRecord(item) || typeof item.text !== "string" || typeof item.index !== "number") continue;
12
+ steps.push({ index: item.index, text: item.text, done: item.done === true });
13
+ }
14
+ return steps;
15
+ }
16
+
5
17
  /** Snapshot-based replay: the last plan-mode-state entry on the branch wins. */
6
18
  export function replayBranch(entries: BranchEntryLike[]): PlanState {
7
19
  let state: PlanState = INITIAL_STATE;
@@ -14,6 +26,7 @@ export function replayBranch(entries: BranchEntryLike[]): PlanState {
14
26
  planFile: typeof data.planFile === "string" ? data.planFile : null,
15
27
  buildThinking: typeof data.buildThinking === "string" ? data.buildThinking : null,
16
28
  enteredAt: typeof data.enteredAt === "number" ? data.enteredAt : null,
29
+ steps: sanitizeSteps(data.steps),
17
30
  };
18
31
  }
19
32
  return state;
package/src/steps.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Step tracking for an approved plan (v0.3, janvitos' plan-execution). A plan
3
+ * is approved as a whole and then executed as a list — without a tracker the
4
+ * agent re-reads the markdown every turn and quietly skips steps.
5
+ *
6
+ * Steps are parsed out of the plan file the agent already wrote, so there is
7
+ * no second source of truth: the markdown stays the plan, this is a cursor
8
+ * over it.
9
+ */
10
+
11
+ export interface PlanStep {
12
+ /** 1-based position in the plan. */
13
+ index: number;
14
+ text: string;
15
+ done: boolean;
16
+ }
17
+
18
+ const MAX_STEPS = 40;
19
+ const MAX_STEP_CHARS = 200;
20
+
21
+ /** Headings that introduce the step list; anything else is prose. */
22
+ const STEP_HEADING = /^#{1,6}\s*(implementation\s+)?(steps|plan|tasks|todo|work)\b/i;
23
+ const NON_STEP_HEADING = /^#{1,6}\s*(risk|verification|testing|open question|context|goal|background|note)/i;
24
+
25
+ function cleanStep(raw: string): string {
26
+ return raw
27
+ .replace(/^\s*(?:\d+[.)]|[-*+]|\[[ xX]\])\s*/, "")
28
+ .replace(/^\[[ xX]\]\s*/, "")
29
+ .replace(/\s+/g, " ")
30
+ .trim()
31
+ .slice(0, MAX_STEP_CHARS);
32
+ }
33
+
34
+ /**
35
+ * Extract the ordered steps from a plan's markdown. Numbered lists win: a
36
+ * plan that numbers its steps means those and only those. Otherwise the
37
+ * bullets under a steps-ish heading are used, which is how most plans that
38
+ * are not numbered are written.
39
+ */
40
+ export function parseSteps(markdown: string): PlanStep[] {
41
+ const lines = (markdown ?? "").split("\n");
42
+
43
+ const numbered: string[] = [];
44
+ const underHeading: string[] = [];
45
+ let inStepSection = false;
46
+
47
+ for (const line of lines) {
48
+ if (/^#{1,6}\s/.test(line)) {
49
+ inStepSection = STEP_HEADING.test(line) && !NON_STEP_HEADING.test(line);
50
+ continue;
51
+ }
52
+ if (/^\s*\d+[.)]\s+\S/.test(line)) {
53
+ // Only top-level numbers: an indented "1." is a detail of a step.
54
+ if (!/^\s{2,}/.test(line)) numbered.push(cleanStep(line));
55
+ continue;
56
+ }
57
+ if (inStepSection && /^\s*[-*+]\s+\S/.test(line) && !/^\s{2,}/.test(line)) {
58
+ underHeading.push(cleanStep(line));
59
+ }
60
+ }
61
+
62
+ const chosen = (numbered.length > 0 ? numbered : underHeading).filter(Boolean).slice(0, MAX_STEPS);
63
+ return chosen.map((text, i) => ({ index: i + 1, text, done: false }));
64
+ }
65
+
66
+ /** Merge parsed steps with what was already completed, matching on text. */
67
+ export function mergeProgress(steps: PlanStep[], previous: PlanStep[]): PlanStep[] {
68
+ const doneText = new Set(previous.filter((s) => s.done).map((s) => s.text));
69
+ return steps.map((step) => ({ ...step, done: doneText.has(step.text) }));
70
+ }
71
+
72
+ export function nextStep(steps: PlanStep[]): PlanStep | null {
73
+ return steps.find((step) => !step.done) ?? null;
74
+ }
75
+
76
+ export interface CompleteResult {
77
+ steps: PlanStep[];
78
+ step: PlanStep | null;
79
+ error: string | null;
80
+ }
81
+
82
+ /**
83
+ * Mark a step done. Out-of-order completion is allowed but not silent: the
84
+ * caller reports which steps were skipped, since skipping is usually a
85
+ * mistake and occasionally the point.
86
+ */
87
+ export function completeStep(steps: PlanStep[], index: number): CompleteResult {
88
+ const target = steps.find((step) => step.index === index);
89
+ if (!target) return { steps, step: null, error: `No step #${index} in the plan (it has ${steps.length}).` };
90
+ if (target.done) return { steps, step: target, error: `Step #${index} is already done.` };
91
+ return {
92
+ steps: steps.map((step) => (step.index === index ? { ...step, done: true } : step)),
93
+ step: target,
94
+ error: null,
95
+ };
96
+ }
97
+
98
+ export function skippedBefore(steps: PlanStep[], index: number): PlanStep[] {
99
+ return steps.filter((step) => step.index < index && !step.done);
100
+ }
101
+
102
+ export function progressLine(steps: PlanStep[]): string {
103
+ const done = steps.filter((s) => s.done).length;
104
+ return `${done}/${steps.length} steps`;
105
+ }
106
+
107
+ const MAX_WIDGET_STEPS = 8;
108
+
109
+ /** Plain-text step list for a notify or a widget. */
110
+ export function formatSteps(steps: PlanStep[], limit = MAX_WIDGET_STEPS): string {
111
+ if (steps.length === 0) return "No steps parsed from the plan.";
112
+ const current = nextStep(steps);
113
+ const start = current ? Math.max(0, Math.min(steps.length - limit, current.index - 1 - 2)) : 0;
114
+ const window = steps.slice(start, start + limit);
115
+ const lines = window.map((step) => {
116
+ const mark = step.done ? "✔" : step === current ? "▸" : "◻";
117
+ return `${mark} ${step.index}. ${step.text.length > 70 ? `${step.text.slice(0, 69)}…` : step.text}`;
118
+ });
119
+ if (start > 0) lines.unshift(`… +${start} above`);
120
+ const after = steps.length - start - window.length;
121
+ if (after > 0) lines.push(`… +${after} more`);
122
+ return [progressLine(steps), ...lines].join("\n");
123
+ }
package/src/types.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  * No imports from pi packages: src/ typechecks and runs standalone.
4
4
  */
5
5
 
6
+ import type { PlanStep } from "./steps.ts";
7
+
6
8
  export interface PlanState {
7
9
  active: boolean;
8
10
  /** Absolute path of the current plan file; edit/write to it is allowed. */
@@ -10,12 +12,15 @@ export interface PlanState {
10
12
  /** Thinking level to restore when leaving plan mode. */
11
13
  buildThinking: string | null;
12
14
  enteredAt: number | null;
15
+ /** Steps of the approved plan, tracked through execution (v0.3). */
16
+ steps: PlanStep[];
13
17
  }
14
18
 
15
19
  export const INITIAL_STATE: PlanState = {
16
20
  active: false,
17
21
  planFile: null,
18
22
  buildThinking: null,
23
+ steps: [],
19
24
  enteredAt: null,
20
25
  };
21
26