@gavin-hu/my-pi-agent 0.18.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 (249) hide show
  1. package/README.md +52 -44
  2. package/README.zh-CN.md +46 -41
  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} +446 -404
  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} +61 -50
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
  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 +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  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} +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} +258 -162
  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/panel/commands.ts +40 -7
  51. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  52. package/extensions/bytefly/panel/settings.ts +3 -20
  53. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  54. package/extensions/bytefly/picker/discovery.ts +0 -2
  55. package/extensions/bytefly/picker/types.ts +0 -2
  56. package/extensions/bytefly/update/check.ts +39 -0
  57. package/extensions/bytefly/update/format.ts +15 -0
  58. package/extensions/bytefly/update/register.ts +73 -34
  59. package/extensions/bytefly/update/state.ts +6 -3
  60. package/extensions/context/tui.ts +1 -1
  61. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  62. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  63. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  64. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  65. package/extensions/file-browse/index.ts +4 -4
  66. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  67. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
  68. package/extensions/file-browse/transport/server.ts +110 -0
  69. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  70. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  71. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  72. package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
  73. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  74. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  75. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  76. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  77. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  78. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  79. package/extensions/file-read/schema.ts +2 -1
  80. package/extensions/memory/store.ts +12 -5
  81. package/extensions/plan/blocks.ts +23 -2
  82. package/extensions/plan/commands.ts +21 -16
  83. package/extensions/plan/feedback.ts +147 -38
  84. package/extensions/plan/index.ts +6 -4
  85. package/extensions/plan/plan-index.ts +43 -9
  86. package/extensions/plan/plan-text.ts +33 -23
  87. package/extensions/plan/plans.ts +8 -11
  88. package/extensions/plan/policy.ts +20 -12
  89. package/extensions/plan/review.ts +3 -27
  90. package/extensions/plan/runtime.ts +21 -9
  91. package/extensions/plan/sections.ts +112 -0
  92. package/extensions/plan/steps.ts +3 -22
  93. package/extensions/plan/tools/exit.ts +11 -95
  94. package/extensions/plan/tui.ts +5 -34
  95. package/extensions/plan/types.ts +0 -8
  96. package/extensions/plan/web-client.ts +1 -1
  97. package/extensions/plan/web-page.ts +0 -7
  98. package/extensions/plan/web-router.ts +3 -57
  99. package/extensions/plan/web-share-session.ts +24 -30
  100. package/extensions/plan/web-streams.ts +14 -14
  101. package/extensions/plan/web-tokens.ts +2 -2
  102. package/extensions/plan/web.ts +66 -120
  103. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  104. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  105. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  106. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  107. package/extensions/rewind/index.ts +3 -3
  108. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  109. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  110. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  111. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  112. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  113. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  114. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  115. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  116. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  117. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  118. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  119. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  120. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  121. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  122. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  123. package/extensions/session-bus/commands.ts +17 -69
  124. package/extensions/session-bus/format.ts +22 -45
  125. package/extensions/session-bus/index.ts +13 -7
  126. package/extensions/session-bus/join-request.ts +40 -0
  127. package/extensions/session-bus/mail-watch.ts +134 -0
  128. package/extensions/session-bus/notice.ts +32 -4
  129. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  130. package/extensions/session-bus/protocol.ts +19 -49
  131. package/extensions/session-bus/resolve.ts +67 -0
  132. package/extensions/session-bus/result.ts +16 -51
  133. package/extensions/session-bus/rows.ts +4 -2
  134. package/extensions/session-bus/runtime.ts +129 -238
  135. package/extensions/session-bus/store.ts +42 -6
  136. package/extensions/session-bus/tools.ts +31 -66
  137. package/extensions/session-bus/tui.ts +4 -4
  138. package/extensions/session-bus/types.ts +13 -13
  139. package/extensions/subagent/agents.ts +9 -8
  140. package/extensions/subagent/invocation.ts +4 -2
  141. package/extensions/team/commands.ts +112 -0
  142. package/extensions/team/dispatch.ts +133 -0
  143. package/extensions/team/format.ts +105 -0
  144. package/extensions/team/index.ts +33 -0
  145. package/extensions/team/schema.ts +29 -0
  146. package/extensions/team/tools.ts +246 -0
  147. package/extensions/team/types.ts +48 -0
  148. package/extensions/web-access/fetch/page.ts +4 -1
  149. package/extensions/web-access/fetch/structured.ts +447 -0
  150. package/extensions/web-access/fetch/tool.ts +2 -1
  151. package/extensions/web-access/search/tool.ts +2 -1
  152. package/extensions/wechat/bridge.ts +76 -92
  153. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  154. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  155. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  156. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  157. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  158. package/extensions/wechat/dialog/channels.ts +136 -0
  159. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  160. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  161. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  162. package/extensions/wechat/index.ts +10 -10
  163. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  164. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  165. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  166. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  167. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  168. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  169. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  170. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  171. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  172. package/extensions/wechat/tools.ts +29 -12
  173. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  174. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  175. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  176. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  177. package/extensions/wechat/transport/outbound.ts +142 -0
  178. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  179. package/extensions/wechat/types.ts +10 -1
  180. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  181. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  182. package/extensions/worktree/guard.ts +3 -5
  183. package/lib/README.md +8 -6
  184. package/lib/board/card.ts +290 -0
  185. package/lib/board/links.ts +117 -0
  186. package/lib/board/page-pointer.ts +256 -0
  187. package/lib/board/round-text.ts +72 -0
  188. package/lib/board/round.ts +89 -0
  189. package/lib/board/rules.ts +304 -0
  190. package/lib/board/session.ts +105 -0
  191. package/lib/board/store.ts +310 -0
  192. package/lib/board/view.ts +150 -0
  193. package/lib/errors.ts +12 -4
  194. package/lib/format.ts +13 -0
  195. package/lib/http/guard.ts +112 -0
  196. package/lib/http/index.ts +16 -5
  197. package/lib/http/serve.ts +234 -0
  198. package/lib/http/server.ts +12 -10
  199. package/lib/http/sink.ts +110 -0
  200. package/lib/http/token.ts +38 -0
  201. package/lib/policy.ts +12 -2
  202. package/lib/requirements.ts +6 -7
  203. package/lib/session-label.ts +64 -0
  204. package/lib/session-roster.ts +130 -0
  205. package/lib/session-tool.ts +184 -0
  206. package/lib/tool-names.ts +25 -2
  207. package/lib/tui/glyphs.ts +2 -0
  208. package/lib/web/README.md +5 -2
  209. package/lib/web/components.ts +5 -0
  210. package/lib/web/runtime.ts +7 -1
  211. package/lib/web/tokens.ts +25 -2
  212. package/package.json +26 -14
  213. package/skills/next/SKILL.md +4 -3
  214. package/skills/project-docs/SKILL.md +133 -0
  215. package/skills/project-docs/references/audit.md +131 -0
  216. package/skills/project-docs/references/changelog.md +105 -0
  217. package/skills/project-docs/references/discovery.md +127 -0
  218. package/skills/project-docs/references/readme.md +80 -0
  219. package/skills/project-docs/references/release.md +84 -0
  220. package/skills/project-docs/references/roadmap.md +93 -0
  221. package/skills/project-docs/scripts/state.ts +316 -0
  222. package/skills/refactor/SKILL.md +100 -125
  223. package/skills/refactor/references/handoff.md +35 -0
  224. package/skills/refactor/references/signals.md +54 -0
  225. package/skills/refactor/scripts/inventory.ts +587 -0
  226. package/extensions/file-browse/README.md +0 -506
  227. package/extensions/file-browse/server.ts +0 -141
  228. package/extensions/plan/split.ts +0 -87
  229. package/extensions/session-bus/README.md +0 -247
  230. package/extensions/session-bus/team.ts +0 -102
  231. package/extensions/wechat/outbound.ts +0 -100
  232. package/lib/plans/sections.ts +0 -172
  233. package/lib/plans/setting.ts +0 -52
  234. package/lib/plans/split.ts +0 -215
  235. package/lib/plans/store.ts +0 -19
  236. package/lib/plans/team.ts +0 -151
  237. package/skills/changelog/SKILL.md +0 -148
  238. package/skills/parallel-dev/SKILL.md +0 -150
  239. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  240. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  241. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  242. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  243. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  244. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  245. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  246. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  247. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  248. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  249. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -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]";
