@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  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 +554 -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} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -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 +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -16,7 +16,8 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { type Component, Key, matchesKey, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
17
17
  import { sanitizeLine, shortenPath } from "../../lib/format.ts";
18
18
  import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
19
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
19
+ import { detailBlockRows, dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
+ import { formatSize } from "../../lib/format.ts";
20
21
  import { clip } from "../../lib/tui/columns.ts";
21
22
  import { emptyState, listRow } from "../../lib/tui/rows.ts";
22
23
  import { titleRule } from "../../lib/tui/rules.ts";
@@ -25,8 +26,12 @@ import type { PlanSummary } from "./plans.ts";
25
26
 
26
27
  /** Rows the screen shows when the terminal height is unknown. */
27
28
  const DEFAULT_ROWS = 14;
28
- /** Fixed rows around the list: header, summary, blank, range, detail, hint, closing rule. */
29
- const CHROME = 7;
29
+ /**
30
+ * Fixed rows around the list body: header, summary, blank, and the closing footer
31
+ * (blank, hint, rule). The focused path adds `detailBlockRows` on top, and `fitRows`
32
+ * adds one more when the range row appears.
33
+ */
34
+ const CHROME = 6;
30
35
  /** Longest title a row draws before the trailing meta wins the column. */
31
36
  const TITLE_WIDTH = 56;
32
37
 
@@ -56,11 +61,33 @@ function localTime(ms: number): string {
56
61
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
57
62
  }
58
63
 
64
+ /**
65
+ * The store's own footprint for one repository: the bytes its plans hold, and the
66
+ * oldest of them. Derived from the summary the directory walk already produced, so
67
+ * reporting a size never becomes a second index that can disagree with the files.
68
+ */
69
+ function footprint(summaries: readonly PlanSummary[]): { bytes: number; oldest: number } {
70
+ let bytes = 0;
71
+ let oldest = summaries[0]?.modified ?? 0;
72
+ for (const summary of summaries) {
73
+ bytes += summary.bytes;
74
+ oldest = Math.min(oldest, summary.modified);
75
+ }
76
+ return { bytes, oldest };
77
+ }
78
+
79
+ /** `2.4 kB — oldest 2d ago`, the store's footprint as the header and the screen say it. */
80
+ function footprintText(summaries: readonly PlanSummary[], nowMs: number): string {
81
+ const { bytes, oldest } = footprint(summaries);
82
+ return `${formatSize(bytes)} — oldest ${relativeAge(oldest, nowMs)}`;
83
+ }
84
+
59
85
  /**
60
86
  * The non-TUI listing: one line per plan with its title, step progress, modified
61
- * time, and path, newest first. The directory is named once in the header.
87
+ * time, and path, newest first. The header names the count, the store's footprint,
88
+ * and the directory once; `now` is injectable so the age can be asserted.
62
89
  */
63
- export function formatPlanIndex(summaries: readonly PlanSummary[], dir: string): string {
90
+ export function formatPlanIndex(summaries: readonly PlanSummary[], dir: string, now: number = Date.now()): string {
64
91
  const where = sanitizeLine(shortenPath(dir));
65
92
  if (summaries.length === 0) {
66
93
  return `No plans in ${where} yet; write_plan saves one there.`;
@@ -69,7 +96,7 @@ export function formatPlanIndex(summaries: readonly PlanSummary[], dir: string):
69
96
  (summary) =>
70
97
  ` ${sanitizeLine(summary.title)} — ${stepsText(summary)} — ${localTime(summary.modified)} — ${sanitizeLine(summary.relativePath)}`,
71
98
  );
72
- return [`Plans (${summaries.length}) — ${where}`, ...lines].join("\n");
99
+ return [`Plans (${summaries.length}) — ${footprintText(summaries, now)} — ${where}`, ...lines].join("\n");
73
100
  }
74
101
 
75
102
  export interface PlanIndexOptions {
@@ -185,7 +212,8 @@ export class PlanIndexComponent implements Component {
185
212
  private summaryLine(width: number): string {
186
213
  const { theme } = this.options;
187
214
  const count = this.summaries.length;
188
- const text = `${paint(theme, "accent", "Plans")} ${paint(theme, "meta", `· ${count} ${count === 1 ? "plan" : "plans"}`)}`;
215
+ const size = footprintText(this.summaries, (this.options.now ?? Date.now)());
216
+ const text = `${paint(theme, "accent", "Plans")} ${paint(theme, "meta", `· ${count} ${count === 1 ? "plan" : "plans"} · ${size}`)}`;
189
217
  const where = paint(theme, "faint", ` · ${sanitizeLine(shortenPath(this.options.dir))}`);
190
218
  return clip(` ${text}${where}`, width);
191
219
  }
@@ -202,12 +230,18 @@ export class PlanIndexComponent implements Component {
202
230
  render(width: number): string[] {
203
231
  const { theme } = this.options;
204
232
  const w = Math.max(1, width);
205
- this.cursor.visible = fitRows(this.options.viewportRows, this.summaries.length, CHROME, DEFAULT_ROWS);
206
233
  this.cursor.sync(this.summaries.length);
234
+ const detail = this.detailLines(w);
235
+ this.cursor.visible = fitRows(
236
+ this.options.viewportRows,
237
+ this.summaries.length,
238
+ CHROME + detailBlockRows(detail),
239
+ DEFAULT_ROWS,
240
+ );
207
241
 
208
242
  const body: string[] = [];
209
243
  if (this.summaries.length === 0) {
210
- body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one under .pi/plans." }));
244
+ body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one in the agent directory." }));
211
245
  } else {
212
246
  const { start, end } = this.cursor.window(this.summaries.length);
213
247
  for (let index = start; index < end; index++) {
@@ -229,7 +263,7 @@ export class PlanIndexComponent implements Component {
229
263
  range: this.cursor.clipped(this.summaries.length)
230
264
  ? formatRange(theme, w, { ...this.cursor.window(this.summaries.length), total: this.summaries.length })
231
265
  : undefined,
232
- detail: this.detailLines(w),
266
+ detail,
233
267
  hint,
234
268
  });
235
269
  }
@@ -15,8 +15,8 @@
15
15
 
16
16
  import { createHash } from "node:crypto";
17
17
  import { stripControlChars } from "../../lib/format.ts";
18
- import { stripFeedback } from "./feedback.ts";
19
- import { extractPlanSections } from "./steps.ts";
18
+ import { displayFeedback, stripFeedback } from "./feedback.ts";
19
+ import { extractSections, sectionKeys, type PlanSection } from "./sections.ts";
20
20
 
21
21
  /** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
22
22
  const TAB_WIDTH = 4;
@@ -38,6 +38,14 @@ export function sanitizePlanText(text: string): string {
38
38
  return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
39
39
  }
40
40
 
41
+ /**
42
+ * The text a review *shows*: terminal-safe, with stored comments kept in place
43
+ * as readable quotes, so a reviewer sees what a teammate wrote.
44
+ */
45
+ export function planDisplayText(content: string): string {
46
+ return displayFeedback(sanitizePlanText(content));
47
+ }
48
+
41
49
  /**
42
50
  * The text a review compares: terminal-safe, with tabs expanded and stored
43
51
  * feedback removed. A review comment a browser wrote into the file is a note to
@@ -52,9 +60,18 @@ function digestOf(text: string): string {
52
60
  return createHash("sha256").update(text.trim()).digest("hex");
53
61
  }
54
62
 
63
+ /**
64
+ * A short, stable id for one plan text. The review page keys this tab's unsaved
65
+ * draft on it, so a draft is restored for the plan it was written against and
66
+ * dropped for a plan that changed underneath it.
67
+ */
68
+ export function planDigest(text: string): string {
69
+ return digestOf(text).slice(0, 12);
70
+ }
71
+
55
72
  /** One digest per heading section, in document order, for a persisted baseline. */
56
73
  export function sectionDigests(content: string): SectionDigest[] {
57
- return extractPlanSections(reviewText(content)).map((section) => ({
74
+ return extractSections(reviewText(content)).map((section) => ({
58
75
  title: section.title,
59
76
  digest: digestOf(section.text),
60
77
  }));
@@ -66,30 +83,40 @@ export function sameSections(a: readonly SectionDigest[], b: readonly SectionDig
66
83
  return a.every((section, index) => section.title === b[index].title && section.digest === b[index].digest);
67
84
  }
68
85
 
86
+ /** One section of the text as it reads now, and whether it differs from the baseline. */
87
+ export interface SectionMark {
88
+ /** The section, in the order the document lists it. */
89
+ section: PlanSection;
90
+ /** Whether its content differs from the baseline's section in the same place. */
91
+ changed: boolean;
92
+ }
93
+
69
94
  /**
70
- * Per-section change flags for a second review: `boolean[]` aligned with
71
- * `extractPlanSections(reviewText(current))`, so duplicate headings are flagged
72
- * by occurrence rather than by title. An absent baseline flags nothing, which is
73
- * what a first review of a plan looks like; `stripFeedback` only ever drops
74
- * quote-marker lines, so the flags stay aligned with the sections a screen
75
- * renders from the same content.
95
+ * Per-section change marks for a second review: every section of `current` with
96
+ * whether its content differs from the baseline's section in the same place.
97
+ *
98
+ * The marks carry their sections, so a caller cannot pair the flags with a list it
99
+ * parsed separately — the section list and its marks come out of one scan, in
100
+ * document order. Duplicate headings are told apart by occurrence rather than by
101
+ * title. An absent baseline marks nothing, which is what a first review of a plan
102
+ * looks like; `stripFeedback` only ever drops quote-marker lines, so the marks stay
103
+ * aligned with the sections a screen renders from the same content.
76
104
  */
77
- export function changedSections(baseline: readonly SectionDigest[] | undefined, current: string): boolean[] {
78
- const sections = extractPlanSections(reviewText(current));
79
- if (baseline === undefined) return sections.map(() => false);
105
+ export function changedSections(baseline: readonly SectionDigest[] | undefined, current: string): SectionMark[] {
106
+ const sections = extractSections(reviewText(current));
107
+ if (baseline === undefined) return sections.map((section) => ({ section, changed: false }));
80
108
 
81
- const before = new Map<string, string[]>();
82
- for (const section of baseline) {
83
- const digests = before.get(section.title);
84
- if (digests) digests.push(section.digest);
85
- else before.set(section.title, [section.digest]);
86
- }
109
+ // Same section across two reviews: `sectionKeys` tells a second `## Notes` from
110
+ // the first, so a duplicate heading is never compared with the wrong digest.
111
+ const before = new Map<string, string>();
112
+ sectionKeys(baseline).forEach((key, at) => {
113
+ const digest = baseline[at]?.digest;
114
+ if (digest !== undefined) before.set(key, digest);
115
+ });
87
116
 
88
- const seen = new Map<string, number>();
89
- return sections.map((section) => {
90
- const index = seen.get(section.title) ?? 0;
91
- seen.set(section.title, index + 1);
92
- const prior = before.get(section.title)?.[index];
93
- return prior === undefined || prior !== digestOf(section.text);
117
+ const keys = sectionKeys(sections);
118
+ return sections.map((section, at) => {
119
+ const prior = before.get(keys[at] ?? "");
120
+ return { section, changed: prior === undefined || prior !== digestOf(section.text) };
94
121
  });
95
122
  }
@@ -4,15 +4,19 @@
4
4
  * A plan is written to disk as markdown: one file per plan, named from the
5
5
  * title and stamp. A refinement rewrites that file atomically, so the user can
6
6
  * read the same artifact the model is working from and the plan survives
7
- * outside the conversation without a partial write ever being visible. Files
8
- * live under `<repo-root>/.pi/plans` (the worktree root inside a worktree), or
9
- * under `<agent-dir>/plans` outside a repository. An extension tool sees the
10
- * session cwd rather than the worktree the user entered, so the active worktree
11
- * is applied before any path is resolved.
7
+ * outside the conversation without a partial write ever being visible.
12
8
  *
13
- * The directory is self-ignoring: the first write drops a `.gitignore` with `*`
14
- * so plans never show up as untracked files or inside rewind snapshots,
15
- * without editing the project's own `.gitignore`.
9
+ * Plans live under `<agent-dir>/plans/<repo-key>`, keyed by the repository's
10
+ * common git directory, so every worktree and every session of one repository
11
+ * reads one store — a plan approved in a worktree is the same plan from the main
12
+ * checkout. Outside a repository they land under `<agent-dir>/plans`. An
13
+ * extension tool sees the session cwd rather than the worktree the user entered,
14
+ * so the active worktree is applied before any path is resolved.
15
+ *
16
+ * Plans are outside the project, so they are never untracked files and never
17
+ * appear in a rewind snapshot. A store written by an older version, under
18
+ * `<repo>/.pi/plans`, is moved in once, and nothing is deleted before its plans
19
+ * have arrived.
16
20
  *
17
21
  * This module owns paths and bytes only; tool wiring lives in `tools.ts`.
18
22
  */
@@ -25,29 +29,38 @@ import {
25
29
  readFileSync,
26
30
  readdirSync,
27
31
  renameSync,
32
+ rmdirSync,
28
33
  statSync,
29
34
  unlinkSync,
30
35
  writeFileSync,
31
36
  } from "node:fs";
32
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
37
+ import { homedir } from "node:os";
38
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
33
39
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
40
  import { resolveEffectiveCwd } from "../../lib/env.ts";
35
- import { repoRootFor } from "../../lib/git/index.ts";
36
- import { sanitizeLine } from "../../lib/format.ts";
41
+ import { sanitizeLine, shortenPath } from "../../lib/format.ts";
37
42
  import { isInside, isInsideReal } from "../../lib/path.ts";
38
- import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
43
+ import { repoKeyFor } from "../../lib/repo-key.ts";
44
+ import { carryFeedback, formatFeedback, normalizeAuthor, type PlanAnnotation } from "./feedback.ts";
39
45
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
40
46
 
41
47
  /** Largest plan accepted, in UTF-8 bytes. */
42
48
  export const MAX_PLAN_BYTES = 256 * 1024;
43
49
  /** Longest slug derived from a plan title. */
44
50
  const MAX_SLUG_LENGTH = 60;
51
+ /** The agent-directory entry plans are filed under. */
52
+ const PLANS_DIRNAME = "plans";
53
+
54
+ /** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
55
+ function plansDir(agentDir: string, key?: string): string {
56
+ return key === undefined ? join(agentDir, PLANS_DIRNAME) : join(agentDir, PLANS_DIRNAME, key);
57
+ }
45
58
 
46
59
  /** A plan read from disk. */
47
60
  export interface StoredPlan {
48
61
  /** Absolute path. */
49
62
  path: string;
50
- /** Path relative to the working directory, when it is inside it. */
63
+ /** How the path is shown to the user: `~`-shortened, since plans live outside the project. */
51
64
  relativePath: string;
52
65
  content: string;
53
66
  bytes: number;
@@ -62,7 +75,7 @@ export interface WrittenPlan extends StoredPlan {
62
75
  export interface PlanSummary {
63
76
  /** Absolute path. */
64
77
  path: string;
65
- /** Path relative to the working directory, when it is inside it. */
78
+ /** How the path is shown to the user: `~`-shortened, since plans live outside the project. */
66
79
  relativePath: string;
67
80
  /** The plan's `# title`, or a title derived from the file name. */
68
81
  title: string;
@@ -99,8 +112,20 @@ export interface PlanStore {
99
112
  index(cwd: string): Promise<PlanSummary[]>;
100
113
  /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
101
114
  setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
102
- /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
103
- saveFeedback(cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan>;
115
+ /** Replace the comments of `options.author` (the owner when absent) and keep every other writer's. */
116
+ saveFeedback(
117
+ cwd: string,
118
+ planPath: string,
119
+ annotations: PlanAnnotation[],
120
+ options?: { author?: string },
121
+ ): Promise<StoredPlan>;
122
+ /**
123
+ * The line a one-time migration wants reported, or undefined.
124
+ *
125
+ * A moved store is the user's business — plans are not where they used to be —
126
+ * so the first caller that can show a notice takes it and clears it.
127
+ */
128
+ takeNotice(): string | undefined;
104
129
  }
105
130
 
106
131
  /** Timestamp prefix the plan store adds to file names. */
@@ -155,16 +180,19 @@ function isContainedPlan(dir: string, path: string): boolean {
155
180
  return isInsideReal(dir, dirname(path));
156
181
  }
157
182
 
158
- /** Keep plan files out of git (and so out of rewind snapshots) without touching the project. */
159
- function ensureIgnored(dir: string): void {
160
- const ignore = join(dir, ".gitignore");
161
- try {
162
- // `wx` is race-free: an existing ignore file (ours or a user's) is left alone.
163
- writeFileSync(ignore, "*\n", { encoding: "utf-8", flag: "wx" });
164
- } catch {
165
- // A read-only plans directory will fail on the plan write anyway; the
166
- // ignore file is best-effort.
167
- }
183
+ /**
184
+ * Resolve a `plan_path` argument against the store.
185
+ *
186
+ * A plan lives outside the project, so a path relative to the working directory
187
+ * can no longer name one. Absolute paths and the `~/…` form the tools display are
188
+ * accepted, and a bare name resolves inside the store — which is what a reader of
189
+ * a tool result has in hand.
190
+ */
191
+ function resolvePlanArg(dir: string, planPath: string): string {
192
+ const text = planPath.trim();
193
+ if (text === "~") return homedir();
194
+ if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
195
+ return isAbsolute(text) ? resolve(text) : resolve(dir, text);
168
196
  }
169
197
 
170
198
  /**
@@ -188,12 +216,6 @@ function writeAtomic(path: string, content: string): void {
188
216
  }
189
217
  }
190
218
 
191
- /** `path` relative to `cwd` when inside it, else `path`. */
192
- function relativeTo(cwd: string, path: string): string {
193
- const rel = relative(cwd, path);
194
- return rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel : path;
195
- }
196
-
197
219
  function uniquePath(dir: string, base: string): string {
198
220
  const taken = (candidate: string): boolean => {
199
221
  try {
@@ -214,16 +236,69 @@ function uniquePath(dir: string, base: string): string {
214
236
  return candidate;
215
237
  }
216
238
 
217
- /** Repository (or worktree) root of `cwd`, or undefined outside a repository. */
218
- async function repoRoot(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
219
- // Local `rev-parse` is fast; cap it so a stuck call cannot hang the tool.
220
- return repoRootFor(pi, cwd, 5_000);
239
+ /** A repository (or worktree) root is applied before any path here is resolved. */
240
+
241
+ /** How many legacy plans one migration moves, so a pathological directory cannot stall a tool call. */
242
+ const MAX_MIGRATED_PLANS = 200;
243
+
244
+ /**
245
+ * Move a store written by an older version, under `<repo>/.pi/plans`, into `dir`.
246
+ *
247
+ * Copy-then-unlink when a rename crosses a filesystem boundary, so a store on a
248
+ * different volume still arrives. Nothing here deletes before its plan has
249
+ * landed, a file that cannot move is left where it is, and the old directory
250
+ * goes only when what remains is the ignore file this package used to write.
251
+ */
252
+ function migrateLegacyStore(dir: string, legacy: string): number {
253
+ if (!existsSync(legacy)) return 0;
254
+ let names: string[];
255
+ try {
256
+ names = readdirSync(legacy);
257
+ } catch {
258
+ return 0;
259
+ }
260
+ const plans = names.filter((name) => isPlanFileName(name)).slice(0, MAX_MIGRATED_PLANS);
261
+ let moved = 0;
262
+ for (const name of plans) {
263
+ const from = join(legacy, name);
264
+ try {
265
+ if (!statSync(from).isFile()) continue;
266
+ mkdirSync(dir, { recursive: true });
267
+ const to = uniquePath(dir, name.replace(/\.md$/i, ""));
268
+ try {
269
+ renameSync(from, to);
270
+ } catch {
271
+ writeFileSync(to, readFileSync(from), { flag: "wx" });
272
+ unlinkSync(from);
273
+ }
274
+ moved += 1;
275
+ } catch {
276
+ // A plan that cannot move stays where it is, and the directory stays with it.
277
+ }
278
+ }
279
+ try {
280
+ if (readdirSync(legacy).every((name) => name === ".gitignore")) {
281
+ const ignore = join(legacy, ".gitignore");
282
+ if (existsSync(ignore) && readFileSync(ignore, "utf-8") === "*\n") unlinkSync(ignore);
283
+ rmdirSync(legacy);
284
+ }
285
+ } catch {
286
+ // Leave a directory that still holds something.
287
+ }
288
+ return moved;
221
289
  }
222
290
 
223
291
  export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date } = {}): PlanStore {
224
292
  const now = options.now ?? (() => new Date());
225
293
  // One `git rev-parse` per effective root; a worktree switch changes that root.
226
- const dirCache = new Map<string, string>();
294
+ const dirCache = new Map<string, { dir: string; root: string | undefined }>();
295
+ let notice: string | undefined;
296
+
297
+ const takeNotice = (): string | undefined => {
298
+ const line = notice;
299
+ notice = undefined;
300
+ return line;
301
+ };
227
302
 
228
303
  /**
229
304
  * The root every path here is resolved against.
@@ -231,21 +306,29 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
231
306
  * A tool registered by an extension receives the session's cwd, not the
232
307
  * worktree the user entered: only the built-ins are re-rooted. The worktree
233
308
  * extension publishes its root in the environment, so this is where the two
234
- * meet, and it is applied before the git lookup, the relative path, and the
235
- * plan path resolution.
309
+ * meet, and it is applied before the repository lookup, the migration of an
310
+ * older store, and the plan path resolution.
236
311
  */
237
312
  const baseOf = (cwd: string): string => resolveEffectiveCwd(cwd);
238
313
 
239
- const dirFor = async (cwd: string): Promise<string> => {
314
+ const resolve = async (cwd: string): Promise<{ dir: string; root: string | undefined }> => {
240
315
  const base = baseOf(cwd);
241
316
  const cached = dirCache.get(base);
242
317
  if (cached) return cached;
243
- const root = await repoRoot(pi, base);
244
- const dir = root ? join(root, CONFIG_DIR_NAME, "plans") : join(getAgentDir(), "plans");
245
- dirCache.set(base, dir);
246
- return dir;
318
+ const repo = await repoKeyFor(pi, base);
319
+ const dir = plansDir(getAgentDir(), repo.fromGit ? repo.key : undefined);
320
+ dirCache.set(base, { dir, root: repo.fromGit ? repo.root : undefined });
321
+ if (repo.fromGit) {
322
+ // The keyed directory is the store; a store an older version left in the
323
+ // checkout moves in once, so an upgrade does not strand its plans.
324
+ const moved = migrateLegacyStore(dir, join(base, CONFIG_DIR_NAME, "plans"));
325
+ if (moved > 0) notice = `Moved ${moved} plan${moved === 1 ? "" : "s"} to ${shortenPath(dir)}.`;
326
+ }
327
+ return { dir, root: repo.fromGit ? repo.root : undefined };
247
328
  };
248
329
 
330
+ const dirFor = async (cwd: string): Promise<string> => (await resolve(cwd)).dir;
331
+
249
332
  const write = async (cwd: string, input: WritePlanInput): Promise<WrittenPlan> => {
250
333
  const base = baseOf(cwd);
251
334
  const title = input.title.trim();
@@ -257,11 +340,10 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
257
340
 
258
341
  const dir = await dirFor(base);
259
342
  mkdirSync(dir, { recursive: true });
260
- ensureIgnored(dir);
261
343
 
262
344
  let path: string;
263
345
  if (input.planPath) {
264
- const resolved = resolve(base, input.planPath);
346
+ const resolved = resolvePlanArg(dir, input.planPath);
265
347
  if (!isContainedPlan(dir, resolved)) {
266
348
  throw new Error("plan_path must be a markdown plan file inside the plans directory.");
267
349
  }
@@ -275,20 +357,30 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
275
357
  throw new Error("The plan path is not writable inside the plans directory.");
276
358
  }
277
359
 
278
- writeAtomic(path, input.content);
279
- return { path, relativePath: relativeTo(base, path), title, content: input.content, bytes };
360
+ // A refinement rewrites the body but not the review: every stored comment is
361
+ // carried onto the block whose text survived, so revising a plan under review
362
+ // does not silently discard what a teammate wrote.
363
+ const previous = existsSync(path) ? readFileSync(path, "utf-8") : undefined;
364
+ const content = previous === undefined ? carryFeedback("", input.content) : carryFeedback(previous, input.content);
365
+ const nextBytes = Buffer.byteLength(content, "utf-8");
366
+ if (nextBytes > MAX_PLAN_BYTES) {
367
+ throw new Error(`The plan is too large with its review comments (${nextBytes} bytes).`);
368
+ }
369
+
370
+ writeAtomic(path, content);
371
+ return { path, relativePath: shortenPath(path), title, content, bytes: nextBytes };
280
372
  };
281
373
 
282
374
  const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
283
375
  const base = baseOf(cwd);
284
376
  const dir = await dirFor(base);
285
- const resolved = resolve(base, planPath);
377
+ const resolved = resolvePlanArg(dir, planPath);
286
378
  if (!isContainedPlan(dir, resolved) || !existsSync(resolved)) return undefined;
287
379
  try {
288
380
  const content = readFileSync(resolved, "utf-8");
289
381
  return {
290
382
  path: resolved,
291
- relativePath: relativeTo(base, resolved),
383
+ relativePath: shortenPath(resolved),
292
384
  content,
293
385
  bytes: Buffer.byteLength(content, "utf-8"),
294
386
  };
@@ -333,14 +425,27 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
333
425
  };
334
426
 
335
427
  /**
336
- * Write the browser review comments into an existing plan, replacing any
337
- * feedback already stored there. The file must already be a contained plan,
338
- * so this is the same path-safe write as `setSteps`.
428
+ * Write the browser review comments into an existing plan, replacing the
429
+ * comments of `options.author` (the owner when absent) and leaving every other
430
+ * writer's in place. The file must already be a contained plan, so this is the
431
+ * same path-safe write as `setSteps`.
339
432
  */
340
- const saveFeedback = async (cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan> => {
433
+ const saveFeedback = async (
434
+ cwd: string,
435
+ planPath: string,
436
+ annotations: PlanAnnotation[],
437
+ options: { author?: string } = {},
438
+ ): Promise<StoredPlan> => {
341
439
  const plan = await read(cwd, planPath);
342
440
  if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
343
- const content = formatFeedback(plan.content, annotations);
441
+ const author = normalizeAuthor(options.author);
442
+ // An untagged entry is the writer's own; one tagged with a different name is
443
+ // another writer's comment, so it is dropped rather than re-signed.
444
+ const own = annotations.filter((annotation) => {
445
+ const who = normalizeAuthor(annotation.author);
446
+ return who === "" || who === author;
447
+ });
448
+ const content = formatFeedback(plan.content, own, { author });
344
449
  writeAtomic(plan.path, content);
345
450
  return {
346
451
  path: plan.path,
@@ -417,7 +522,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
417
522
 
418
523
  const summary: PlanSummary = {
419
524
  path,
420
- relativePath: relativeTo(base, path),
525
+ relativePath: shortenPath(path),
421
526
  title: planTitle(name),
422
527
  modified: info.mtimeMs,
423
528
  bytes: info.size,
@@ -448,5 +553,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
448
553
  return summaries;
449
554
  };
450
555
 
451
- return { dirFor, write, read, latest, index, setSteps, saveFeedback };
556
+ return { dirFor, write, read, latest, index, setSteps, saveFeedback, takeNotice };
452
557
  }
@@ -14,26 +14,30 @@
14
14
  */
15
15
 
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
- import { createReadOnlyPolicy, type ReadOnlyPolicy } from "../../lib/policy.ts";
18
- import { ASK_USER_QUESTION_TOOL, GOAL_TOOL, SUBAGENT_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
17
+ import { createReadOnlyPolicy, WORKSPACE_READERS, type ReadOnlyPolicy } from "../../lib/policy.ts";
18
+ import {
19
+ ASK_USER_QUESTION_TOOL,
20
+ GOAL_TOOL,
21
+ SUBAGENT_TOOL,
22
+ TODO_TOOL,
23
+ WEB_FETCH_TOOL,
24
+ WEB_SEARCH_TOOL,
25
+ } from "../../lib/tool-names.ts";
19
26
  import { ENTER_TOOL } from "./runtime.ts";
20
27
 
21
28
  /**
22
- * Tools that stay available while planning: structured readers, the trackers,
23
- * the git-only `bash`, and the known web/question readers. They are also the
24
- * tools the path backstop does not second-guess.
29
+ * Tools that stay available while planning: structured readers (documents
30
+ * included), the trackers, the git-only `bash`, and the known web/question
31
+ * readers. They are also the tools the path backstop does not second-guess.
25
32
  */
26
33
  export const PLAN_SAFE_TOOLS = [
27
- "read",
28
- "grep",
29
- "find",
30
- "ls",
34
+ ...WORKSPACE_READERS,
31
35
  TODO_TOOL,
32
36
  GOAL_TOOL,
33
37
  "bash",
34
38
  ASK_USER_QUESTION_TOOL,
35
- "web_search",
36
- "web_fetch",
39
+ WEB_SEARCH_TOOL,
40
+ WEB_FETCH_TOOL,
37
41
  ];
38
42
 
39
43
  /**
@@ -43,7 +47,11 @@ export const PLAN_SAFE_TOOLS = [
43
47
  * deliberately absent — it is default-denied like any other shell.
44
48
  */
45
49
  export const PLAN_ALLOWED_TOOLS = [...PLAN_SAFE_TOOLS, SUBAGENT_TOOL];
46
- /** Tools always blocked while planning, even when annotated read-only. */
50
+ /**
51
+ * Tools always blocked while planning, even when annotated read-only.
52
+ * `enter_plan_mode` is denied so it stays hidden while planning; the guard exempts
53
+ * plan's own registration so a call already in flight can still answer "already".
54
+ */
47
55
  const ALWAYS_BLOCKED_TOOLS = ["write", "edit", ENTER_TOOL];
48
56
 
49
57
  /** Shown when a tool call is refused while planning. */