@gavin-hu/my-pi-agent 0.7.0 → 0.8.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.
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * It is deliberately not CommonMark: it covers the block and inline constructs
5
5
  * that show up in repository READMEs — headings, paragraphs, fenced code,
6
- * blockquotes, nested and task lists, tables, code spans, emphasis, links, and
7
- * images. Everything is escaped and only a known set of tags is emitted; raw
8
- * HTML in the source is shown as text, never executed.
6
+ * blockquotes, nested and task lists, tables, code spans, emphasis, inline and
7
+ * reference links, and images. Named and numeric character references are
8
+ * decoded as literal text. Everything is escaped and only a known set of tags
9
+ * is emitted; raw HTML in the source is shown as text, never executed.
9
10
  *
10
11
  * Relative links become `/view/<path>` and relative images become `/raw/<path>`
11
12
  * so they resolve from any page URL. The `/view` and `/raw` routes re-vet the
@@ -26,10 +27,68 @@ import { encodePath } from "./paths.ts";
26
27
  const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
27
28
  const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
28
29
  const HTTP_SCHEME = /^https?:/i;
30
+ /** A link reference definition: `[id]: dest "title"`. */
31
+ const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
32
+
33
+ interface LinkDefinition {
34
+ dest: string;
35
+ title?: string;
36
+ }
37
+
38
+ /** Per-document render state: the resolution base and the reference definitions. */
39
+ interface Context {
40
+ baseDir: string;
41
+ defs: Map<string, LinkDefinition>;
42
+ }
29
43
 
30
44
  /** Render a Markdown source string to a safe HTML fragment. */
31
45
  export function renderMarkdown(source: string, baseDir = ""): string {
32
- return renderBlocks(source.split(/\r\n|\r|\n/), baseDir);
46
+ const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
47
+ return renderBlocks(body, { baseDir, defs });
48
+ }
49
+
50
+ /** Fold a reference label the way CommonMark does: trim, collapse, case-fold. */
51
+ function normalizeLabel(label: string): string {
52
+ return label.trim().replace(/\s+/g, " ").toLowerCase();
53
+ }
54
+
55
+ /**
56
+ * Lift link reference definitions out of the source before block rendering, so
57
+ * a `[id]: dest` line becomes a definition rather than a paragraph and can be
58
+ * referenced from anywhere in the document. Fences are skipped so a
59
+ * definition-shaped line inside code stays literal. Definitions are
60
+ * single-line; one inside a blockquote or list item is left in place as text.
61
+ */
62
+ function collectDefinitions(lines: string[]): { defs: Map<string, LinkDefinition>; body: string[] } {
63
+ const defs = new Map<string, LinkDefinition>();
64
+ const body: string[] = [];
65
+ let fence: Fence | undefined;
66
+ for (const line of lines) {
67
+ if (fence) {
68
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
69
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = undefined;
70
+ body.push(line);
71
+ continue;
72
+ }
73
+ const open = matchFence(line);
74
+ if (open) {
75
+ fence = open;
76
+ body.push(line);
77
+ continue;
78
+ }
79
+ const match = DEFINITION.exec(line);
80
+ if (!match) {
81
+ body.push(line);
82
+ continue;
83
+ }
84
+ const key = normalizeLabel(match[1]);
85
+ if (!defs.has(key)) {
86
+ const dest = match[2].startsWith("<") && match[2].endsWith(">") ? match[2].slice(1, -1) : match[2];
87
+ defs.set(key, { dest, title: match[3] });
88
+ }
89
+ body.push("");
90
+ }
91
+ return { defs, body };
33
92
  }
34
93
 