@@ -191,19 +191,43 @@ export function stripFeedback(plan: string): string {
191
191
  }
192
192
 
193
193
  /**
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.
194
+ * A plan read the way the review reads it: its body, its blocks, and its
195
+ * comments, out of one parse.
196
196
  */
197
- export function parseFeedback(plan: string): PlanAnnotation[] {
197
+ export interface FeedbackRead {
198
+ /** The plan body with every stored marker removed. */
199
+ body: string;
200
+ /** The body split into source-line blocks: the shape an anchor names. */
201
+ blocks: PlanBlock[];
202
+ /** The comments the markers carried, each anchored to a content block's start. */
203
+ annotations: PlanAnnotation[];
204
+ }
205
+
206
+ /**
207
+ * Read a plan once. An anchor is the start of the block the marker follows, so a
208
+ * comment keeps its place across reloads — and it is always a block with
209
+ * content, because the page renders no line for a blank one and so can never
210
+ * attach a thread to it. A plan whose every line is blank has no such block: its
211
+ * comments keep anchor `0` and the page lists them as unattached, which is the
212
+ * truth there.
213
+ */
214
+ export function readFeedback(plan: string): FeedbackRead {
198
215
  const { clean, markers } = scanMarkers(plan);
199
- if (markers.length === 0) return [];
200
216
  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 };
217
+ const annotations = markers.map((marker) => {
218
+ const annotation: PlanAnnotation = {
219
+ anchor: contentBlockAt(blocks, marker.at)?.start ?? 0,
220
+ comment: marker.comment,
221
+ };
204
222
  if (marker.author) annotation.author = marker.author;
205
223
  return annotation;
206
224
  });
225
+ return { body: clean, blocks, annotations };
226
+ }
227
+
228
+ /** The comments a plan carries, in the order they were written. */
229
+ export function parseFeedback(plan: string): PlanAnnotation[] {
230
+ return readFeedback(plan).annotations;
207
231
  }
