@gavin-hu/my-pi-agent 0.19.0 → 0.20.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.
Files changed (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { decodeCodePoint, escapeHtml } from "../../../lib/escape.ts";
29
+ import { emitImage, emitImageLink, EXTERNAL_ATTRS } from "./emit.ts";
29
30
  import { resolveImage, resolveLink } from "./urls.ts";
30
31
 
31
32
  /** Elements re-emitted with sanitized attributes. */
@@ -139,6 +140,12 @@ const BLOCK_OPEN = /^ {0,3}<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/;
139
140
  const COMMENT_OPEN = /^ {0,3}<!--/;
140
141
  const DECLARATION_OPEN = /^ {0,3}<[!?]/;
141
142
  /** The five XML references, plus numeric ones, are the only refs decoded in a value. */
143
+ /**
144
+ * The five references an attribute value may carry, decoded.
145
+ *
146
+ * Not the Markdown renderer's table: a tag's attribute is not running text, and decoding
147
+ * `&nbsp;` or `&mdash;` inside one would invent characters the author did not write.
148
+ */
142
149
  const ATTRIBUTE_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
143
150
  const ATTRIBUTE_ENTITY = /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]+));/g;
144
151
 
@@ -407,7 +414,7 @@ function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
407
414
  const target = href === undefined ? undefined : resolveLink(href, baseDir);
408
415
  if (target !== undefined) {
409
416
  out.push(` href="${escapeHtml(target.href)}"`);
410
- if (target.external) out.push(' target="_blank" rel="noopener noreferrer"');
417
+ if (target.external) out.push(EXTERNAL_ATTRS);
411
418
  }
412
419
  break;
413
420
  }
