@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
@@ -1,26 +1,33 @@
1
1
  /**
2
2
  * Inline review feedback for plan documents (pure).
3
3
  *
4
- * A browser comment is stored *in the plan file itself* as a blockquote on the
5
- * line directly after the block it refers to:
4
+ * A comment is stored *in the plan file itself* as a blockquote on the line
5
+ * directly after the block it refers to:
6
6
  *
7
7
  * - [ ] Add a token bucket
8
- * > [!REVIEW] what about the burst case?
8
+ * > [!REVIEW] @alice: what about the burst case?
9
+ * > [!REVIEW] name the ceiling too
9
10
  *
10
11
  * Placing it after a whole block (never inside a fence) keeps the markdown
11
- * valid and puts the comment next to the text the model must revise. Saving is
12
- * idempotent: {@link formatFeedback} strips every prior marker first, so
13
- * repeated saves replace rather than accumulate. The anchor is a source line in
14
- * the *stripped* plan, which is exactly what the browser rendered.
12
+ * valid and puts the comment next to the text the model must revise. The author
13
+ * tag names whoever wrote it: a tagged marker belongs to that reviewer, and an
14
+ * untagged one is the plan owner's. Every write is authored, so saving one
15
+ * person's comments replaces only their own and leaves the rest in place.
16
+ *
17
+ * The anchor is a source line in the *stripped* plan, which is exactly what the
18
+ * browser rendered. Saving is idempotent: {@link formatFeedback} strips the
19
+ * writer's prior markers first, so repeated saves replace rather than
20
+ * accumulate.
15
21
  */
16
22
 
17
23
  import { sanitizeLine } from "../../lib/format.ts";
18
- import { blockAt, planBlocks } from "./blocks.ts";
24
+ import { blockAt, contentBlockAt, planBlocks, type PlanBlock } from "./blocks.ts";
25
+ import { extractSections, sectionKeys } from "./sections.ts";
19
26
 
20
27
  /** Marker that opens every stored feedback line. */
21
28
  export const REVIEW_MARKER = "[!REVIEW]";
22
29
 