208
232
 
209
233
  /** Sanitized, non-empty comments with the anchor they land on. */
@@ -230,7 +254,9 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
230
254
  .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
231
255
  const blocks = planBlocks(clean);
232
256
  const added = storableComments(annotations).map((item) => ({
233
- at: blockAt(blocks, item.anchor)?.end ?? -1,
257
+ // A marker beside a block, never after a blank line: an anchor that names
258
+ // blank space is written where the page can read it back as a thread.
259
+ at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
234
260
  line: markerLine(author, item.comment),
235
261
  }));
236
262
  return compose(clean, [...kept, ...added]);
@@ -247,30 +273,92 @@ function blockKey(block: PlanBlock): string {
247
273
  }
248
274
 
249
275
  /**
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.
276
+ * Words a block is compared by when it may merely have been reworded: lowercased,
277
+ * with the list marker and task checkbox dropped. Every step of a `## Steps`
278
+ * section would otherwise match every other one through its `- [ ]` alone.
279
+ */
280
+ function blockWords(block: PlanBlock): Set<string> {
281
+ const text = block.lines
282
+ .join(" ")
283
+ .toLowerCase()
284
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, " ")
285
+ .replace(/\[[ x]\]/g, " ");
286
+ return new Set(text.split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ""));
287
+ }
288
+
289
+ /** Share of the longer block's words that both blocks have. */
290
+ function sharedShare(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
291
+ if (a.size === 0 || b.size === 0) return 0;
292
+ let shared = 0;
293
+ for (const word of a) if (b.has(word)) shared += 1;
294
+ return shared / Math.max(a.size, b.size);
295
+ }
296
+
297
+ /** Share of its words a block keeps, at or above which it is the same block reworded. */
298
+ const MATCH_SHARE = 0.5;
299
+
300
+ /**
301
+ * Which block of `next` each block of `old` became, keeping both lists in
302
+ * document order: the longest set of pairs whose wording overlaps enough to be one
303
+ * block reworded. A block left without a pair is `-1` — deleted, or replaced by
304
+ * text that shares nothing — which is the gap its comment belongs in.
305
+ */
306
+ function alignBlocks(old: readonly PlanBlock[], next: readonly PlanBlock[]): number[] {
307
+ const before = old.map(blockWords);
308
+ const after = next.map(blockWords);
309
+ const matches = before.map((words) => after.map((other) => (sharedShare(words, other) >= MATCH_SHARE ? 1 : 0)));
310
+ // A longest common subsequence over the match grid, so a block that moved keeps
311
+ // its own pairing instead of pulling the blocks around it along.
312
+ const width = next.length + 1;
313
+ const counts = new Array<number>((old.length + 1) * width).fill(0);
314
+ for (let row = old.length - 1; row >= 0; row--) {
315
+ for (let column = next.length - 1; column >= 0; column--) {
316
+ const paired = matches[row][column] === 1 ? 1 + counts[(row + 1) * width + column + 1] : 0;
317
+ counts[row * width + column] = Math.max(
318
+ paired,
319
+ counts[(row + 1) * width + column],
320
+ counts[row * width + column + 1],
321
+ );
322
+ }
323
+ }
324
+ const aligned = new Array<number>(old.length).fill(-1);
325
+ let row = 0;
326
+ let column = 0;
327
+ while (row < old.length && column < next.length) {
328
+ if (matches[row][column] === 1 && counts[row * width + column] === 1 + counts[(row + 1) * width + column + 1]) {
329
+ aligned[row] = column;
330
+ row += 1;
331
+ column += 1;
332
+ continue;
333
+ }
334
+ if (counts[(row + 1) * width + column] >= counts[row * width + column + 1]) row += 1;
335
+ else column += 1;
336
+ }
337
+ return aligned;
338
+ }
339
+
340
+ /**
341
+ * A plan's heading sections, keyed by `sectionKeys`, each with the blocks a
342
+ * comment can hang on and the last line of that content. Blank lines are left out
343
+ * on purpose: a position among them would move when one is added or removed
344
+ * above, and a reworded step would lose its comment to the next block. A section
345
+ * with no content block ends at its own heading, which the page renders.
254
346
  */
