@gavin-hu/my-pi-agent 0.18.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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -74,17 +74,24 @@ export function readMemoryFile(path: string): string {
74
74
  return readFileSync(path, "utf-8");
75
75
  }
76
76
 
77
- /** Coarse identity of a store file, for cheap change detection. */
77
+ /**
78
+ * Identity of a store file, for cheap change detection.
79
+ *
80
+ * The stamp is nanoseconds, not milliseconds: an edit that keeps the file's size
81
+ * is otherwise invisible for a whole millisecond, which is long enough for an
82
+ * editor that writes in place. `size` stays a number so the doctor's byte counts
83
+ * read the same.
84
+ */
78
85
  export interface MemoryFileStamp {
79
- mtimeMs: number;
86
+ mtimeNs: bigint;
80
87
  size: number;
81
88
  }
82
89
 
83
90
  /** Freshness stamp for a store file; `undefined` when it is missing or unstattable. */
84
91
  export function memoryFileStamp(path: string): MemoryFileStamp | undefined {
85
92
  try {
86
- const info = statSync(path);
87
- return { mtimeMs: info.mtimeMs, size: info.size };
93
+ const info = statSync(path, { bigint: true });
94
+ return { mtimeNs: info.mtimeNs, size: Number(info.size) };
88
95
  } catch {
89
96
  return undefined;
90
97
  }
@@ -93,7 +100,7 @@ export function memoryFileStamp(path: string): MemoryFileStamp | undefined {
93
100
  /** Whether two stamps describe the same file state. */
94
101
  export function sameStamp(a: MemoryFileStamp | undefined, b: MemoryFileStamp | undefined): boolean {
95
102
  if (a === undefined || b === undefined) return a === b;
96
- return a.mtimeMs === b.mtimeMs && a.size === b.size;
103
+ return a.mtimeNs === b.mtimeNs && a.size === b.size;
97
104
  }
98
105
 
99
106
  /** Note text of every bullet line, sanitized to one terminal-safe line. */
@@ -14,7 +14,7 @@
14
14
  * step.
15
15
  */
16
16
 
17
- import { FENCE, HEADING } from "./steps.ts";
17
+ import { FENCE, HEADING } from "./sections.ts";
18
18
 
19
19
  /** How a block is rendered and grouped. */
20
20
  export type PlanBlockKind = "heading" | "paragraph" | "list" | "code" | "quote" | "table" | "hr" | "blank";
@@ -176,7 +176,7 @@ export function planBlocks(plan: string): PlanBlock[] {
176
176
  }
177
177
 
178
178
  /** The block containing source line `line`, or the nearest block before it. */
179
- export function blockAt(blocks: PlanBlock[], line: number): PlanBlock | undefined {
179
+ export function blockAt(blocks: readonly PlanBlock[], line: number): PlanBlock | undefined {
180
180
  let candidate: PlanBlock | undefined;
181
181
  for (const block of blocks) {
182
182
  if (block.start <= line && line <= block.end) return block;
@@ -186,6 +186,27 @@ export function blockAt(blocks: PlanBlock[], line: number): PlanBlock | undefine
186
186
  return candidate;
187
187
  }
188
188
 
189
+ /**
190
+ * The nearest block with content at or before `line`, else the first one after
191
+ * it: what a comment in blank space belongs to.
192
+ *
193
+ * A blank line is a block, so {@link blockAt} may name one, but a comment cannot
194
+ * hang on nothing: the review page renders no line for a blank block, so an
195
+ * anchor there is one it can never attach a thread to.
196
+ */
197
+ export function contentBlockAt(blocks: readonly PlanBlock[], line: number): PlanBlock | undefined {
198
+ let before: PlanBlock | undefined;
199
+ for (const block of blocks) {
200
+ const content = block.kind !== "blank";
201
+ if (block.start <= line) {
202
+ if (content) before = block;
203
+ continue;
204
+ }
205
+ if (content) return before ?? block;
206
+ }
207
+ return before;
208
+ }
209
+
189
210
  /** The source inside a fenced code block, without its opening and closing fence lines. */
190
211
  export function codeBody(block: PlanBlock): string[] {
191
212
  if (block.kind !== "code") return block.lines.slice();
@@ -13,6 +13,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
13
13
  import { isRemoteTurn } from "../../lib/interaction.ts";
14
14
  import { READ_ONLY_SUMMARY } from "../../lib/plan-entry.ts";
15
15
  import { qrLines } from "../../lib/qr.ts";
16
+ import { withRailsSuppressed } from "../../lib/rails.ts";
16
17
  import { feedbackSummary } from "./feedback.ts";
17
18
  import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
18
19
  import { runBrowserReview } from "./review.ts";
@@ -192,22 +193,26 @@ async function openPlanIndex(
192
193
 
193
194
  // The review runs after the screen closes, so the input focus is free again.
194
195
  let selected: (PlanSummary | undefined) | undefined;
195
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
196
- const component = new PlanIndexComponent({
197
- summaries,
198
- dir,
199
- theme,
200
- onSelect: (summary) => {
201
- selected = summary;
202
- done();
203
- },
204
- onClose: () => done(),
205
- reload: summarise,
206
- requestRender: () => tui.requestRender(),
207
- viewportRows: () => tui.terminal?.rows,
208
- });
209
- return component;
210
- });
196
+ // A dock list shares the editor slot with the goal and todo rails, so it takes
197
+ // the dock to itself for as long as it is open, like the other list screens.
198
+ await withRailsSuppressed(pi, () =>
199
+ ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
200
+ const component = new PlanIndexComponent({
201
+ summaries,
202
+ dir,
203
+ theme,
204
+ onSelect: (summary) => {
205
+ selected = summary;
206
+ done();
207
+ },
208
+ onClose: () => done(),
209
+ reload: summarise,
210
+ requestRender: () => tui.requestRender(),
211
+ viewportRows: () => tui.terminal?.rows,
212
+ });
213
+ return component;
214
+ }),
215
+ );
211
216
  if (selected) await reviewInBrowser(pi, runtime, web, selected.path, ctx);
212
217
  }
213
218
 
@@ -21,8 +21,8 @@
21
21
  */
22
22
 
23
23
  import { sanitizeLine } from "../../lib/format.ts";
24
- import { blockAt, planBlocks, type PlanBlock } from "./blocks.ts";
25
- import { extractPlanSections } from "./steps.ts";
24
+ import { blockAt, contentBlockAt, planBlocks, type PlanBlock } from "./blocks.ts";
25
+ import { extractSections, sectionKeys } from "./sections.ts";
26
26
 
27
27
  /** Marker that opens every stored feedback line. */
28
28
  export const REVIEW_MARKER = "[!REVIEW]";
@@ -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`. */
@@ -191,28 +206,58 @@ export function stripFeedback(plan: string): string {
191
206
  }
192
207
 
193
208
  /**
194
- * Read stored feedback back. The anchor is the block ending just before each
195
- * marker in the stripped plan, so a comment keeps its place across reloads.
209
+ * A plan read the way the review reads it: its body, its blocks, and its
210
+ * comments, out of one parse.
196
211
  */
197
- export function parseFeedback(plan: string): PlanAnnotation[] {
212
+ export interface FeedbackRead {
213
+ /** The plan body with every stored marker removed. */
214
+ body: string;
215
+ /** The body split into source-line blocks: the shape an anchor names. */
216
+ blocks: PlanBlock[];
217
+ /** The comments the markers carried, each anchored to a content block's start. */
218
+ annotations: PlanAnnotation[];
219
+ }
220
+
221
+ /**
222
+ * Read a plan once. An anchor is the start of the block the marker follows, so a
223
+ * comment keeps its place across reloads — and it is always a block with
224
+ * content, because the page renders no line for a blank one and so can never
225
+ * attach a thread to it. A plan whose every line is blank has no such block: its
226
+ * comments keep anchor `0` and the page lists them as unattached, which is the
227
+ * truth there.
228
+ */
229
+ export function readFeedback(plan: string): FeedbackRead {
198
230
  const { clean, markers } = scanMarkers(plan);
199
- if (markers.length === 0) return [];
200
231
  const blocks = planBlocks(clean);
201
- return markers.map((marker) => {
202
- const block = blockAt(blocks, marker.at);
203
- const annotation: PlanAnnotation = { anchor: block ? block.start : 0, comment: marker.comment };
232
+ const annotations = markers.map((marker) => {
233
+ const annotation: PlanAnnotation = {
234
+ anchor: contentBlockAt(blocks, marker.at)?.start ?? 0,
235
+ comment: marker.comment,
236
+ };
204
237
  if (marker.author) annotation.author = marker.author;
238
+ if (marker.moved) annotation.moved = true;
205
239
  return annotation;
206
240
  });
241
+ return { body: clean, blocks, annotations };
242
+ }
243
+
244
+ /** The comments a plan carries, in the order they were written. */
245
+ export function parseFeedback(plan: string): PlanAnnotation[] {
246
+ return readFeedback(plan).annotations;
207
247
  }
208
248
 
209
249
  /** Sanitized, non-empty comments with the anchor they land on. */
210
- function storableComments(annotations: readonly PlanAnnotation[]): Array<{ anchor: number; comment: string }> {
211
- 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 }> = [];
212
254
  for (const annotation of annotations) {
213
255
  const comment = sanitizeLine(annotation.comment);
214
256
  if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
215
- 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 });
216
261
  }
217
262
  return items;
218
263
  }
@@ -227,11 +272,13 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
227
272
  const { clean, markers } = scanMarkers(plan);
228
273
  const kept = markers
229
274
  .filter((marker) => marker.author !== author)
230
- .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) }));
231
276
  const blocks = planBlocks(clean);
232
277
  const added = storableComments(annotations).map((item) => ({
233
- at: blockAt(blocks, item.anchor)?.end ?? -1,
234
- line: markerLine(author, item.comment),
278
+ // A marker beside a block, never after a blank line: an anchor that names
279
+ // blank space is written where the page can read it back as a thread.
280
+ at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
281
+ line: markerLine(author, item.comment, item.moved),
235
282
  }));
236
283
  return compose(clean, [...kept, ...added]);
237
284
  }
@@ -247,30 +294,92 @@ function blockKey(block: PlanBlock): string {
247
294
  }
248
295
 
249
296
  /**
250
- * A plan's heading sections, keyed by title and occurrence, each with the blocks
251
- * inside it and its last source line. Two `## Notes` sections are told apart by
252
- * which one they are, the way `changedSections` does it, so a duplicate heading
253
- * never collects both sections' blocks.
297
+ * Words a block is compared by when it may merely have been reworded: lowercased,
298
+ * with the list marker and task checkbox dropped. Every step of a `## Steps`
299
+ * section would otherwise match every other one through its `- [ ]` alone.
300
+ */
301
+ function blockWords(block: PlanBlock): Set<string> {
302
+ const text = block.lines
303
+ .join(" ")
304
+ .toLowerCase()
305
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, " ")
306
+ .replace(/\[[ x]\]/g, " ");
307
+ return new Set(text.split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ""));
308
+ }
309
+
310
+ /** Share of the longer block's words that both blocks have. */
311
+ function sharedShare(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
312
+ if (a.size === 0 || b.size === 0) return 0;
313
+ let shared = 0;
314
+ for (const word of a) if (b.has(word)) shared += 1;
315
+ return shared / Math.max(a.size, b.size);
316
+ }
317
+
318
+ /** Share of its words a block keeps, at or above which it is the same block reworded. */
319
+ const MATCH_SHARE = 0.5;
320
+
321
+ /**
322
+ * Which block of `next` each block of `old` became, keeping both lists in
323
+ * document order: the longest set of pairs whose wording overlaps enough to be one
324
+ * block reworded. A block left without a pair is `-1` — deleted, or replaced by
325
+ * text that shares nothing — which is the gap its comment belongs in.
326
+ */
327
+ function alignBlocks(old: readonly PlanBlock[], next: readonly PlanBlock[]): number[] {
328
+ const before = old.map(blockWords);
329
+ const after = next.map(blockWords);
330
+ const matches = before.map((words) => after.map((other) => (sharedShare(words, other) >= MATCH_SHARE ? 1 : 0)));
331
+ // A longest common subsequence over the match grid, so a block that moved keeps
332
+ // its own pairing instead of pulling the blocks around it along.
333
+ const width = next.length + 1;
334
+ const counts = new Array<number>((old.length + 1) * width).fill(0);
335
+ for (let row = old.length - 1; row >= 0; row--) {
336
+ for (let column = next.length - 1; column >= 0; column--) {
337
+ const paired = matches[row][column] === 1 ? 1 + counts[(row + 1) * width + column + 1] : 0;
338
+ counts[row * width + column] = Math.max(
339
+ paired,
340
+ counts[(row + 1) * width + column],
341
+ counts[row * width + column + 1],
342
+ );
343
+ }
344
+ }
345
+ const aligned = new Array<number>(old.length).fill(-1);
346
+ let row = 0;
347
+ let column = 0;
348
+ while (row < old.length && column < next.length) {
349
+ if (matches[row][column] === 1 && counts[row * width + column] === 1 + counts[(row + 1) * width + column + 1]) {
350
+ aligned[row] = column;
351
+ row += 1;
352
+ column += 1;
353
+ continue;
354
+ }
355
+ if (counts[(row + 1) * width + column] >= counts[row * width + column + 1]) row += 1;
356
+ else column += 1;
357
+ }
358
+ return aligned;
359
+ }
360
+
361
+ /**
362
+ * A plan's heading sections, keyed by `sectionKeys`, each with the blocks a
363
+ * comment can hang on and the last line of that content. Blank lines are left out
364
+ * on purpose: a position among them would move when one is added or removed
365
+ * above, and a reworded step would lose its comment to the next block. A section
366
+ * with no content block ends at its own heading, which the page renders.
254
367
  */
