@pify/plan-mode 0.2.0 → 0.4.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,10 @@ 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
+ - **Reopen a saved plan** (v0.4): `/plan open <file>` takes a filename, a stem, or any distinctive fragment (`/plan open oauth`), hands the plan text back to the agent as a hidden message, and restarts step tracking from what the file says **now** — the file is the source of truth, not the step list it produced last time.
29
+ /plan steps # progress through the approved plan (v0.3)
30
+ /plan export [file] # standalone HTML next to the plan (v0.3)
27
31
  pi --plan # start a session already in plan mode
28
32
  ```
29
33
 
@@ -36,6 +40,16 @@ pi remove npm:@narumitw/pi-plan-mode
36
40
  pify install plan-mode
37
41
  ```
38
42
 
43
+ ## After approval (v0.3)
44
+
45
+ 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.
46
+
47
+ - `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.
48
+ - The status badge follows execution — `📋 2/7 steps` — instead of disappearing at approval.
49
+ - `/plan steps` shows the list; progress survives `/reload` and branch switches with the rest of the plan state.
50
+
51
+ `/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.
52
+
39
53
  ## License
40
54
 
41
55
  MIT © [Pify maintainers](https://github.com/pifydev)
@@ -26,11 +26,25 @@ import type {
26
26
  import { Type } from "typebox";
27
27
 
28
28
  import { classifyToolCall } from "../src/policy.ts";
29
- import { createPlanFile, listPlanFiles } 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, resolvePlanFile } 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,
33
46
  buildHandoffMessage,
47
+ buildReopenMessage,
34
48
  buildImplementHereMessage,
35
49
  } from "../src/prompts.ts";
36
50
  import { PLAN_STATE, replayBranch } from "../src/state.ts";
@@ -38,6 +52,16 @@ import { INITIAL_STATE, PLAN_THINKING, type PlanState } from "../src/types.ts";
38
52
 
39
53
  const REMINDER_TYPE = "plan-mode-reminder";
40
54
 
55
+ /** Read a file, or "" when it is missing/unreadable. */
56
+ function readFileSafe(file: string | null): string {
57
+ if (!file) return "";
58
+ try {
59
+ return readFileSync(file, "utf8");
60
+ } catch {
61
+ return "";
62
+ }
63
+ }
64
+
41
65
  type UiContext = ExtensionContext;
42
66
 