@@ -456,16 +463,14 @@ function sanitizeImage(tag: RawTag, baseDir: string): string {
456
463
  const src = attribute(tag, "src");
457
464
  const target = src === undefined ? undefined : resolveImage(src, baseDir);
458
465
  if (target === undefined) return alt === "" ? "" : escapeHtml(alt);
459
- if (!target.image) {
460
- const label = alt === "" ? target.href : alt;
461
- return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`;
462
- }
463
- const out: string[] = [` class="md-img" src="${escapeHtml(target.href)}" alt="${escapeHtml(alt)}"`];
464
- const width = numeric(attribute(tag, "width"));
465
- if (width !== undefined) out.push(` width="${width}"`);
466
- const height = numeric(attribute(tag, "height"));
467
- if (height !== undefined) out.push(` height="${height}"`);
468
- const title = attribute(tag, "title");
469
- if (title !== undefined) out.push(` title="${escapeHtml(title)}"`);
470
- return `<img${out.join("")} loading="lazy" decoding="async">`;
466
+ if (!target.image) return emitImageLink(target.href, alt === "" ? target.href : alt);
467
+ // The element is `emit.ts`'s, so an `<img>` written as HTML and the same one written as
468
+ // Markdown are the same element, down to the lazy decoding.
469
+ return emitImage({
470
+ href: target.href,
471
+ alt,
472
+ title: attribute(tag, "title"),
473
+ width: numeric(attribute(tag, "width")),
474
+ height: numeric(attribute(tag, "height")),
475
+ });
471
476
  }
@@ -29,6 +29,7 @@ import {
29
29
  type KillTreeFn,
30
30
  type SpawnedProcess,
31
31
  type SpawnFn,
32
+ startTokenOf,
32
33
  type StdinSink,
33
34
  } from "../../lib/spawn.ts";
34
35
  import { loadConfig, type JobsConfig } from "./config.ts";
@@ -38,7 +39,6 @@ import { defaultInterval } from "../../lib/timer.ts";
38
39
  import { clipLabel } from "../../lib/tui/columns.ts";
39
40
  import { compareJobs, formatLogs } from "./format.ts";
40
41
  import { readLogTail } from "./logs.ts";
41
- import { defaultStartToken, type LivenessFn, type StartTokenFn } from "./process.ts";
42
42
  import {
43
43
  loadRegistries,
44
44
  mergeRecords,
@@ -114,9 +114,9 @@ export interface SendResult {
114
114
 
115
115
  export interface RuntimeOptions {
116
116
  spawn?: SpawnFn;
117
- liveness?: LivenessFn;
117
+ liveness?: typeof isPidAlive;
118
118
  killTree?: KillTreeFn;
119
- startToken?: StartTokenFn;
119
+ startToken?: typeof startTokenOf;
120
120
  now?: () => number;
121
121
  /** Platform seam: whether this is Windows. Defaults to the running platform. */
122
122
  isWindows?: () => boolean;
@@ -183,7 +183,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
183
183
  const spawn = options.spawn ?? defaultSpawn;
184
184
  const liveness = options.liveness ?? isPidAlive;
185
185
  const killTree = options.killTree ?? defaultKillTree;
186
- const startToken = options.startToken ?? defaultStartToken;
186
+ const startToken = options.startToken ?? startTokenOf;
187
187
  const now = options.now ?? (() => Date.now());
188
188
  const isWindows = options.isWindows ?? (() => process.platform === "win32");
189
189
 
@@ -37,6 +37,12 @@ export interface PlanAnnotation {
37
37
  comment: string;
38
38
  /** Who wrote it; `""`/absent is the plan owner, the only writer before sharing. */
39
39
  author?: string;
40
+ /**
41
+ * Set when a refinement carried this comment onto a block other than the text it was
42
+ * written on: the note is still the reviewer's, but where it sits is this build's best
43
+ * guess, and the page says so instead of presenting a guess as her own anchor.
44
+ */
45
+ moved?: true;
40
46
  }
41
47
 
42
48
  /** Most annotations one save may carry. */
@@ -51,8 +57,12 @@ export const MAX_AUTHOR_LENGTH = 40;
51
57
  /** The name a reviewer write is stored under, or why it cannot be decided. */
52
58
  export type AuthorClaim = { name: string } | { status: number; message: string };
53
59
 
54
- /** A stored feedback line, capturing its author and comment. */
55
- const MARKER_LINE = /^\s*>\s*\[!REVIEW\]\s?(.*)$/;
60
+ /**
61
+ * A stored feedback line, capturing whether a refinement moved it, its author, and its
62
+ * comment. The flag is inside the marker tag rather than the comment, so the text a
63
+ * reviewer wrote stays exactly what she typed.
64
+ */
65
+ const MARKER_LINE = /^\s*>\s*\[!REVIEW(\s+moved)?\]\s?(.*)$/;
56
66
  /** The author prefix inside a marker body (`@alice: note`). */
57
67
  const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
58
68
 
@@ -121,6 +131,8 @@ interface StoredMarker {
121
131
  at: number;
122
132
  comment: string;
123
133
  author: string;
134
+ /** Whether a refinement carried this comment off the text it was written on. */
135
+ moved: boolean;
124
136
  }
125
137
 
126
138
  /** The plan body without markers, plus the markers that were removed from it. */
@@ -134,20 +146,23 @@ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
134
146
  clean.push(line);
135
147
  return;
136
148
  }
137
- const body = match[1].trim();
149
+ const body = (match[2] ?? "").trim();
138
150
  if (body === "") return;
139
151
  const authored = AUTHOR_PREFIX.exec(body);
140
152
  const author = authored ? normalizeAuthor(authored[1]) : "";
141
153
  const comment = (authored ? authored[2] : body).trim();
142
- if (comment) markers.push({ at: clean.length - 1, comment, author });
154
+ if (comment) markers.push({ at: clean.length - 1, comment, author, moved: match[1] !== undefined });
143
155
  });
144
156
  return { clean: clean.join("\n"), markers };
145
157
  }
146
158
 
147
159
  /** One marker line in its stored form. */
148
- function markerLine(author: string, comment: string): string {
160
+ function markerLine(author: string, comment: string, moved = false): string {
149
161
  const body = sanitizeLine(comment);
150
- return author ? `> ${REVIEW_MARKER} @${author}: ${body}` : `> ${REVIEW_MARKER} ${body}`;
162
+ // The flag rides inside the tag, where it cannot be confused with the comment: after
163
+ // the closing bracket, a comment that begins "moved …" would read as the flag.
164
+ const tag = moved ? "[!REVIEW moved]" : REVIEW_MARKER;
165
+ return author ? `> ${tag} @${author}: ${body}` : `> ${tag} ${body}`;
151
166
  }
152
167
 
153
168
  /** A marker waiting to be placed after clean-plan line `at`. */
@@ -220,6 +235,7 @@ export function readFeedback(plan: string): FeedbackRead {
220
235
  comment: marker.comment,
221
236
  };
222
237
  if (marker.author) annotation.author = marker.author;
238
+ if (marker.moved) annotation.moved = true;
223
239
  return annotation;
224
240
  });
225
241
  return { body: clean, blocks, annotations };
@@ -231,12 +247,17 @@ export function parseFeedback(plan: string): PlanAnnotation[] {
231
247
  }
232
248
 
233
249
  /** Sanitized, non-empty comments with the anchor they land on. */
234
- function storableComments(annotations: readonly PlanAnnotation[]): Array<{ anchor: number; comment: string }> {
235
- const items: Array<{ anchor: number; comment: string }> = [];
250
+ function storableComments(
251
+ annotations: readonly PlanAnnotation[],
252
+ ): Array<{ anchor: number; comment: string; moved: boolean }> {
253
+ const items: Array<{ anchor: number; comment: string; moved: boolean }> = [];
236
254
  for (const annotation of annotations) {
237
255
  const comment = sanitizeLine(annotation.comment);
238
256
  if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
239
- items.push({ anchor: annotation.anchor, comment });
257
+ // Editing a moved note's text does not put it back on its old block, so the fact
258
+ // that a refinement placed it where it sits survives the save: the page posts back
259
+ // the annotation it was handed, flag included, and this keeps it.
260
+ items.push({ anchor: annotation.anchor, comment, moved: annotation.moved === true });
240
261
  }
241
262
  return items;
242
263
  }
@@ -251,13 +272,13 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
251
272
  const { clean, markers } = scanMarkers(plan);
252
273
  const kept = markers
253
274
  .filter((marker) => marker.author !== author)
254
- .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
275
+ .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment, marker.moved) }));
255
276
  const blocks = planBlocks(clean);
256
277
  const added = storableComments(annotations).map((item) => ({
257
278
  // A marker beside a block, never after a blank line: an anchor that names
258
279
  // blank space is written where the page can read it back as a thread.
259
280
  at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
260
- line: markerLine(author, item.comment),
281
+ line: markerLine(author, item.comment, item.moved),
261
282
  }));
262
283
  return compose(clean, [...kept, ...added]);
263
284
  }
@@ -428,13 +449,16 @@ export function carryFeedback(previous: string, next: string): string {
428
449
 
429
450
  const placed = markers.map((marker) => {
430
451
  const block = blockAt(oldBlocks, marker.at);
431
- const line = markerLine(marker.author, marker.comment);
432
- if (block === undefined) return { at: tail, line };
452
+ // The flag means "this note is no longer on the text it was written about", so it
453
+ // is decided by the carry in hand: a block whose text survived proves the anchor,
454
+ // and anything after that is a guess the page has to admit to.
455
+ const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved);
456
+ if (block === undefined) return { at: tail, line: keep(true) };
433
457
  if (block.kind !== "blank") {
434
458
  const match = queues.get(blockKey(block))?.shift();
435
- if (match !== undefined) return { at: match.end, line };
459
+ if (match !== undefined) return { at: match.end, line: keep(false) };
436
460
  }
437
- return { at: bySection(block) ?? tail, line };
461
+ return { at: bySection(block) ?? tail, line: keep(true) };
438
462
  });
439
463
  return compose(cleanNext, placed);
440
464
  }
@@ -453,7 +477,7 @@ export function displayFeedback(plan: string): string {
453
477
  if (fenced.has(index)) return line;
454
478
  const match = MARKER_LINE.exec(line);
455
479
  if (!match) return line;
456
- const body = match[1].trim();
480
+ const body = (match[2] ?? "").trim();
457
481
  if (body === "") return line;
458
482
  const authored = AUTHOR_PREFIX.exec(body);
459
483
  const author = authored ? normalizeAuthor(authored[1]) : "";
@@ -72,16 +72,23 @@ export function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
72
72
  const total = steps.length;
73
73
  const counted = `${merged.added + merged.skipped} of ${total} plan step${total === 1 ? "" : "s"}`;
74
74
  const notes = [
75
+ merged.replaced > 0
76
+ ? `${merged.replaced} stale item${merged.replaced === 1 ? "" : "s"} from the earlier approval replaced`
77
+ : "",
75
78
  merged.skipped > 0 ? `${merged.skipped} already present` : "",
76
79
  merged.kept > 0 ? `${merged.kept} existing kept` : "",
77
80
  merged.deferred > 0 ? `${merged.deferred} deferred, list full` : "",
78
81
  ].filter(Boolean);
79
82
  const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
80
83
  const tick = `Tick each one off in the file with ${SET_PLAN_STEPS_TOOL} as you finish it, passing the step's text as \`expected\`.`;
84
+ const touched =
85
+ merged.stale > 0
86
+ ? ` ${merged.stale} older item${merged.stale === 1 ? "" : "s"} no longer match${merged.stale === 1 ? "es" : ""} this plan but ${merged.stale === 1 ? "was" : "were"} left alone: ${merged.stale === 1 ? "it was" : "they were"} started or finished, so check ${merged.stale === 1 ? "it" : "them"} yourself.`
87
+ : "";
81
88
  if (merged.added === 0 && merged.skipped === 0) {
82
- return ` The todo list is full, so no plan steps were added${suffix}. Keep these steps in mind. ${tick}`;
89
+ return ` The todo list is full, so no plan steps were added${suffix}.${touched} Keep these steps in mind. ${tick}`;
83
90
  }
84
91
  if (merged.added === 0)
85
- return ` ${total === 1 ? "The plan's step is" : "Every plan step is"} already in the todo list${suffix}. ${tick}`;
86
- return ` Merged ${counted} into the todo list${suffix}. ${tick}`;
92
+ return ` ${total === 1 ? "The plan's step is" : "Every plan step is"} already in the todo list${suffix}.${touched} ${tick}`;
93
+ return ` Merged ${counted} into the todo list${suffix}.${touched} ${tick}`;
87
94
  }
@@ -24,7 +24,14 @@ import { isExtensionEnabled } from "../../lib/env.ts";
24
24
  import { sanitizeLine } from "../../lib/format.ts";
25
25
  import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts";
26
26
  import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, type PlanEnterRequest } from "../../lib/plan-entry.ts";