35
94
  function isBlank(line: string): boolean {
@@ -56,7 +115,7 @@ function stripIndent(line: string, columns: number): string {
56
115
  return line.slice(index);
57
116
  }
58
117
 
59
- function renderBlocks(lines: string[], baseDir: string): string {
118
+ function renderBlocks(lines: string[], ctx: Context): string {
60
119
  const out: string[] = [];
61
120
  let i = 0;
62
121
  while (i < lines.length) {
@@ -76,7 +135,7 @@ function renderBlocks(lines: string[], baseDir: string): string {
76
135
 
77
136
  const heading = matchHeading(line);
78
137
  if (heading) {
79
- out.push(`<h${heading.level}>${renderInline(heading.text, baseDir)}</h${heading.level}>`);
138
+ out.push(`<h${heading.level}>${renderInline(heading.text, ctx)}</h${heading.level}>`);
80
139
  i++;
81
140
  continue;
82
141
  }
@@ -88,27 +147,29 @@ function renderBlocks(lines: string[], baseDir: string): string {
88
147
  }
89
148
 
90
149
  if (isBlockquoteLine(line)) {
91
- const [html, next] = renderBlockquote(lines, i, baseDir);
150
+ const [html, next] = renderBlockquote(lines, i, ctx);
92
151
  out.push(html);
93
152
  i = next;
94
153
  continue;
95
154
  }
96
155
 
97
- if (i + 1 < lines.length && isTableStart(line, lines[i + 1])) {
98
- const [html, next] = renderTable(lines, i, baseDir);
156
+ // A list marker wins over a pipe, so `- | a | b |` stays a list item
157
+ // instead of becoming a table whose first cell is `-`.
158
+ if (matchListItem(line)) {
159
+ const [html, next] = renderList(lines, i, ctx);
99
160
  out.push(html);
100
161
  i = next;
101
162
  continue;
102
163
  }
103
164
 
104
- if (matchListItem(line)) {
105
- const [html, next] = renderList(lines, i, baseDir);
165
+ if (i + 1 < lines.length && isTableStart(line, lines[i + 1])) {
166
+ const [html, next] = renderTable(lines, i, ctx);
106
167
  out.push(html);
107
168
  i = next;
108
169
  continue;
109
170
  }
110
171
 
111
- const [html, next] = renderParagraph(lines, i, baseDir);
172
+ const [html, next] = renderParagraph(lines, i, ctx);
112
173
  out.push(html);
113
174
  i = next;
114
175
  }
@@ -128,7 +189,7 @@ function isBlockStart(lines: string[], i: number): boolean {
128
189
  );
129
190
  }
130
191
 
131
- function renderParagraph(lines: string[], start: number, baseDir: string): [string, number] {
192
+ function renderParagraph(lines: string[], start: number, ctx: Context): [string, number] {
132
193
  const collected: string[] = [];
133
194
  let i = start;
134
195
  for (; i < lines.length; i++) {
@@ -141,7 +202,7 @@ function renderParagraph(lines: string[], start: number, baseDir: string): [stri
141
202
  let text = line.trimEnd();
142
203
  const hard = / {2}$/.test(line) || /\\$/.test(text);
143
204
  if (text.endsWith("\\")) text = text.slice(0, -1);
144
- const rendered = renderInline(text.trim(), baseDir);
205
+ const rendered = renderInline(text.trim(), ctx);
145
206
  return hard ? `${rendered}<br>` : rendered;
146
207
  })
147
208
  .join("\n");
@@ -192,7 +253,7 @@ function isBlockquoteLine(line: string): boolean {
192
253
  return /^ {0,3}>/.test(line);
193
254
  }
194
255
 
195
- function renderBlockquote(lines: string[], start: number, baseDir: string): [string, number] {
256
+ function renderBlockquote(lines: string[], start: number, ctx: Context): [string, number] {
196
257
  const inner: string[] = [];
197
258
  let i = start;
198
259
  for (; i < lines.length; i++) {
@@ -207,7 +268,7 @@ function renderBlockquote(lines: string[], start: number, baseDir: string): [str
207
268
  }
208
269
  break;
209
270
  }
210
- return [`<blockquote>${renderBlocks(inner, baseDir)}</blockquote>`, i];
271
+ return [`<blockquote>${renderBlocks(inner, ctx)}</blockquote>`, i];
211
272
  }
212
273
 
213
274
  function splitTableRow(line: string): string[] {
@@ -237,7 +298,10 @@ function splitTableRow(line: string): string[] {
237
298
  function isTableStart(header: string, delimiter: string): boolean {
238
299
  if (!header.includes("|")) return false;
239
300
  const cells = splitTableRow(delimiter);
240
- return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
301
+ // GFM requires the delimiter row to match the header row's cell count;
302
+ // otherwise the lines are a paragraph, not a table.
303
+ if (cells.length === 0 || cells.length !== splitTableRow(header).length) return false;
304
+ return cells.every((cell) => /^:?-+:?$/.test(cell));
241
305
  }
242
306
 
243
307
  function alignmentClass(cell: string): string {
@@ -249,10 +313,12 @@ function alignmentClass(cell: string): string {
249
313
  return "";
250
314
  }
251
315
 
252
- function renderTable(lines: string[], start: number, baseDir: string): [string, number] {
316
+ function renderTable(lines: string[], start: number, ctx: Context): [string, number] {
253
317
  const header = splitTableRow(lines[start]);
254
318
  const align = splitTableRow(lines[start + 1]).map(alignmentClass);
255
- const head = header.map((cell, index) => `<th${align[index] ?? ""}>${renderInline(cell, baseDir)}</th>`).join("");
319
+ const head = header
320
+ .map((cell, index) => `<th scope="col"${align[index] ?? ""}>${renderInline(cell, ctx)}</th>`)
321
+ .join("");
256
322
  const rows: string[] = [];
257
323
  let i = start + 2;
258
324
  for (; i < lines.length; i++) {
@@ -260,11 +326,12 @@ function renderTable(lines: string[], start: number, baseDir: string): [string,
260
326
  const cells = splitTableRow(lines[i]);
261
327
  const tds: string[] = [];
262
328
  for (let column = 0; column < header.length; column++) {
263
- tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "", baseDir)}</td>`);
329
+ tds.push(`<td${align[column] ?? ""}>${renderInline(cells[column] ?? "", ctx)}</td>`);
264
330
  }
265
331
  rows.push(`<tr>${tds.join("")}</tr>`);
266
332
  }
267
- return [`<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`, i];
333
+ const table = `<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
334
+ return [`<div class="md-table-wrap">${table}</div>`, i];
268
335
  }
269
336
 
270
337
  interface ListItem {
@@ -285,9 +352,9 @@ function matchListItem(line: string): ListItem | undefined {
285
352
  };
286
353
  }
287
354
 
288
- function renderList(lines: string[], start: number, baseDir: string): [string, number] {
355
+ function renderList(lines: string[], start: number, ctx: Context): [string, number] {
289
356
  const first = matchListItem(lines[start]);
290
- if (!first) return renderParagraph(lines, start, baseDir);
357
+ if (!first) return renderParagraph(lines, start, ctx);
291
358
  const baseIndent = first.indent;
292
359
  const ordered = first.ordered;
293
360
  const items: string[] = [];
@@ -323,18 +390,18 @@ function renderList(lines: string[], start: number, baseDir: string): [string, n
323
390
  }
324
391
  break;
325
392
  }
326
- items.push(renderListItem(contentLines, baseDir));
393
+ items.push(renderListItem(contentLines, ctx));
327
394
  }
328
395
  const tag = ordered ? "ol" : "ul";
329
396
  return [`<${tag}>${items.join("")}</${tag}>`, i];
330
397
  }
331
398
 
332
- function renderListItem(contentLines: string[], baseDir: string): string {
399
+ function renderListItem(contentLines: string[], ctx: Context): string {
333
400
  const task = /^\[([ xX])\]\s*(.*)$/.exec(contentLines[0] ?? "");
334
401
  const done = task ? task[1].toLowerCase() === "x" : false;
335
402
  const source = task ? [task[2], ...contentLines.slice(1)] : contentLines;
336
403
  const tight = !source.some(isBlank);
337
- let body = renderBlocks(source, baseDir);
404
+ let body = renderBlocks(source, ctx);
338
405
  if (tight) body = body.replace(/<\/?p>/g, "");
339
406
  const mark = task ? `<span class="md-check" aria-hidden="true">${done ? "☑" : "☐"}</span> ` : "";
340
407
  return `<li${task ? (done ? ` class="task done"` : ` class="task"`) : ""}>${mark}${body}</li>`;
@@ -347,11 +414,10 @@ interface ParsedLink {
347
414
  end: number;
348
415
  }
349
416
 
350
- function parseInlineLink(text: string, start: number): ParsedLink | undefined {
351
- if (text[start] !== "[") return undefined;
417
+ /** The index of the `]` closing the bracket run opened at `start`, or -1. */
418
+ function matchBracket(text: string, start: number): number {
352
419
  let depth = 0;
353
- let i = start;
354
- for (; i < text.length; i++) {
420
+ for (let i = start; i < text.length; i++) {
355
421
  const char = text[i];
356
422
  if (char === "\\") {
357
423
  i++;
@@ -360,10 +426,16 @@ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
360
426
  if (char === "[") depth++;
361
427
  else if (char === "]") {
362
428
  depth--;
363
- if (depth === 0) break;
429
+ if (depth === 0) return i;
364
430
  }
365
431
  }
366
- if (i >= text.length || text[i] !== "]" || text[i + 1] !== "(") return undefined;
432
+ return -1;
433
+ }
434
+
435
+ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
436
+ if (text[start] !== "[") return undefined;
437
+ const i = matchBracket(text, start);
438
+ if (i === -1 || text[i + 1] !== "(") return undefined;
367
439
  const label = text.slice(start + 1, i);
368
440
  let j = i + 2;
369
441
  while (j < text.length && /\s/.test(text[j])) j++;
@@ -409,6 +481,29 @@ function parseInlineLink(text: string, start: number): ParsedLink | undefined {
409
481
  return { label, dest, title, end: j + 1 };
410
482
  }
411
483
 
484
+ /**
485
+ * Resolve `[label][id]`, `[label][]`, or `[label]` against the document's
486
+ * reference definitions, preferring the full form and falling back to the
487
+ * shortcut so `[foo][bar]` still resolves `[foo]` when only that is defined.
488
+ */
489
+ function parseReferenceLink(text: string, start: number, ctx: Context): ParsedLink | undefined {
490
+ if (text[start] !== "[") return undefined;
491
+ const close = matchBracket(text, start);
492
+ if (close === -1 || text[close + 1] === "(") return undefined;
493
+ const label = text.slice(start + 1, close);
494
+ if (text[close + 1] === "[") {
495
+ const end = matchBracket(text, close + 1);
496
+ if (end !== -1) {
497
+ const raw = text.slice(close + 2, end);
498
+ const def = ctx.defs.get(normalizeLabel(raw === "" ? label : raw));
499
+ if (def) return { label, dest: def.dest, title: def.title, end: end + 1 };
500
+ }
501
+ }
502
+ const def = ctx.defs.get(normalizeLabel(label));
503
+ if (def) return { label, dest: def.dest, title: def.title, end: close + 1 };
504
+ return undefined;
505
+ }
506
+
412
507
  interface ResolvedLink {
413
508
  href: string;
414
509
  external: boolean;
@@ -459,18 +554,18 @@ function resolveImage(dest: string, baseDir: string): ResolvedImage | undefined
459
554
  return { href: `/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
460
555
  }
461
556
 
462
- function renderLink(parsed: ParsedLink, baseDir: string): string {
463
- const label = renderInline(parsed.label, baseDir);
464
- const target = resolveLink(parsed.dest, baseDir);
557
+ function renderLink(parsed: ParsedLink, ctx: Context): string {
558
+ const label = renderInline(parsed.label, ctx);
559
+ const target = resolveLink(parsed.dest, ctx.baseDir);
465
560
  if (!target) return label;
466
561
  const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
467
562
  const external = target.external ? ` target="_blank" rel="noopener noreferrer"` : "";
468
563
  return `<a href="${escapeHtml(target.href)}"${title}${external}>${label}</a>`;
469
564
  }
470
565
 
471
- function renderImage(parsed: ParsedLink, baseDir: string): string {
566
+ function renderImage(parsed: ParsedLink, ctx: Context): string {
472
567
  const alt = escapeHtml(parsed.label);
473
- const target = resolveImage(parsed.dest, baseDir);
568
+ const target = resolveImage(parsed.dest, ctx.baseDir);
474
569
  if (!target) return alt;
475
570
  if (!target.image) {
476
571
  return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${alt || escapeHtml(parsed.dest)}</a>`;
@@ -504,8 +599,86 @@ function findEmphasisClose(text: string, start: number, char: string): number {
504
599
  return -1;
505
600
  }
506
601
 
602
+ /**
603
+ * The named character references common enough to matter in a README. Anything
604
+ * outside this set stays literal; numeric references are always decoded.
605
+ */
606
+ const NAMED_ENTITIES: Record<string, string> = {
607
+ amp: "&",
608
+ lt: "<",
609
+ gt: ">",
610
+ quot: '"',
611
+ apos: "'",
612
+ nbsp: "\u00a0",
613
+ copy: "©",
614
+ reg: "®",
615
+ trade: "™",
616
+ hellip: "…",
617
+ mdash: "—",
618
+ ndash: "–",
619
+ lsquo: "‘",
620
+ rsquo: "’",
621
+ ldquo: "“",
622
+ rdquo: "”",
623
+ laquo: "«",
624
+ raquo: "»",
625
+ times: "×",
626
+ divide: "÷",
627
+ middot: "·",
628
+ bull: "•",
629
+ deg: "°",
630
+ plusmn: "±",
631
+ dagger: "†",
632
+ sect: "§",
633
+ para: "¶",
634
+ euro: "€",
635
+ pound: "£",
636
+ yen: "¥",
637
+ cent: "¢",
638
+ larr: "←",
639
+ rarr: "→",
640
+ harr: "↔",
641
+ le: "≤",
642
+ ge: "≥",
643
+ ne: "≠",
644
+ asymp: "≈",
645
+ sup2: "²",
646
+ sup3: "³",
647
+ frac12: "½",
648
+ frac14: "¼",
649
+ frac34: "¾",
650
+ };
651
+
652
+ interface Entity {
653
+ value: string;
654
+ length: number;
655
+ }
656
+
657
+ /** A code point, or U+FFFD when the reference is a surrogate or out of range. */
658
+ function codePoint(value: number): string {
659
+ if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
660
+ return "\uFFFD";
661
+ return String.fromCodePoint(value);
662
+ }
663
+
664
+ /**
665
+ * Match a numeric or named character reference at `start`. Decoding happens in
666
+ * the inline scanner rather than a pre-pass so an entity cannot become Markdown
667
+ * syntax: `&#42;x&#42;` stays the literal text `*x*`.
668
+ */
669
+ function matchEntity(text: string, start: number): Entity | undefined {
670
+ const match = /^&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/.exec(text.slice(start));
671
+ if (!match) return undefined;
672
+ let value: string | undefined;
673
+ if (match[1] !== undefined) value = codePoint(Number(match[1]));
674
+ else if (match[2] !== undefined) value = codePoint(Number.parseInt(match[2], 16));
675
+ else if (match[3] !== undefined && Object.hasOwn(NAMED_ENTITIES, match[3])) value = NAMED_ENTITIES[match[3]];
676
+ if (value === undefined) return undefined;
677
+ return { value, length: match[0].length };
678
+ }
679
+
507
680
  /** Render inline Markdown within a block, escaping every literal character. */
508
- function renderInline(text: string, baseDir: string): string {
681
+ function renderInline(text: string, ctx: Context): string {
509
682
  let out = "";
510
683
  let i = 0;
511
684
  while (i < text.length) {
@@ -517,6 +690,15 @@ function renderInline(text: string, baseDir: string): string {
517
690
  continue;
518
691
  }
519
692
 
693
+ if (char === "&") {
694
+ const entity = matchEntity(text, i);
695
+ if (entity) {
696
+ out += escapeHtml(entity.value);
697
+ i += entity.length;
698
+ continue;
699
+ }
700
+ }
701
+
520
702
  if (char === "`") {
521
703
  const run = matchRun(text, i, "`");
522
704
  const close = text.indexOf("`".repeat(run), i + run);
@@ -530,18 +712,18 @@ function renderInline(text: string, baseDir: string): string {
530
712
  }
531
713
 
532
714
  if (char === "!" && text[i + 1] === "[") {
533
- const parsed = parseInlineLink(text, i + 1);
715
+ const parsed = parseInlineLink(text, i + 1) ?? parseReferenceLink(text, i + 1, ctx);
534
716
  if (parsed) {
535
- out += renderImage(parsed, baseDir);
717
+ out += renderImage(parsed, ctx);
536
718
  i = parsed.end;
537
719
  continue;
538
720
  }
539
721
  }
540
722
 
541
723
  if (char === "[") {
542
- const parsed = parseInlineLink(text, i);
724
+ const parsed = parseInlineLink(text, i) ?? parseReferenceLink(text, i, ctx);
543
725
  if (parsed) {
544
- out += renderLink(parsed, baseDir);
726
+ out += renderLink(parsed, ctx);
545
727
  i = parsed.end;
546
728
  continue;
547
729
  }
@@ -561,7 +743,7 @@ function renderInline(text: string, baseDir: string): string {
561
743
  if ((char === "*" || char === "_") && text[i + 1] === char) {
562
744
  const close = text.indexOf(char + char, i + 2);
563
745
  if (close > i + 2) {
564
- out += `<strong>${renderInline(text.slice(i + 2, close), baseDir)}</strong>`;
746
+ out += `<strong>${renderInline(text.slice(i + 2, close), ctx)}</strong>`;
565
747
  i = close + 2;
566
748
  continue;
567
749
  }
@@ -570,7 +752,7 @@ function renderInline(text: string, baseDir: string): string {
570
752
  if (char === "~" && text[i + 1] === "~") {
571
753
  const close = text.indexOf("~~", i + 2);
572
754
  if (close > i + 2) {
573
- out += `<del>${renderInline(text.slice(i + 2, close), baseDir)}</del>`;
755
+ out += `<del>${renderInline(text.slice(i + 2, close), ctx)}</del>`;
574
756
  i = close + 2;
575
757
  continue;
576
758
  }
@@ -579,7 +761,7 @@ function renderInline(text: string, baseDir: string): string {
579
761
  if (char === "*" || char === "_") {
580
762
  const close = findEmphasisClose(text, i, char);
581
763
  if (close > i + 1) {
582
- out += `<em>${renderInline(text.slice(i + 1, close), baseDir)}</em>`;
764
+ out += `<em>${renderInline(text.slice(i + 1, close), ctx)}</em>`;
583
765
  i = close + 1;
584
766
  continue;
585
767
  }
@@ -42,8 +42,9 @@ pi --plan # start in plan mode
42
42
  them into `todo`.
43
43
  - **A navigable review.** The review screen splits the body at its headings,
44
44
  names the section you are in, and jumps between sections with `n`/`p`.
45
- - **Kept current.** After approval, `set_plan_step` flips a step's checkbox in
46
- the file, so the plan mirrors what has actually been done.
45
+ - **Kept current.** After approval, `set_plan_steps` flips the checkboxes of
46
+ the steps you name in the file, so the plan mirrors what has actually been
47
+ done.
47
48
  - **Reviewable plan.** `exit_plan_mode` reads the plan file back and presents
48
49
  it for approval. The user approves, keeps planning, or asks for a refinement.
49
50
  - **Browser review on demand.** Press `w` on the review screen (or run
@@ -68,7 +69,7 @@ pi --plan # start in plan mode
68
69
  | `enter_plan_mode` | normal mode | Ask to enter plan mode; empty schema, confirmed with `ctx.ui.confirm`. |
69
70
  | `write_plan` | plan mode | Save the plan: `title` (file-name slug), `content` (full markdown in the documented outline), optional `plan_path` to overwrite a refinement. Returns the absolute path. |
70
71
  | `exit_plan_mode` | plan mode | Read the plan file (`plan_path`) and present it for approval. |
71
- | `set_plan_step` | normal mode | Keep an approved plan current: set one top-level step (`step`, 1-based, in the order `exit_plan_mode` listed) to `pending` or `completed`, in place. Only the checkbox changes. |
72
+ | `set_plan_steps` | normal mode | Keep an approved plan current: set the checkboxes of the given top-level steps (`updates`, each a 1-based `step` in the order `exit_plan_mode` listed, plus `status`) to `pending` or `completed` in one write. Only the checkboxes change, and a reopened step drops its `[DONE:n]` marker. |
72
73
 
73
74
  ### Commands and flags
74
75
 
@@ -249,6 +250,8 @@ agent file that overrides it.
249
250
 
250
251
  ## Browser review
251
252
 
253
+ <img src="../../assets/plan-review.png" alt="The browser-based plan review, showing the plan's sections, a rendered code block, and an inline comment box" width="100%">
254
+
252
255
  Press `w` on the review screen — or run `/plan review [path]` — to open the plan
253
256
  in the default browser on `127.0.0.1`. The page is a review surface: a sticky
254
257
  header with the plan title, theme toggle, and Save/Close; a sticky **section
@@ -314,7 +317,7 @@ returns them to the model.
314
317
 
315
318
  - **Not a sandbox.** Extensions share Pi's OS permissions.
316
319
  - **No progress tracker of record.** `todo` owns completion state; the plan
317
- file only mirrors it (`set_plan_step`), and plan mode does not read completion
320
+ file only mirrors it (`set_plan_steps`), and plan mode does not read completion
318
321
  back out.
319
322
  - **No auto-execution.** Approval hands control back to the model.
320
323
 
@@ -322,7 +325,7 @@ returns them to the model.
322
325
 
323
326
  | Integration point | How |
324
327
  |---|---|
325
- | Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_step` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_step` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepDetails`); no `outputSchema`/`structuredContent`. |
328
+ | Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_steps` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`); no `outputSchema`/`structuredContent`. |
326
329
  | Commands and flags | `/plan` (toggle, or enter and send a task), `/plan review [path]` (browser review), `--plan` launch flag, `Ctrl+Alt+P` shortcut. |
327
330
  | State | Enabled flag and the tools it hid persisted with `pi.appendEntry("plan-mode", { enabled, removed })` and rebuilt from `ctx.sessionManager.getBranch()`; the last plan path lives in runtime memory for the footer chip. |
328
331
  | Hooks | `tool_call` blocks non-read-only calls and forces `subagent` read-only; `before_agent_start` injects the hidden `[PLAN MODE ACTIVE]` context; `context` keeps only the newest plan context while enabled and drops stale ones otherwise. |
@@ -360,10 +363,11 @@ returns them to the model.
360
363
  source-line map, so `tui.ts` splits the body at its headings and renders each
361
364
  section separately. That gives exact offsets for the current-section label and
362
365
  `n`/`p`; the single-section case is byte-for-byte the old render.
363
- - **`set_plan_step` is a second contained write.** It changes only a step's
364
- checkbox, in an existing markdown plan inside the plans directory
365
- (`isContainedPlan`), and refuses while plan mode is on — so the read-only
366
- guarantee and the path backstop are unchanged.
366
+ - **`set_plan_steps` is a second contained write.** It changes only the named
367
+ steps' checkboxes, in an existing markdown plan inside the plans directory
368
+ (`isContainedPlan`), in a single all-or-nothing write — refusing every entry
369
+ if any step is out of range — and refuses while plan mode is on, so the
370
+ read-only guarantee and the path backstop are unchanged.
367
371
  - **Direction: enforce capabilities at the tool boundary.** (1) A shared
368
372
  default-deny policy classifies every call as read-only or mutating instead of
369
373
  maintaining a mutator list. (2) Read-only delegation is enforced in the child
@@ -394,13 +398,13 @@ returns them to the model.
394
398
  | File | Responsibility |
395
399
  |---|---|
396
400
  | `index.ts` | Wiring: tools, command, flag, shortcut, events, context injection. |
397
- | `types.ts` | `PlanModeEntry`, `EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepDetails`. |
401
+ | `types.ts` | `PlanModeEntry`, `EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`. |
398
402
  | `policy.ts` | Plan mode's read-only policy, the shared prompt summary, and the bash-refusal guidance. |
399
403
  | `git-bash.ts` | `checkReadOnlyGit`: the pure allowlist that restricts `bash` to read-only git commands. |
400
- | `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, write/read/`setStep`/`saveFeedback`. |
404
+ | `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, write/read/`setSteps`/`saveFeedback`. |
401
405
  | `steps.ts` | Plan-document structure (pure): the outline, step extraction, heading sections, and the checkbox edit. |
402
406
  | `runtime.ts` | Enabled state, tool gating, persistence, control-tool identity, footer status. |
403
- | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_step`. |
407
+ | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`. |
404
408
  | `commands.ts` | `/plan` (enter, optional task) and the `/plan review [path]` browser subcommand. |
405
409
  | `tui.ts` | `PlanViewComponent` (sectioned Markdown body, section jump, scroll gauge, approve/refine/browser/keep). |
406
410
  | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, blanks). |
@@ -20,7 +20,7 @@ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works
20
20
  import { repoRootFor } from "../../lib/git/index.ts";
21
21
  import { isInside, isInsideReal } from "../../lib/path.ts";
22
22
  import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
23
- import { extractPlanSteps, setStepStatus, type PlanStep } from "./steps.ts";
23
+ import { extractPlanSteps, setStepStatuses, type StepUpdate } from "./steps.ts";
24
24
 
25
25
  /** Largest plan accepted, in UTF-8 bytes. */
26
26
  export const MAX_PLAN_BYTES = 256 * 1024;
@@ -57,8 +57,8 @@ export interface PlanStore {
57
57
  write(cwd: string, input: WritePlanInput): Promise<WrittenPlan>;
58
58
  /** Read a plan inside the plans directory, or undefined when missing/outside. */
59
59
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
60
- /** Set a top-level step's checkbox in an existing plan. Throws when the file or step is invalid. */
61
- setStep(cwd: string, planPath: string, step: number, status: PlanStep["status"]): Promise<StoredPlan>;
60
+ /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
61
+ setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
62
62
  /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
63
63
  saveFeedback(cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan>;
64
64
  }
@@ -222,26 +222,25 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
222
222
  }
223
223
  };
224
224
 
225
- const setStep = async (
226
- cwd: string,
227
- planPath: string,
228
- step: number,
229
- status: PlanStep["status"],
230
- ): Promise<StoredPlan> => {
225
+ const setSteps = async (cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan> => {
231
226
  const plan = await read(cwd, planPath);
232
227
  if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
233
- const updated = setStepStatus(plan.content, step, status);
234
- if (!updated) {
235
- const total = extractPlanSteps(plan.content).length;
228
+ const total = extractPlanSteps(plan.content).length;
229
+ const outOfRange = updates.find(
230
+ (update) => !Number.isInteger(update.step) || update.step < 1 || update.step > total,
231
+ );
232
+ if (outOfRange) {
236
233
  const range = total === 0 ? "it has no steps" : `valid steps are 1 to ${total}`;
237
- throw new Error(`Step ${step} is out of range; ${range}.`);
234
+ throw new Error(`Step ${outOfRange.step} is out of range; ${range}.`);
238
235
  }
239
- writeFileSync(plan.path, updated.plan, "utf-8");
236
+ const updated = setStepStatuses(plan.content, updates);
237
+ if (!updated) throw new Error("Could not update the plan steps; pass steps from the plan file.");
238
+ writeFileSync(plan.path, updated, "utf-8");
240
239
  return {
241
240
  path: plan.path,
242
241
  relativePath: plan.relativePath,
243
- content: updated.plan,
244
- bytes: Buffer.byteLength(updated.plan, "utf-8"),
242
+ content: updated,
243
+ bytes: Buffer.byteLength(updated, "utf-8"),
245
244
  };
246
245
  };
247
246
 
@@ -263,5 +262,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
263
262
  };
264
263
  };
265
264
 
266
- return { dirFor, write, read, setStep, saveFeedback };
265
+ return { dirFor, write, read, setSteps, saveFeedback };
267
266
  }
@@ -31,7 +31,7 @@ export const ENTER_TOOL = "enter_plan_mode";
31
31
  /** Tool the model calls to save the plan artifact. */
32
32
  export const WRITE_PLAN_TOOL = "write_plan";
33
33
  /** Tool the model calls to keep an approved plan's steps current. */
34
- export const SET_PLAN_STEP_TOOL = "set_plan_step";
34
+ export const SET_PLAN_STEPS_TOOL = "set_plan_steps";
35
35
  /** Custom-entry type used for persistence. */
36
36
  export const STATE_TYPE = "plan-mode";
37
37