255
347
  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>();
348
+ const sections = extractSections(plan);
259
349
  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
- });
350
+ const keys = sectionKeys(sections);
351
+ keys.forEach((key, at) => index.set(key, { blocks: [], end: sections[at]?.line ?? 0 }));
267
352
 
268
353
  let position = 0;
269
354
  for (const block of blocks) {
270
355
  while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
271
356
  const key = keys[position];
272
357
  if (key === undefined) continue;
273
- index.get(key)?.blocks.push(block);
358
+ const entry = index.get(key);
359
+ if (!entry || block.kind === "blank") continue;
360
+ entry.blocks.push(block);
361
+ entry.end = block.end;
274
362
  }
275
363
  return index;
276
364
  }
@@ -280,9 +368,11 @@ function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, {
280
368
  * Comments are not plan content, so `next` is stripped of any marker it carries,
281
369
  * and every old one is re-attached by the strongest evidence that survives: the
282
370
  * 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.
371
+ * then the block the alignment says it became when the wording changed, then the
372
+ * gap it left between the blocks that survived, then the end of that section's
373
+ * content. A comment whose section is gone too moves to the plan's last content
374
+ * block, so the page keeps showing it under a block rather than listing it as
375
+ * unattached; only a plan with no block at all leaves it in that list.
286
376
  */
287
377
  export function carryFeedback(previous: string, next: string): string {
288
378
  const { clean: oldClean, markers } = scanMarkers(previous);
@@ -303,16 +393,35 @@ export function carryFeedback(previous: string, next: string): string {
303
393
  const oldSections = sectionIndex(oldClean, oldBlocks);
304
394
  const nextSections = sectionIndex(cleanNext, nextBlocks);
305
395
  const lastLine = cleanNext === "" ? -1 : cleanNext.split("\n").length - 1;
396
+ // Where a comment goes when nothing about it survived: beside the plan's last
397
+ // block, never after the blank line a trailing newline leaves.
398
+ const tail = contentBlockAt(nextBlocks, lastLine)?.end ?? lastLine;
399
+
400
+ /** One alignment per section, reused by every comment that lands in it. */
401
+ const alignments = new Map<string, number[]>();
306
402
 
307
- /** The block at the same ordinal in the same section, else that section's end. */
403
+ /**
404
+ * Where a comment goes when its own block's text is gone: the block the alignment
405
+ * says it became, else the gap it left, else its section's end.
406
+ */
308
407
  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;
408
+ for (const [key, before] of oldSections) {
409
+ const index = before.blocks.indexOf(block);
410
+ if (index < 0) continue;
411
+ const after = nextSections.get(key);
412
+ if (after === undefined) return undefined;
413
+ let aligned = alignments.get(key);
414
+ if (aligned === undefined) {
415
+ aligned = alignBlocks(before.blocks, after.blocks);
416
+ alignments.set(key, aligned);
417
+ }
418
+ const became = aligned[index];
419
+ if (became >= 0) return after.blocks[became].end;
420
+ // The gap it left is right after the block before its nearest surviving
421
+ // successor: for the first block of a section, that is the heading.
422
+ const successor = aligned.findIndex((at, from) => from > index && at >= 0);
423
+ const gap = successor >= 0 ? after.blocks[aligned[successor] - 1] : undefined;
424
+ return gap?.end ?? after.end;
316
425
  }
317
426
  return undefined;
318
427
  };
@@ -320,12 +429,12 @@ export function carryFeedback(previous: string, next: string): string {
320
429
  const placed = markers.map((marker) => {
321
430
  const block = blockAt(oldBlocks, marker.at);
322
431
  const line = markerLine(marker.author, marker.comment);
323
- if (block === undefined) return { at: lastLine, line };
432
+ if (block === undefined) return { at: tail, line };
324
433
  if (block.kind !== "blank") {
325
434
  const match = queues.get(blockKey(block))?.shift();
326
435
  if (match !== undefined) return { at: match.end, line };
327
436
  }
328
- return { at: bySection(block) ?? lastLine, line };
437
+ return { at: bySection(block) ?? tail, line };
329
438
  });
330
439
  return compose(cleanNext, placed);
331
440
  }
@@ -149,10 +149,12 @@ export default function planMode(pi: ExtensionAPI): void {
149
149
  * the path backstop (a hint on a path-carrying tool is only a claim).
150
150
  */
151
151
  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;
152
+ // Plan-owned tools are the sanctioned exception: `write_plan` saves the
153
+ // artifact, `exit_plan_mode` presents it, `cancel_plan_mode` restores access,
154
+ // and `enter_plan_mode` answers "already" to a call that was in flight when
155
+ // plan mode came on. The source-path check keeps a same-named tool from
156
+ // another extension from borrowing the exemption.
157
+ if (runtime.isOwnTool(toolName)) return undefined;
156
158
  // `subagent` stays active, but only as read-only delegation. The flag is
157
159
  // forced in place (the child enforces it through `--tools`), so the model
158
160
  // cannot forget it; a `subagent` that does not declare the parameter is
@@ -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,8 +230,14 @@ 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) {
@@ -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
  }
@@ -16,7 +16,7 @@
16
16
  import { createHash } from "node:crypto";
17
17
  import { stripControlChars } from "../../lib/format.ts";
18
18
  import { displayFeedback, stripFeedback } from "./feedback.ts";
19
- import { extractPlanSections } from "./steps.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;
@@ -71,7 +71,7 @@ export function planDigest(text: string): string {
71
71
 
72
72
  /** One digest per heading section, in document order, for a persisted baseline. */
73
73
  export function sectionDigests(content: string): SectionDigest[] {
74
- return extractPlanSections(reviewText(content)).map((section) => ({
74
+ return extractSections(reviewText(content)).map((section) => ({
75
75
  title: section.title,
76
76
  digest: digestOf(section.text),
77
77
  }));
@@ -83,30 +83,40 @@ export function sameSections(a: readonly SectionDigest[], b: readonly SectionDig
83
83
  return a.every((section, index) => section.title === b[index].title && section.digest === b[index].digest);
84
84
  }
85
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
+
86
94
  /**
87
- * Per-section change flags for a second review: `boolean[]` aligned with
88
- * `extractPlanSections(reviewText(current))`, so duplicate headings are flagged
89
- * by occurrence rather than by title. An absent baseline flags nothing, which is
90
- * what a first review of a plan looks like; `stripFeedback` only ever drops
91
- * quote-marker lines, so the flags stay aligned with the sections a screen
92
- * 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.
93
104
  */
94
- export function changedSections(baseline: readonly SectionDigest[] | undefined, current: string): boolean[] {
95
- const sections = extractPlanSections(reviewText(current));
96
- 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 }));
97
108
 
98
- const before = new Map<string, string[]>();
99
- for (const section of baseline) {
100
- const digests = before.get(section.title);
101
- if (digests) digests.push(section.digest);
102
- else before.set(section.title, [section.digest]);
103
- }
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
+ });
104
116
 
105
- const seen = new Map<string, number>();
106
- return sections.map((section) => {
107
- const index = seen.get(section.title) ?? 0;
108
- seen.set(section.title, index + 1);
109
- const prior = before.get(section.title)?.[index];
110
- 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) };
111
121
  });
112
122
  }