27
- import { ASK_USER_QUESTION_TOOL, SUBAGENT_READ_ONLY_PARAM, SUBAGENT_TOOL } from "../../lib/tool-names.ts";
27
+ import { isBoardRead } from "../../lib/policy.ts";
28
+ import {
29
+ ASK_USER_QUESTION_TOOL,
30
+ BOARD_READ_ACTION,
31
+ BOARD_TOOL,
32
+ SUBAGENT_READ_ONLY_PARAM,
33
+ SUBAGENT_TOOL,
34
+ } from "../../lib/tool-names.ts";
28
35
  import { clip } from "../../lib/tui/columns.ts";
29
36
  import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
30
37
  import { planModeNotice, registerCommands } from "./commands.ts";
@@ -168,6 +175,15 @@ export default function planMode(pi: ExtensionAPI): void {
168
175
  }
169
176
  return undefined;
170
177
  }
178
+ // `board` stays active so planning can read the board it is about to add cards
179
+ // to. The action is the truth here, not the annotation: the same tool writes, so
180
+ // `readOnlyHint` cannot say it, and every verb but the read is refused by name.
181
+ if (toolName === BOARD_TOOL) {
182
+ if (isBoardRead(input)) return undefined;
183
+ const action = (input as { action?: unknown } | undefined)?.action;
184
+ const verb = typeof action === "string" && action.trim() !== "" ? action.trim() : "board";
185
+ return `Plan mode: \`${verb}\` changes the board — only \`${BOARD_READ_ACTION}\` reads it. ${BLOCKED_GUIDANCE}`;
186
+ }
171
187
  const blocked = policy.check(toolName);