255
368
  function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, { blocks: PlanBlock[]; end: number }> {
256
- const sections = extractPlanSections(plan);
257
- const lineCount = plan === "" ? 0 : plan.split("\n").length;
258
- const seen = new Map<string, number>();
369
+ const sections = extractSections(plan);
259
370
  const index = new Map<string, { blocks: PlanBlock[]; end: number }>();
260
- const keys = sections.map((section, position) => {
261
- const occurrence = seen.get(section.title) ?? 0;
262
- seen.set(section.title, occurrence + 1);
263
- const key = `${occurrence}\u0000${section.title}`;
264
- index.set(key, { blocks: [], end: (sections[position + 1]?.line ?? lineCount) - 1 });
265
- return key;
266
- });
371
+ const keys = sectionKeys(sections);
372
+ keys.forEach((key, at) => index.set(key, { blocks: [], end: sections[at]?.line ?? 0 }));
267
373
 
268
374
  let position = 0;
269
375
  for (const block of blocks) {
270
376
  while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
271
377
  const key = keys[position];
272
378
  if (key === undefined) continue;
273
- index.get(key)?.blocks.push(block);
379
+ const entry = index.get(key);
380
+ if (!entry || block.kind === "blank") continue;
381
+ entry.blocks.push(block);
382
+ entry.end = block.end;
274
383
  }
275
384
  return index;
276
385
  }