@@ -40,7 +40,6 @@ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works
40
40
  import { resolveEffectiveCwd } from "../../lib/env.ts";
41
41
  import { sanitizeLine, shortenPath } from "../../lib/format.ts";
42
42
  import { isInside, isInsideReal } from "../../lib/path.ts";
43
- import { plansDir } from "../../lib/plans/store.ts";
44
43
  import { repoKeyFor } from "../../lib/repo-key.ts";
45
44
  import { carryFeedback, formatFeedback, normalizeAuthor, type PlanAnnotation } from "./feedback.ts";
46
45
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
@@ -49,6 +48,13 @@ import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } f
49
48
  export const MAX_PLAN_BYTES = 256 * 1024;
50
49
  /** Longest slug derived from a plan title. */
51
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
+ }
52
58
 
53
59
  /** A plan read from disk. */
54
60
  export interface StoredPlan {
@@ -94,13 +100,6 @@ export interface WritePlanInput {
94
100
  export interface PlanStore {
95
101
  /** Directory plans are written to for `cwd`. */
96
102
  dirFor(cwd: string): Promise<string>;
97
- /**
98
- * The repository root `cwd` belongs to, or undefined outside a repository.
99
- *
100
- * A repository-scoped setting — the split switch — is read at the root, so a
101
- * session started in a subdirectory still sees what the project turned on.
102
- */
103
- rootFor(cwd: string): Promise<string | undefined>;
104
103
  write(cwd: string, input: WritePlanInput): Promise<WrittenPlan>;
105
104
  /** Read a plan inside the plans directory, or undefined when missing/outside. */
106
105
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
@@ -330,8 +329,6 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
330
329
 
331
330
  const dirFor = async (cwd: string): Promise<string> => (await resolve(cwd)).dir;
332
331
 
333
- const rootFor = async (cwd: string): Promise<string | undefined> => (await resolve(cwd)).root;
334
-
335
332
  const write = async (cwd: string, input: WritePlanInput): Promise<WrittenPlan> => {
336
333
  const base = baseOf(cwd);
337
334
  const title = input.title.trim();
@@ -556,5 +553,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
556
553
  return summaries;
557
554
  };
558
555
 
559
- return { dirFor, rootFor, write, read, latest, index, setSteps, saveFeedback, takeNotice };
556
+ return { dirFor, write, read, latest, index, setSteps, saveFeedback, takeNotice };
560
557
  }