23
- /** A single browser comment, anchored to a source block. */
30
+ /** A single review comment, anchored to a source block. */
24
31
  export interface PlanAnnotation {
25
32
  /** 0-based first source line of the anchored block, in the stripped plan. */
26
33
  anchor: number;
@@ -28,6 +35,8 @@ export interface PlanAnnotation {
28
35
  quote?: string;
29
36
  /** The comment text. */
30
37
  comment: string;
38
+ /** Who wrote it; `""`/absent is the plan owner, the only writer before sharing. */
39
+ author?: string;
31
40
  }
32
41
 
33
42
  /** Most annotations one save may carry. */
@@ -36,14 +45,67 @@ export const MAX_ANNOTATIONS = 200;
36
45
  export const MAX_COMMENT_LENGTH = 2000;
37
46
  /** Longest retained selection quote. */
38
47
  export const MAX_QUOTE_LENGTH = 500;
48
+ /** Longest stored author name. */
49
+ export const MAX_AUTHOR_LENGTH = 40;
50
+
51
+ /** The name a reviewer write is stored under, or why it cannot be decided. */
52
+ export type AuthorClaim = { name: string } | { status: number; message: string };
39
53
 
40
- /** A stored feedback line, capturing its comment. */
54
+ /** A stored feedback line, capturing its author and comment. */
41
55
  const MARKER_LINE = /^\s*>\s*\[!REVIEW\]\s?(.*)$/;
56
+ /** The author prefix inside a marker body (`@alice: note`). */
57
+ const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
42
58
 
43
59
  /**
44
- * Source lines inside a fenced code block. A marker-looking line there is
45
- * documentation, not stored feedback, so it is never stripped or parsed.
60
+ * A reviewer name as it is stored and displayed: one sanitized line, no leading
61
+ * `@`, no `:` (which would end the author early in the marker grammar), capped.
62
+ * Anything unusable becomes `""`, the owner.
63
+ */
64
+ export function normalizeAuthor(raw: unknown): string {
65
+ if (typeof raw !== "string") return "";
66
+ const text = sanitizeLine(raw)
67
+ .replace(/^[\s@]+/, "")
68
+ .replace(/:/g, " ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ return text.slice(0, MAX_AUTHOR_LENGTH).trim();
72
+ }
73
+
74
+ /**
75
+ * Decide the name a reviewer's write is stored under.
76
+ *
77
+ * A bound link keeps its name: a payload that signs a different one is a stale or
78
+ * foreign page — a second tab of the same link, or a script — and re-signing it
79
+ * would move markers that belong to whoever picked that name. Before the first
80
+ * write the payload's own signature claims the name, unless another live link
81
+ * already holds it. An unsigned post claims nothing, so it is refused rather than
82
+ * binding a name to an accidental save.
46
83
  */
84
+ export function claimAuthor(
85
+ input: { bound?: string; taken?: readonly string[] },
86
+ annotations: readonly PlanAnnotation[],
87
+ ): AuthorClaim {
88
+ const signed = [...new Set(annotations.map((annotation) => normalizeAuthor(annotation.author)))].filter(Boolean);
89
+ if (input.bound !== undefined) {
90
+ if (signed.some((name) => name !== input.bound)) {
91
+ return {
92
+ status: 409,
93
+ message: `This link posts as @${input.bound}; reload the page to comment as @${input.bound}.`,
94
+ };
95
+ }
96
+ return { name: input.bound };
97
+ }
98
+ if (signed.length === 0) return { status: 400, message: "Choose a display name before commenting." };
99
+ if (signed.length > 1) return { status: 409, message: "A post signs one name; reload the page and post again." };
100
+ const name = signed[0] ?? "";
101
+ if ((input.taken ?? []).includes(name)) {
102
+ return { status: 400, message: "That name is already in use in this review." };
103
+ }
104
+ return { name };
105
+ }
106
+
107
+ /** Source lines inside a fenced code block. A marker-looking line there is
108
+ * documentation, not stored feedback, so it is never stripped or parsed. */
47
109
  function fencedLines(plan: string): Set<number> {
48
110
  const fenced = new Set<number>();
49
111
  for (const block of planBlocks(plan)) {
@@ -53,104 +115,386 @@ function fencedLines(plan: string): Set<number> {
53
115
  return fenced;
54
116
  }
55
117
 
56
- /** Remove every stored feedback line, leaving the plan body as authored. */
57
- export function stripFeedback(plan: string): string {
58
- const fenced = fencedLines(plan);
59
- return plan
60
- .split("\n")
61
- .filter((line, index) => fenced.has(index) || !MARKER_LINE.test(line))
62
- .join("\n");
118
+ /** A stored marker, with the clean-plan line it followed and who wrote it. */
119
+ interface StoredMarker {
120
+ /** Index in the clean plan of the line the marker followed; `-1` is the top. */
121
+ at: number;
122
+ comment: string;
123
+ author: string;
63
124
  }
64
125
 
65
- /**
66
- * Read stored feedback back. The anchor is the block ending just before each
67
- * marker in the stripped plan, so a comment keeps its place across reloads.
68
- */
69
- export function parseFeedback(plan: string): PlanAnnotation[] {
126
+ /** The plan body without markers, plus the markers that were removed from it. */
127
+ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
70
128
  const fenced = fencedLines(plan);
71
129
  const clean: string[] = [];
72
- const markers: Array<{ after: number; comment: string }> = [];
130
+ const markers: StoredMarker[] = [];
73
131
  plan.split("\n").forEach((line, index) => {
74
132
  const match = fenced.has(index) ? null : MARKER_LINE.exec(line);
75
- if (match) {
76
- const comment = match[1].trim();
77
- if (comment) markers.push({ after: clean.length, comment });
133
+ if (!match) {
134
+ clean.push(line);
78
135
  return;
79
136
  }
80
- clean.push(line);
137
+ const body = match[1].trim();
138
+ if (body === "") return;
139
+ const authored = AUTHOR_PREFIX.exec(body);
140
+ const author = authored ? normalizeAuthor(authored[1]) : "";
141
+ const comment = (authored ? authored[2] : body).trim();
142
+ if (comment) markers.push({ at: clean.length - 1, comment, author });
81
143
  });
82
- if (markers.length === 0) return [];
144
+ return { clean: clean.join("\n"), markers };
145
+ }
83
146
 
84
- const blocks = planBlocks(clean.join("\n"));
85
- return markers.map(({ after, comment }) => {
86
- const anchorLine = after - 1;
87
- const block = anchorLine >= 0 ? blockAt(blocks, anchorLine) : undefined;
88
- return { anchor: block ? block.start : 0, comment };
89
- });
147
+ /** One marker line in its stored form. */
148
+ function markerLine(author: string, comment: string): string {
149
+ const body = sanitizeLine(comment);
150
+ return author ? `> ${REVIEW_MARKER} @${author}: ${body}` : `> ${REVIEW_MARKER} ${body}`;
151
+ }
152
+
153
+ /** A marker waiting to be placed after clean-plan line `at`. */
154
+ interface Placed {
155
+ at: number;
156
+ line: string;
90
157
  }
91
158
 
92
159
  /**
93
- * Write `annotations` into `plan`, replacing any feedback already there. A
94
- * comment is placed after the whole anchored block, so a fenced code block is
95
- * never split and a list item gains its comment as a following quote.
160
+ * Rebuild `clean` with `entries` inserted after the lines they name, grouping
161
+ * several entries on one line in the order given. `-1` places at the top.
96
162
  */
97
- export function formatFeedback(plan: string, annotations: PlanAnnotation[]): string {
98
- const clean = stripFeedback(plan);
99
- const items = annotations
100
- .map((annotation) => ({ ...annotation, comment: sanitizeLine(annotation.comment) }))
101
- .filter((annotation) => annotation.comment.length > 0 && annotation.comment.length <= MAX_COMMENT_LENGTH);
102
- if (items.length === 0) return clean;
103
- if (clean === "") return items.map((annotation) => `> ${REVIEW_MARKER} ${annotation.comment}`).join("\n");
104
-
105
- const blocks = planBlocks(clean);
106
- // Insertion line -> marker lines, so several comments on one block stay grouped
107
- // in the order they were written.
163
+ function compose(clean: string, entries: Placed[]): string {
164
+ if (entries.length === 0) return clean;
165
+ if (clean === "") return entries.map((entry) => entry.line).join("\n");
108
166
  const buckets = new Map<number, string[]>();
109
- for (const annotation of items) {
110
- const block = blockAt(blocks, annotation.anchor);
111
- const at = block ? block.end : -1;
112
- const bucket = buckets.get(at) ?? [];
113
- bucket.push(`> ${REVIEW_MARKER} ${annotation.comment}`);
114
- buckets.set(at, bucket);
167
+ for (const entry of entries) {
168
+ const bucket = buckets.get(entry.at) ?? [];
169
+ bucket.push(entry.line);
170
+ buckets.set(entry.at, bucket);
115
171
  }
116
-
172
+ const lines = clean.split("\n");
117
173
  const out: string[] = [];
118
174
  const flush = (at: number): void => {
119
175
  const bucket = buckets.get(at);
120
176
  if (bucket) out.push(...bucket);
121
177
  };
122
178
  flush(-1);
123
- clean.split("\n").forEach((line, index) => {
179
+ lines.forEach((line, index) => {
124
180
  out.push(line);
125
181
  flush(index);
126
182
  });
183
+ // A marker placed past the last line still lands after it rather than nowhere.
184
+ flush(lines.length);
127
185
  return out.join("\n");
128
186
  }
129
187
 
188
+ /** Remove every stored feedback line, leaving the plan body as authored. */
189
+ export function stripFeedback(plan: string): string {
190
+ return scanMarkers(plan).clean;
191
+ }
192
+
193
+ /**
194
+ * A plan read the way the review reads it: its body, its blocks, and its
195
+ * comments, out of one parse.
196
+ */
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 {
215
+ const { clean, markers } = scanMarkers(plan);
216
+ const blocks = planBlocks(clean);
217
+ const annotations = markers.map((marker) => {
218
+ const annotation: PlanAnnotation = {
219
+ anchor: contentBlockAt(blocks, marker.at)?.start ?? 0,
220
+ comment: marker.comment,
221
+ };
222
+ if (marker.author) annotation.author = marker.author;
223
+ return annotation;
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;
231
+ }
232
+
233
+ /** Sanitized, non-empty comments with the anchor they land on. */
234
+ function storableComments(annotations: readonly PlanAnnotation[]): Array<{ anchor: number; comment: string }> {
235
+ const items: Array<{ anchor: number; comment: string }> = [];
236
+ for (const annotation of annotations) {
237
+ const comment = sanitizeLine(annotation.comment);
238
+ if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
239
+ items.push({ anchor: annotation.anchor, comment });
240
+ }
241
+ return items;
242
+ }
243
+
244
+ /**
245
+ * Write `annotations` into `plan` as `options.author`'s comments, replacing the
246
+ * markers that author already had and keeping every other writer's in place.
247
+ * With no author, the writer is the plan owner — the untagged markers.
248
+ */
249
+ export function formatFeedback(plan: string, annotations: PlanAnnotation[], options: { author?: string } = {}): string {
250
+ const author = normalizeAuthor(options.author);
251
+ const { clean, markers } = scanMarkers(plan);
252
+ const kept = markers
253
+ .filter((marker) => marker.author !== author)
254
+ .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment) }));
255
+ const blocks = planBlocks(clean);
256
+ const added = storableComments(annotations).map((item) => ({
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,
260
+ line: markerLine(author, item.comment),
261
+ }));
262
+ return compose(clean, [...kept, ...added]);
263
+ }
264
+
265
+ /** Text normalized the way an anchor match compares it. */
266
+ function matchingText(text: string): string {
267
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
268
+ }
269
+
270
+ /** Block text normalized for matching a comment's anchor across a rewrite. */
271
+ function blockKey(block: PlanBlock): string {
272
+ return matchingText(block.lines.join(" "));
273
+ }
274
+
275
+ /**
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.
346
+ */
347
+ function sectionIndex(plan: string, blocks: readonly PlanBlock[]): Map<string, { blocks: PlanBlock[]; end: number }> {
348
+ const sections = extractSections(plan);
349
+ const index = new Map<string, { blocks: PlanBlock[]; end: number }>();
350
+ const keys = sectionKeys(sections);
351
+ keys.forEach((key, at) => index.set(key, { blocks: [], end: sections[at]?.line ?? 0 }));
352
+
353
+ let position = 0;
354
+ for (const block of blocks) {
355
+ while (position + 1 < sections.length && (sections[position + 1]?.line ?? 0) <= block.start) position += 1;
356
+ const key = keys[position];
357
+ if (key === undefined) continue;
358
+ const entry = index.get(key);
359
+ if (!entry || block.kind === "blank") continue;
360
+ entry.blocks.push(block);
361
+ entry.end = block.end;
362
+ }
363
+ return index;
364
+ }
365
+
366
+ /**
367
+ * Carry the comments in `previous` onto `next`, the plan as it was rewritten.
368
+ * Comments are not plan content, so `next` is stripped of any marker it carries,
369
+ * and every old one is re-attached by the strongest evidence that survives: the
370
+ * block's text (per occurrence, so two identical steps keep their own comments),
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.
376
+ */
377
+ export function carryFeedback(previous: string, next: string): string {
378
+ const { clean: oldClean, markers } = scanMarkers(previous);
379
+ const cleanNext = stripFeedback(next);
380
+ if (markers.length === 0) return cleanNext;
381
+ const oldBlocks = planBlocks(oldClean);
382
+ const nextBlocks = planBlocks(cleanNext);
383
+ // Identical blocks queue up in document order, so the first unclaimed one takes
384
+ // the next comment rather than every comment landing on the last of them.
385
+ const queues = new Map<string, PlanBlock[]>();
386
+ for (const block of nextBlocks) {
387
+ if (block.kind === "blank") continue;
388
+ const key = blockKey(block);
389
+ const queue = queues.get(key);
390
+ if (queue) queue.push(block);
391
+ else queues.set(key, [block]);
392
+ }
393
+ const oldSections = sectionIndex(oldClean, oldBlocks);
394
+ const nextSections = sectionIndex(cleanNext, nextBlocks);
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[]>();
402
+
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
+ */
407
+ const bySection = (block: PlanBlock): number | undefined => {
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;
425
+ }
426
+ return undefined;
427
+ };
428
+
429
+ const placed = markers.map((marker) => {
430
+ const block = blockAt(oldBlocks, marker.at);
431
+ const line = markerLine(marker.author, marker.comment);
432
+ if (block === undefined) return { at: tail, line };
433
+ if (block.kind !== "blank") {
434
+ const match = queues.get(blockKey(block))?.shift();
435
+ if (match !== undefined) return { at: match.end, line };
436
+ }
437
+ return { at: bySection(block) ?? tail, line };
438
+ });
439
+ return compose(cleanNext, placed);
440
+ }
441
+
442
+ /**
443
+ * Show stored comments in place, as the quotes they are: `@alice: …` for a
444
+ * reviewer, `Review: …` for the owner. A terminal review, a dialog, and a phone
445
+ * prompt read the plan through this, so a comment is never invisible just
446
+ * because it is not the reader's own. Fenced code is opaque, as everywhere else.
447
+ */
448
+ export function displayFeedback(plan: string): string {
449
+ const fenced = fencedLines(plan);
450
+ return plan
451
+ .split("\n")
452
+ .map((line, index) => {
453
+ if (fenced.has(index)) return line;
454
+ const match = MARKER_LINE.exec(line);
455
+ if (!match) return line;
456
+ const body = match[1].trim();
457
+ if (body === "") return line;
458
+ const authored = AUTHOR_PREFIX.exec(body);
459
+ const author = authored ? normalizeAuthor(authored[1]) : "";
460
+ const comment = (authored ? authored[2] : body).trim();
461
+ if (!comment) return line;
462
+ return author ? `> @${author}: ${comment}` : `> Review: ${comment}`;
463
+ })
464
+ .join("\n");
465
+ }
466
+
130
467
  /** One-line quote for the model summary. */
131
468
  function shortQuote(quote: string): string {
132
469
  const clean = sanitizeLine(quote);
133
470
  return clean.length > 80 ? `${clean.slice(0, 79).trimEnd()}…` : clean;
134
471
  }
135
472
 
136
- /** Numbered, model-facing text for the comments a save carried. */
473
+ /** Numbered, model-facing text for the comments a review carried. */
137
474
  export function feedbackSummary(annotations: PlanAnnotation[]): string {
138
475
  const items = annotations
139
476
  .map((annotation) => ({
140
477
  quote: annotation.quote ? shortQuote(annotation.quote) : "",
478
+ // A name attached by a payload is data from another browser, not a label
479
+ // the plan owner chose, so it is normalized like every other author.
480
+ author: normalizeAuthor(annotation.author),
141
481
  comment: sanitizeLine(annotation.comment),
142
482
  }))
143
483
  .filter((item) => item.comment.length > 0);
144
484
  return items
145
- .map((item, index) => `${index + 1}. ${item.quote ? `On "${item.quote}": ` : ""}${item.comment}`)
485
+ .map((item, index) => {
486
+ const who = item.author ? `@${item.author}: ` : "";
487
+ const where = item.quote ? `On "${item.quote}": ` : "";
488
+ return `${index + 1}. ${who}${where}${item.comment}`;
489
+ })
146
490
  .join("\n");
147
491
  }
148
492
 
149
493
  /**
150
- * Validate an untrusted annotations payload from the browser. Returns a
494
+ * Validate an untrusted annotations payload from a browser. Returns a
151
495
  * normalized list, or `undefined` when the shape is wrong. Empty comments are
152
496
  * dropped rather than rejected, so saving after deleting every comment clears
153
- * the file's feedback.
497
+ * that writer's feedback.
154
498
  */
155
499
  export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined {
156
500
  if (!Array.isArray(value) || value.length > MAX_ANNOTATIONS) return undefined;
@@ -162,11 +506,13 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
162
506
  if (typeof anchor !== "number" || !Number.isInteger(anchor) || anchor < 0) return undefined;
163
507
  if (typeof record.comment !== "string") return undefined;
164
508
  if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
509
+ if (record.author !== undefined && typeof record.author !== "string") return undefined;
165
510
  const comment = sanitizeLine(record.comment);
166
511
  if (comment.length > MAX_COMMENT_LENGTH) return undefined;
167
512
  if (comment.length === 0) continue;
168
513
  const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
169
- annotations.push(quote ? { anchor, quote, comment } : { anchor, comment });
514
+ const author = normalizeAuthor(record.author);
515
+ annotations.push({ anchor, comment, ...(quote ? { quote } : {}), ...(author ? { author } : {}) });
170
516
  }
171
517
  return annotations;
172
518
  }
@@ -6,7 +6,7 @@
6
6
  * read-only git command, powershell is blocked, and everything else that mutates
7
7
  * (worktree, MCP tools) is blocked. `subagent` stays active but is forced
8
8
  * read-only. The one permitted write is `write_plan`,
9
- * which saves the plan under `.pi/plans`. The model investigates, saves the
9
+ * which saves the plan in the agent directory. The model investigates, saves the
10
10
  * plan, and calls `exit_plan_mode` to read the file back and ask for approval.
11
11
  *
12
12
  * Toggle with `/plan`, `Ctrl+Alt+P`, or start with `--plan`. The state is
@@ -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