43
67
  export default function planMode(pi: ExtensionAPI) {
@@ -54,7 +78,14 @@ export default function planMode(pi: ExtensionAPI) {
54
78
 
55
79
  function updateBadge(ctx: UiContext): void {
56
80
  if (!ctx.hasUI) return;
57
- ctx.ui.setStatus("plan", state.active ? "📋 plan" : undefined);
81
+ if (state.active) {
82
+ ctx.ui.setStatus("plan", "📋 plan");
83
+ return;
84
+ }
85
+ // After approval the badge follows execution instead of disappearing —
86
+ // that is exactly when a plan gets quietly abandoned halfway.
87
+ const open = state.steps.filter((s) => !s.done).length;
88
+ ctx.ui.setStatus("plan", open > 0 ? `📋 ${progressLine(state.steps)}` : undefined);
58
89
  }
59
90
 
60
91
  function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
@@ -75,6 +106,7 @@ export default function planMode(pi: ExtensionAPI) {
75
106
  planFile: null,
76
107
  buildThinking,
77
108
  enteredAt: Date.now(),
109
+ steps: [],
78
110
  });
79
111
  try {
80
112
  // Planning earns deeper thought (bacnh85); restored on exit.
@@ -87,10 +119,15 @@ export default function planMode(pi: ExtensionAPI) {
87
119
  return true;
88
120
  }
89
121
 
90
- function leavePlanMode(ctx: UiContext): void {
122
+ /**
123
+ * Leave plan mode. `keepSteps` carries the approved plan's step list into
124
+ * execution — the tracker only exists after an approval, never after a
125
+ * discard.
126
+ */
127
+ function leavePlanMode(ctx: UiContext, keepSteps: PlanStep[] = []): void {
91
128
  if (!state.active) return;
92
129
  const restore = state.buildThinking;
93
- commit(ctx, { ...INITIAL_STATE });
130
+ commit(ctx, { ...INITIAL_STATE, steps: keepSteps });
94
131
  approvedTools.clear();
95
132
  if (restore) {
96
133
  try {
@@ -210,6 +247,42 @@ export default function planMode(pi: ExtensionAPI) {
210
247
  },
211
248
  });
212
249
 
250
+ pi.registerTool({
251
+ name: "plan_step_done",
252
+ label: "Plan step done",
253
+ description:
254
+ "Mark one step of the approved plan complete and get the next one. Call it as you finish each " +
255
+ "step, with evidence of what you verified — not at the end for all steps at once. Only available " +
256
+ "after a plan was approved.",
257
+ parameters: Type.Object({
258
+ index: Type.Number({ description: "1-based step number from the plan" }),
259
+ evidence: Type.String({ description: "What you verified for this step (command output, file state)" }),
260
+ }),
261
+ async execute(_id, params: { index: number; evidence: string }, _signal, _onUpdate, ctx) {
262
+ if (state.steps.length === 0) {
263
+ throw new Error("No approved plan is being tracked. plan_step_done only works after exit_plan_mode approval.");
264
+ }
265
+ if (!params.evidence.trim()) {
266
+ throw new Error("plan_step_done requires evidence: what you verified for this step.");
267
+ }
268
+ const result = completeStep(state.steps, params.index);
269
+ if (result.error) throw new Error(result.error);
270
+
271
+ const skipped = skippedBefore(state.steps, params.index);
272
+ commit(ctx as UiContext, { ...state, steps: result.steps });
273
+
274
+ const next = nextStep(result.steps);
275
+ const lines = [
276
+ `Step #${params.index} done (${progressLine(result.steps)}).`,
277
+ skipped.length > 0
278
+ ? `Still open before it: ${skipped.map((s) => `#${s.index}`).join(", ")} — go back unless they no longer apply.`
279
+ : "",
280
+ next ? `Next: #${next.index} ${next.text}` : "All steps complete. Report the result to the user.",
281
+ ].filter(Boolean);
282
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { steps: result.steps } };
283
+ },
284
+ });
285
+
213
286
  const APPROVE_HERE = "Approve — implement here";
214
287
  const APPROVE_FRESH = "Approve — implement in a fresh session";
215
288
  const REVISE = "Revise the plan";
@@ -309,7 +382,13 @@ export default function planMode(pi: ExtensionAPI) {
309
382
  }
310
383
 
311
384
  const planFile = state.planFile;
312
- leavePlanMode(uiCtx);
385
+ // v0.3: the approved plan becomes a tracked step list, so execution has
386
+ // a cursor instead of the agent re-reading the markdown each turn.
387
+ const steps = planFile ? parseSteps(readFileSafe(planFile)) : [];
388
+ leavePlanMode(uiCtx, steps);
389
+ if (steps.length > 0) {
390
+ notify(uiCtx, `Tracking ${steps.length} steps — /plan steps to view.`, "info");
391
+ }
313
392
 
314
393
  if (decision === APPROVE_FRESH) {
315
394
  try {
@@ -347,9 +426,48 @@ export default function planMode(pi: ExtensionAPI) {
347
426
  // ── Command & shortcut ───────────────────────────────────────────────
348
427
 
349
428
  pi.registerCommand("plan", {
350
- description: "Toggle read-only plan mode: /plan [off | list | <first planning prompt>]",
429
+ description: "Plan mode: /plan [off | list | open <file> | steps | export [file] | <first planning prompt>]",
351
430
  handler: async (args, ctx) => {
352
431
  const text = (args ?? "").trim();
432
+ if (text.toLowerCase() === "steps") {
433
+ notify(
434
+ ctx,
435
+ state.steps.length === 0
436
+ ? "No plan is being tracked. Steps appear after a plan is approved with exit_plan_mode."
437
+ : formatSteps(state.steps),
438
+ "info",
439
+ );
440
+ return;
441
+ }
442
+ if (text.toLowerCase() === "export" || text.toLowerCase().startsWith("export ")) {
443
+ const target = text.slice("export".length).trim() || state.planFile;
444
+ if (!target) {
445
+ notify(ctx, "Nothing to export — no current plan. Usage: /plan export [file.md]", "warning");
446
+ return;
447
+ }
448
+ const file = isAbsolute(target) ? target : join(plansDir(ctx.cwd), target);
449
+ const markdown = readFileSafe(file);
450
+ if (!markdown) {
451
+ notify(ctx, `Could not read ${file}.`, "error");
452
+ return;
453
+ }
454
+ const out = htmlPathFor(file);
455
+ try {
456
+ writeFileSync(
457
+ out,
458
+ renderPlanHtml(markdown, {
459
+ title: basename(file, ".md").replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/-/g, " "),
460
+ generatedAt: new Date().toISOString().replace("T", " ").slice(0, 16),
461
+ sourceFile: basename(file),
462
+ progress: state.steps.length > 0 ? progressLine(state.steps) : undefined,
463
+ }),
464
+ );
465
+ notify(ctx, `Exported ${out}`, "info");
466
+ } catch (err) {
467
+ notify(ctx, `Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
468
+ }
469
+ return;
470
+ }
353
471
  if (text.toLowerCase() === "list") {
354
472
  const plans = listPlanFiles(ctx.cwd);
355
473
  notify(
@@ -361,6 +479,37 @@ export default function planMode(pi: ExtensionAPI) {
361
479
  );
362
480
  return;
363
481
  }
482
+ // v0.4: reopen a saved plan — the other half of the plan library. The
483
+ // file is the source of truth, so tracking restarts from what it says
484
+ // now, not from the step list it produced when it was written.
485
+ if (text.toLowerCase().startsWith("open")) {
486
+ const wanted = text.slice("open".length).trim();
487
+ if (!wanted) {
488
+ notify(ctx, "Usage: /plan open <file> (see /plan list)", "warning");
489
+ return;
490
+ }
491
+ const file = resolvePlanFile(ctx.cwd, wanted);
492
+ if (!file) {
493
+ notify(ctx, `No saved plan matches "${wanted}". /plan list shows what is there.`, "warning");
494
+ return;
495
+ }
496
+ const markdown = readFileSafe(file);
497
+ if (!markdown) {
498
+ notify(ctx, `Could not read ${file}.`, "error");
499
+ return;
500
+ }
501
+ const steps = parseSteps(markdown);
502
+ commit(ctx, { ...state, planFile: file, steps });
503
+ sendReminder(buildReopenMessage(file, markdown));
504
+ notify(
505
+ ctx,
506
+ steps.length > 0
507
+ ? `Reopened ${basename(file)} — tracking ${steps.length} steps. /plan steps to view.`
508
+ : `Reopened ${basename(file)}. No checklist steps were found in it.`,
509
+ "info",
510
+ );
511
+ return;
512
+ }
364
513
  if (text.toLowerCase() === "off") {
365
514
  if (!state.active) {
366
515
  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.2.0",
3
+ "version": "0.4.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",
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,5 +1,5 @@
1
1
  import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { isAbsolute, join } from "node:path";
3
3
 
4
4
  /** Plan files live in .pi/plans/, reviewable and committable. */
5
5
  export function plansDir(cwd: string): string {
@@ -41,6 +41,27 @@ export function listPlanFiles(cwd: string): Array<{ file: string; size: number }
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * Find a saved plan by whatever the user typed: a path, a filename, or any
46
+ * distinctive part of one ("oauth" for 2026-09-06-add-oauth-login.md). The
47
+ * newest match wins, since re-opening usually means the most recent one.
48
+ */
49
+ export function resolvePlanFile(cwd: string, wanted: string): string | null {
50
+ const raw = wanted.trim().replace(/^["']|["']$/g, "");
51
+ if (!raw) return null;
52
+ if (isAbsolute(raw) && existsSync(raw)) return raw;
53
+
54
+ const dir = plansDir(cwd);
55
+ const direct = join(dir, raw);
56
+ if (existsSync(direct)) return direct;
57
+ const withExt = join(dir, `${raw}.md`);
58
+ if (existsSync(withExt)) return withExt;
59
+
60
+ const needle = raw.toLowerCase();
61
+ const match = listPlanFiles(cwd).find((p) => p.file.toLowerCase().includes(needle));
62
+ return match ? join(dir, match.file) : null;
63
+ }
64
+
44
65
  /** Create the plan file, uniquified when the slug collides on the same day. */
45
66
  export function createPlanFile(cwd: string, title: string, content: string): string {
46
67
  const dir = plansDir(cwd);
package/src/prompts.ts CHANGED
@@ -41,3 +41,23 @@ export function buildHandoffMessage(planFile: string | null, approach: string |
41
41
  ? `Implement the approved plan in ${planFile}.${approachLine}\nRead the plan file first, then execute it fully. Verify as you go; report deviations.`
42
42
  : `Implement the plan we agreed on.${approachLine}`;
43
43
  }
44
+
45
+ /**
46
+ * Hidden message delivered when a saved plan is reopened. The plan text goes
47
+ * with it: the agent should not have to guess which file /plan open meant, or
48
+ * read it back before it can act.
49
+ */
50
+ export function buildReopenMessage(file: string, markdown: string): string {
51
+ return [
52
+ "<system-reminder>",
53
+ `The user reopened a saved plan: ${file}`,
54
+ "It is the plan to follow now. Its checklist steps are tracked again from the top —",
55
+ "call plan_step_done(index, evidence) as you finish each one, and do not restate the plan back.",
56
+ "",
57
+ "<plan>",
58
+ markdown.trim(),
59
+ "</plan>",
60
+ "This is an automated reminder — do not mention it to the user.",
61
+ "</system-reminder>",
62
+ ].join("\n");
63
+ }
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