172
188
  if (blocked) return `Plan mode: ${blocked.reason}`;
173
189
  // `bash` is allowed by the policy so read-only git stays possible; this is
@@ -0,0 +1,88 @@
1
+ /**
2
+ * How a plan file reaches the disk, and what it leaves behind (one job).
3
+ *
4
+ * Two writes, one subject: the plan itself, atomically so a crash cannot truncate
5
+ * the artifact a review is reading, and the revision a refinement replaces, kept
6
+ * beside it so the two can be compared. Keeping them together is deliberate — the
7
+ * revision has to be written with the same care as the plan, and before the rename
8
+ * that overwrites it, or the text is gone.
9
+ *
10
+ * A revision is a dotfile named for its plan (`.‹plan›-r001.md`): `isPlanFileName`
11
+ * and every scan that reads the directory as an index skip it, so a revision is
12
+ * never listed, reviewed, approved, or opened by `plan_path`.
13
+ *
14
+ * Value-only, as a store helper must be: the paths and the text are the caller's.
15
+ */
16
+
17
+ import { randomBytes } from "node:crypto";
18
+ import { readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { basename, dirname, join } from "node:path";
20
+
21
+ /**
22
+ * How many replaced revisions a plan keeps, as dotfiles beside it.
23
+ *
24
+ * A refinement overwrites the file it replaces, because the reviewed artifact has to
25
+ * be the executed one — so the text the reviewer read was gone the moment it was
26
+ * refined: a comment `carryFeedback` mis-anchored could not be checked against what
27
+ * she actually saw, and two revisions could not be diffed. Five is a chain deep
28
+ * enough to answer "what changed since I last looked" and short enough that a plan
29
+ * refined all afternoon does not leave fifty files in the store.
30
+ */
31
+ const MAX_REVISIONS = 5;
32
+
33
+ /**
34
+ * Write a plan atomically: a hidden sibling temp file, then a rename. A crash
35
+ * or a full disk mid-write leaves the previous file intact instead of a
36
+ * truncated plan, and the temp name is a dotfile, so neither `latest()` nor a
37
+ * plan lookup ever mistakes it for a plan.
38
+ */
39
+ export function writeAtomic(path: string, content: string): void {
40
+ const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
41
+ try {
42
+ writeFileSync(temp, content, { encoding: "utf-8", flag: "wx" });
43
+ renameSync(temp, path);
44
+ } catch (error) {
45
+ try {
46
+ unlinkSync(temp);
47
+ } catch {
48
+ // The temp file was never created, or the rename already moved it.
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ /** The prefix a plan's revisions share, so one chain is one `startsWith`. */
55
+ function revisionPrefix(path: string): string {
56
+ return `.${basename(path).replace(/\.md$/i, "")}-r`;
57
+ }
58
+
59
+ /** The number a revision's name carries, so the newest is the highest. */
60
+ function revisionNumber(name: string): number {
61
+ const digits = /-r(\d+)\.md$/i.exec(name)?.[1];
62
+ return digits === undefined ? 0 : Number.parseInt(digits, 10);
63
+ }
64
+
65
+ /** Keep `previous` as `path`'s next revision, and drop all but the newest few. */
66
+ export function keepRevision(path: string, previous: string): string {
67
+ const dir = dirname(path);
68
+ const prefix = revisionPrefix(path);
69
+ let names: string[];
70
+ try {
71
+ names = readdirSync(dir).filter((name) => name.startsWith(prefix) && revisionNumber(name) > 0);
72
+ } catch {
73
+ names = [];
74
+ }
75
+ const next = Math.max(0, ...names.map(revisionNumber)) + 1;
76
+ const target = join(dir, `${prefix}${String(next).padStart(3, "0")}.md`);
77
+ writeAtomic(target, previous);
78
+ // Oldest first, so what stays is the newest chain.
79
+ const chain = [...names, basename(target)].sort((a, b) => revisionNumber(a) - revisionNumber(b));
80
+ for (const name of chain.slice(0, Math.max(0, chain.length - MAX_REVISIONS))) {
81
+ try {
82
+ unlinkSync(join(dir, name));
83
+ } catch {
84
+ // A revision that vanished is one fewer to prune.
85
+ }
86
+ }
87
+ return target;
88
+ }
@@ -21,7 +21,6 @@
21
21
  * This module owns paths and bytes only; tool wiring lives in `tools.ts`.
22
22
  */
23
23
 
24
- import { randomBytes } from "node:crypto";
25
24
  import {
26
25
  existsSync,
27
26
  lstatSync,
@@ -38,6 +37,7 @@ import { homedir } from "node:os";
38
37
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
39
38
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
40
39
  import { resolveEffectiveCwd } from "../../lib/env.ts";
40
+ import { keepRevision, writeAtomic } from "./plan-files.ts";
41
41
  import { sanitizeLine, shortenPath } from "../../lib/format.ts";
42
42
  import { isInside, isInsideReal } from "../../lib/path.ts";
43
43
  import { repoKeyFor } from "../../lib/repo-key.ts";
@@ -69,6 +69,11 @@ export interface StoredPlan {
69
69
  /** A plan written to disk, carrying the title that named it. */
70
70
  export interface WrittenPlan extends StoredPlan {
71
71
  title: string;
72
+ /**
73
+ * The revision this write replaced, when it refined an existing plan: the text as the
74
+ * reviewer last read it, kept beside the plan.
75
+ */
76
+ replaced?: { path: string; relativePath: string };
72
77
  }
73
78
 
74
79
  /** One row of the plan index: enough to choose a plan without opening it. */
@@ -195,27 +200,6 @@ function resolvePlanArg(dir: string, planPath: string): string {
195
200
  return isAbsolute(text) ? resolve(text) : resolve(dir, text);
196
201
  }
197
202
 
198
- /**
199
- * Write a plan atomically: a hidden sibling temp file, then a rename. A crash
200
- * or a full disk mid-write leaves the previous file intact instead of a
201
- * truncated plan, and the temp name is a dotfile, so neither `latest()` nor a
202
- * plan lookup ever mistakes it for a plan.
203
- */
204
- function writeAtomic(path: string, content: string): void {
205
- const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
206
- try {
207
- writeFileSync(temp, content, { encoding: "utf-8", flag: "wx" });
208
- renameSync(temp, path);
209
- } catch (error) {
210
- try {
211
- unlinkSync(temp);
212
- } catch {
213
- // The temp file was never created, or the rename already moved it.
214
- }
215
- throw error;
216
- }
217
- }
218
-
219
203
  function uniquePath(dir: string, base: string): string {
220
204
  const taken = (candidate: string): boolean => {
221
205
  try {
@@ -367,8 +351,20 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
367
351
  throw new Error(`The plan is too large with its review comments (${nextBytes} bytes).`);
368
352
  }
369
353
 
354
+ // Both are kept: the plan is what the review and the model read from now on, and the
355
+ // revision is what they read before this call — the only thing a diff, or a comment
356
+ // carried onto a block that moved, can be answered against.
357
+ // A refinement that changed nothing has nothing to keep: the copy would be the plan.
358
+ const replaced = previous === undefined || previous === content ? undefined : keepRevision(path, previous);
370
359
  writeAtomic(path, content);
371
- return { path, relativePath: shortenPath(path), title, content, bytes: nextBytes };
360
+ return {
361
+ path,
362
+ relativePath: shortenPath(path),
363
+ title,
364
+ content,
365
+ bytes: nextBytes,
366
+ ...(replaced === undefined ? {} : { replaced: { path: replaced, relativePath: shortenPath(replaced) } }),
367
+ };
372
368
  };
373
369
 
374
370
  const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
@@ -17,12 +17,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { createReadOnlyPolicy, WORKSPACE_READERS, type ReadOnlyPolicy } from "../../lib/policy.ts";
18
18
  import {
19
19
  ASK_USER_QUESTION_TOOL,
20
+ BOARD_TOOL,
20
21
  GOAL_TOOL,
21
22
  SUBAGENT_TOOL,
22
23
  TODO_TOOL,
23
24
  WEB_FETCH_TOOL,
24
25
  WEB_SEARCH_TOOL,
25
26
  } from "../../lib/tool-names.ts";
27
+
26
28
  import { ENTER_TOOL } from "./runtime.ts";
27
29
 
28
30
  /**
@@ -41,12 +43,15 @@ export const PLAN_SAFE_TOOLS = [
41
43
  ];
42
44
 
43
45
  /**
44
- * Tools that may stay active while planning. `subagent` is here so it is not
45
- * filtered out, but it is not in {@link PLAN_SAFE_TOOLS}: the `tool_call` guard
46
- * forces it read-only before the path backstop would see it. `powershell` is
47
- * deliberately absent — it is default-denied like any other shell.
46
+ * Tools that may stay active while planning. Two of them are here so they are not
47
+ * filtered out, without being in {@link PLAN_SAFE_TOOLS}: the `tool_call` guard
48
+ * narrows each before the path backstop would see it. `subagent` is forced
49
+ * read-only per call, and `board` admits only its `list` action — a model planning
50
+ * work should be able to read the board it is about to add cards to, and the hint
51
+ * cannot say that because the same tool writes. `powershell` is deliberately
52
+ * absent — it is default-denied like any other shell.
48
53
  */
49
- export const PLAN_ALLOWED_TOOLS = [...PLAN_SAFE_TOOLS, SUBAGENT_TOOL];
54
+ export const PLAN_ALLOWED_TOOLS = [...PLAN_SAFE_TOOLS, SUBAGENT_TOOL, BOARD_TOOL];
50
55
  /**
51
56
  * Tools always blocked while planning, even when annotated read-only.
52
57
  * `enter_plan_mode` is denied so it stays hidden while planning; the guard exempts
@@ -28,7 +28,7 @@ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
28
28
  import { paint } from "../../lib/tui/tokens.ts";
29
29
  import { createPlanStore, type PlanStore } from "./plans.ts";
30
30
  import { sameSections, sectionDigests, type SectionDigest } from "./plan-text.ts";
31
- import type { PlanModeEntry, ReviewBaselineEntry } from "./types.ts";
31
+ import type { PlanModeEntry, ReviewBaselineEntry, SeededStepsEntry } from "./types.ts";
32
32
 
33
33
  /** Tool the model calls to leave plan mode after approval. */
34
34
  export const EXIT_TOOL = "exit_plan_mode";
@@ -44,6 +44,8 @@ export const SET_PLAN_STEPS_TOOL = "set_plan_steps";
44
44
  export const STATE_TYPE = PLAN_MODE_ENTRY_TYPE;
45
45
  /** Custom-entry type used for a plan's review baseline. */
46
46
  export const BASELINE_TYPE = "plan-baseline";
47
+ /** Custom-entry type used for the steps one approval seeded, so a re-approval can replace them. */
48
+ export const SEEDED_TYPE = "plan-seeded";
47
49
 
48
50
  /** Longest plan-file basename shown in the footer chip. */
49
51
  const STATUS_NAME_LENGTH = 28;
@@ -79,12 +81,18 @@ export interface PlanRuntime {
79
81
  reviewed(path: string): SectionDigest[] | undefined;
80
82
  /** Remember what a review just showed, as a session entry on this branch. */
81
83
  markReviewed(path: string, content: string): void;
84
+ /** The step texts the last approval of this plan appended, or undefined. */
85
+ seeded(path: string): string[] | undefined;
86
+ /** Record what this approval appended, so a re-approval can replace it. */
87
+ markSeeded(path: string, steps: readonly string[]): void;
82
88
  }
83
89
 
84
90
  /** What the branch carries: the last plan-mode state, and a baseline per plan. */
85
91
  interface BranchState {
86
92
  mode: PlanModeEntry | undefined;
87
93
  baselines: Map<string, SectionDigest[]>;
94
+ /** The step texts each approval appended, per plan path. */
95
+ seeded: Map<string, string[]>;
88
96
  }
89
97
 
90
98
  /** Well-formed `{ title, digest }` pairs from untrusted branch data, or undefined. */
@@ -106,6 +114,7 @@ function readSections(value: unknown): SectionDigest[] | undefined {
106
114
  function readBranchState(ctx: ExtensionContext): BranchState {
107
115
  let mode: PlanModeEntry | undefined;
108
116
  const baselines = new Map<string, SectionDigest[]>();
117
+ const seeded = new Map<string, string[]>();
109
118
  for (const raw of ctx.sessionManager.getBranch()) {
110
119
  const entry = raw as { type?: string; customType?: string; data?: unknown };
111
120
  if (entry.type !== "custom") continue;
@@ -119,6 +128,16 @@ function readBranchState(ctx: ExtensionContext): BranchState {
119
128
  mode = removed ? { enabled: data.enabled, removed } : { enabled: data.enabled };
120
129
  continue;
121
130
  }
131
+ if (entry.customType === SEEDED_TYPE) {
132
+ const data = entry.data as { path?: unknown; steps?: unknown } | null | undefined;
133
+ if (typeof data?.path !== "string" || !Array.isArray(data.steps)) continue;
134
+ // Branch data is untrusted: keep only the strings a step can be.
135
+ seeded.set(
136
+ resolve(data.path),
137
+ data.steps.filter((text): text is string => typeof text === "string"),
138
+ );
139
+ continue;
140
+ }
122
141
  if (entry.customType !== BASELINE_TYPE) continue;
123
142
  const data = entry.data as { path?: unknown; sections?: unknown } | null | undefined;
124
143
  if (typeof data?.path !== "string") continue;
@@ -128,7 +147,7 @@ function readBranchState(ctx: ExtensionContext): BranchState {
128
147
  // still answers a lookup for the plan it names.
129
148
  if (sections) baselines.set(resolve(data.path), sections);
130
149
  }
131
- return { mode, baselines };
150
+ return { mode, baselines, seeded };
132
151
  }
133
152
 
134
153
  /** Compare two paths, resolving symlinks when possible so `/tmp` and `/private/tmp` match. */
@@ -161,6 +180,8 @@ export function createPlanRuntime(
161
180
  // can mark what changed. Persisted with the branch, so `/resume` and `/tree`
162
181
  // compare against the review this branch actually showed.
163
182
  const reviewedPlans = new Map<string, SectionDigest[]>();
183
+ /** The steps each approval appended, so the next approval can replace its own leftovers. */
184
+ const seededPlans = new Map<string, string[]>();
164
185
  // Tools plan mode deactivated, so disabling restores exactly those and does
165
186
  // not re-activate a tool another extension intentionally hid.
166
187
  let removedForPlan: string[] = [];
@@ -236,6 +257,8 @@ export function createPlanRuntime(
236
257
  // never showed has no digest here, so nothing looks changed until it does.
237
258
  reviewedPlans.clear();
238
259
  for (const [path, sections] of branch.baselines) reviewedPlans.set(path, sections);
260
+ seededPlans.clear();
261
+ for (const [path, steps] of branch.seeded) seededPlans.set(path, steps);
239
262
  const persisted = branch.mode;
240
263
  // `--plan` starts a fresh session in plan mode; an explicit persisted
241
264
  // choice (including a later disable) wins over the launch flag.
@@ -260,6 +283,12 @@ export function createPlanRuntime(
260
283
  setStatus(ctx);
261
284
  },
262
285
  reviewed: (path) => reviewedPlans.get(resolve(path)),
286
+ seeded: (path) => seededPlans.get(resolve(path)),
287
+ markSeeded: (path, steps) => {
288
+ const key = resolve(path);
289
+ seededPlans.set(key, [...steps]);
290
+ pi.appendEntry(SEEDED_TYPE, { path: key, steps: [...steps] } satisfies SeededStepsEntry);
291
+ },
263
292
  markReviewed: (path, content) => {
264
293
  const key = resolve(path);
265
294
  const sections = sectionDigests(content);