@@ -280,9 +389,11 @@ function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, {
280
389
  * Comments are not plan content, so `next` is stripped of any marker it carries,
281
390
  * and every old one is re-attached by the strongest evidence that survives: the
282
391
  * block's text (per occurrence, so two identical steps keep their own comments),
283
- * then the same section and ordinal, then the end of that section. A comment
284
- * whose section is gone too is placed at the end rather than dropped, where the
285
- * review page shows it as unattached.
392
+ * then the block the alignment says it became when the wording changed, then the
393
+ * gap it left between the blocks that survived, then the end of that section's
394
+ * content. A comment whose section is gone too moves to the plan's last content
395
+ * block, so the page keeps showing it under a block rather than listing it as
396
+ * unattached; only a plan with no block at all leaves it in that list.
286
397
  */
287
398
  export function carryFeedback(previous: string, next: string): string {
288
399
  const { clean: oldClean, markers } = scanMarkers(previous);
@@ -303,29 +414,51 @@ export function carryFeedback(previous: string, next: string): string {
303
414
  const oldSections = sectionIndex(oldClean, oldBlocks);
304
415
  const nextSections = sectionIndex(cleanNext, nextBlocks);
305
416
  const lastLine = cleanNext === "" ? -1 : cleanNext.split("\n").length - 1;
417
+ // Where a comment goes when nothing about it survived: beside the plan's last
418
+ // block, never after the blank line a trailing newline leaves.
419
+ const tail = contentBlockAt(nextBlocks, lastLine)?.end ?? lastLine;
420
+
421
+ /** One alignment per section, reused by every comment that lands in it. */
422
+ const alignments = new Map<string, number[]>();
306
423
 
307
- /** The block at the same ordinal in the same section, else that section's end. */
424
+ /**
425
+ * Where a comment goes when its own block's text is gone: the block the alignment
426
+ * says it became, else the gap it left, else its section's end.
427
+ */
308
428
  const bySection = (block: PlanBlock): number | undefined => {
309
- for (const [key, old] of oldSections) {
310
- const ordinal = old.blocks.indexOf(block);
311
- if (ordinal < 0) continue;
312
- const section = nextSections.get(key);
313
- if (section === undefined) return undefined;
314
- const same = section.blocks[ordinal];
315
- return same ? same.end : section.end;
429
+ for (const [key, before] of oldSections) {
430
+ const index = before.blocks.indexOf(block);
431
+ if (index < 0) continue;
432
+ const after = nextSections.get(key);
433
+ if (after === undefined) return undefined;
434
+ let aligned = alignments.get(key);
435
+ if (aligned === undefined) {
436
+ aligned = alignBlocks(before.blocks, after.blocks);
437
+ alignments.set(key, aligned);
438
+ }
439
+ const became = aligned[index];
440
+ if (became >= 0) return after.blocks[became].end;
441
+ // The gap it left is right after the block before its nearest surviving
442
+ // successor: for the first block of a section, that is the heading.
443
+ const successor = aligned.findIndex((at, from) => from > index && at >= 0);
444
+ const gap = successor >= 0 ? after.blocks[aligned[successor] - 1] : undefined;
445
+ return gap?.end ?? after.end;
316
446
  }
317
447
  return undefined;
318
448
  };
319
449
 
320
450
  const placed = markers.map((marker) => {
321
451
  const block = blockAt(oldBlocks, marker.at);
322
- const line = markerLine(marker.author, marker.comment);
323
- if (block === undefined) return { at: lastLine, 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) };
324
457
  if (block.kind !== "blank") {
325
458
  const match = queues.get(blockKey(block))?.shift();
326
- if (match !== undefined) return { at: match.end, line };
459
+ if (match !== undefined) return { at: match.end, line: keep(false) };
327
460
  }
328
- return { at: bySection(block) ?? lastLine, line };
461
+ return { at: bySection(block) ?? tail, line: keep(true) };
329
462
  });
330
463
  return compose(cleanNext, placed);
331
464
  }
@@ -344,7 +477,7 @@ export function displayFeedback(plan: string): string {
344
477
  if (fenced.has(index)) return line;
345
478
  const match = MARKER_LINE.exec(line);
346
479
  if (!match) return line;
347
- const body = match[1].trim();
480
+ const body = (match[2] ?? "").trim();
348
481
  if (body === "") return line;
349
482
  const authored = AUTHOR_PREFIX.exec(body);
350
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";
@@ -149,10 +156,12 @@ export default function planMode(pi: ExtensionAPI): void {
149
156
  * the path backstop (a hint on a path-carrying tool is only a claim).
150
157
  */
151
158
  const blockedReason = (toolName: string, input: unknown): string | undefined => {
152
- // Plan-owned control tools are the sanctioned exception: `write_plan` saves
153
- // the artifact, `exit_plan_mode` presents it. The source-path check keeps a
154
- // same-named tool from another extension from borrowing the exemption.
155
- if (runtime.isControlTool(toolName)) return undefined;
159
+ // Plan-owned tools are the sanctioned exception: `write_plan` saves the
160
+ // artifact, `exit_plan_mode` presents it, `cancel_plan_mode` restores access,
161
+ // and `enter_plan_mode` answers "already" to a call that was in flight when
162
+ // plan mode came on. The source-path check keeps a same-named tool from
163
+ // another extension from borrowing the exemption.
164
+ if (runtime.isOwnTool(toolName)) return undefined;
156
165
  // `subagent` stays active, but only as read-only delegation. The flag is
157
166
  // forced in place (the child enforces it through `--tools`), so the model
158
167
  // cannot forget it; a `subagent` that does not declare the parameter is
@@ -166,6 +175,15 @@ export default function planMode(pi: ExtensionAPI): void {
166
175
  }
167
176
  return undefined;
168
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
+ }
169
187
  const blocked = policy.check(toolName);
170
188
  if (blocked) return `Plan mode: ${blocked.reason}`;
171
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
+ }