@gavin-hu/my-pi-agent 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -7,73 +7,124 @@ exit, with cleanup that refuses to throw work away. The model keeps calling
7
7
  root changes. It is one of two sibling git extensions in this package
8
8
  (`rewind` is the other).
9
9
 
10
+ ## Capabilities
11
+
12
+ Status records what a reader can rely on: **Shipped** is in a tagged release, **Unreleased** is complete in the current tree but not yet tagged, and **Partial** has one released part and one unreleased part. Rows are ordered by user value, highest first.
13
+
14
+ ### Isolating work
15
+
16
+ | Capability | How | What you get | Status |
17
+ |---|---|---|---|
18
+ | Isolated checkout | `enter_worktree`, `/worktree enter`, `--worktree` | A new checkout at `<repo>/.pi/worktrees/<name>` on branch `worktree-<name>`, cut from `origin/main`. | Shipped |
19
+ | Root-following built-ins | `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls` | The built-in tools keep taking ordinary relative paths, resolved inside the worktree. | Shipped |
20
+ | Escape refusal | `guard.blockFileEscapes`, `guard.blockGitRedirects`, `guard.blockSymlinkEscapes` | Writes, `cd`, and `git` redirects that reach the main checkout are refused, symlink escapes included. | Shipped |
21
+ | Plan-mode refusal | `/worktree`, `enter_worktree` | In plan mode the screen opens read-only and every verb refuses until the plan is approved. | Shipped |
22
+ | PowerShell refusal | `powershell` while isolated | The unparseable shell is refused instead of becoming an unguarded escape. | Shipped |
23
+ | Seeded working copy | `.worktreeinclude`, `include` | Gitignored files such as `.env` are copied into each new worktree. | Shipped |
24
+ | Ignore bootstrap | `enter_worktree` | The first worktree offers to add `.pi/worktrees` to `.gitignore`, so the checkout does not pollute status. | Shipped |
25
+ | PR/MR checkout | `enter_worktree` `name`, `--worktree` | A pull or merge request's head is fetched and checked out as `pr-<n>`. | Shipped |
26
+ | Child sessions | `PI_WORKTREE_ROOT`, `PI_WORKTREE_BRANCH`, `PI_WORKTREE_MAIN` | A `pi` started from a tool binds to the same worktree, read-only for lifecycle. | Shipped |
27
+
28
+ ### Returning and managing
29
+
30
+ | Capability | How | What you get | Status |
31
+ |---|---|---|---|
32
+ | Return with a handoff | `exit_worktree`, `/worktree exit` | Back in the main checkout, with the branch, its commits, and the merge or PR command that finishes them. | Shipped |
33
+ | Work that survives | `exit_worktree`, `worktree.onExit` | A worktree holding changes or commits is asked about or kept, never force-deleted. | Shipped |
34
+ | Status inventory | `list_worktrees`, `/worktree` | The active root, the gaps isolation does not cover, and every managed worktree with its state. | Shipped |
35
+ | Worktree screen | `/worktree` | One list screen with the focused worktree's path, base, and gaps, and the verbs to act on it. | Shipped |
36
+ | Cleanup | `prune_worktrees`, `/worktree prune` | Clean, unused, old worktrees are removed; every other one is kept with a reason. | Shipped |
37
+ | Concurrent-session safety | `enter_worktree`, `prune_worktrees` | A worktree in use is locked, so another session's sweep cannot remove it. | Shipped |
38
+ | Session restore | `/resume`, `--continue` | An active worktree is restored from the branch, or reported gone. | Shipped |
39
+ | Extension signal | `worktree:changed` | Another extension follows the active root without polling. | Shipped |
40
+ | Doctor finding | `run_doctor` | A degraded `guard.blockPowershell` setting is named in the health report instead of only at use. | Shipped |
41
+
42
+ ## Quickstart
43
+
10
44
  ```bash
11
45
  pi --extension ./extensions/worktree # load just this extension
12
46
  pi -e . # load the whole @gavin-hu/my-pi-agent package
13
47
  pi install ./ # install the package
14
- pi -e . --worktree feature-auth # load the package and enter a worktree at startup
15
48
  ```
16
49
 
17
- ## Tool rendering
50
+ ```text
51
+ /worktree # the list screen, or the same facts as text
52
+ ```
18
53
 
19
- How the `list_worktrees` call and its result look in the transcript — the call line as a headline and the body indented under it:
54
+ The model enters with `enter_worktree`; `pi -e . --worktree feature-auth` starts
55
+ the session already isolated.
56
+
57
+ ## Tools
20
58
 
21
59
  <img src="../../assets/tools/list_worktrees.png" alt="The list_worktrees tool's call and result, drawn by the generic fallback" width="100%">
22
60
 
23
- ## The `/worktree` screen
61
+ `enter_worktree` creates or enters a checkout, `exit_worktree` returns to the
62
+ main checkout, and `prune_worktrees` and `list_worktrees` manage and report. All
63
+ four are `executionMode: "sequential"`, and only `list_worktrees` is read-only.
24
64
 
25
- <img src="../../assets/worktree.png" alt="The /worktree screen: a Worktrees title rule with a `4 worktrees · 1 dirty · 1 ahead` summary, the current dark-mode row carrying its ahead count, a dirty row, a merged row, and a stale-locked row, the focused row's path and base with the Not isolated gap under it, and the key hints" width="100%">
65
+ | Tool | Usage |
66
+ |---|---|
67
+ | `enter_worktree` | `name` to create a worktree, or `path` to enter an existing one. Errors if already isolated. |
68
+ | `exit_worktree` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision and a `handoff` (`branch`, `base`, `ahead`, `commits`, `merged`). |
69
+ | `prune_worktrees` | no parameters. Removes eligible managed worktrees and reports the rest. |
70
+ | `list_worktrees` | no parameters. Read-only status: active root, inactive overrides, isolation gaps, managed worktrees. |
26
71
 
27
- `/worktree` on its own opens a list screen: one row per managed worktree with its
28
- state (current, dirty, ahead, behind, merged, locked, last used), the active
29
- worktree's base and isolation gaps under the focused row, and a detail pane for
30
- the selected one. Outside a terminal — print, RPC, JSON — the same facts print as
31
- text instead, which is also what the model's `list_worktrees` reads.
72
+ `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
73
+ `worktree-<name>` cut from `origin/main` (which can lag the local `main`), or
74
+ enters an existing worktree by `path`, and rebinds the session's working root to
75
+ it.
76
+
77
+ `exit_worktree` returns to the main checkout. A clean worktree is removed
78
+ automatically; one with uncommitted changes or new commits prompts (or is kept
79
+ in non-interactive mode). Its result ends with a **handoff**: the branch, how
80
+ many commits it holds beyond the merge target, how many it made since it was
81
+ created, whether that target already contains them, and the `git merge --no-ff`
82
+ and `gh pr create` commands that finish the task. A branch with no commits of its
83
+ own says so rather than claiming a merge — a worktree that never committed is an
84
+ ancestor of its base, and "merged" would read as work that landed. The tool
85
+ never commits, merges, pushes, or opens the PR. A branch that still holds work
86
+ (uncommitted changes, or commits not reachable from the recorded base) is never
87
+ force-deleted, even with `remove: true`.
88
+
89
+ While isolated, `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` all resolve
90
+ relative paths inside the worktree, and:
91
+
92
+ - writes/edits outside the worktree are refused, including through a symlink
93
+ that points at the main checkout;
94
+ - `git -C <main>`, `GIT_DIR=`, `GIT_WORK_TREE=`, `--git-dir=`, `--work-tree=`,
95
+ and `cd` outside the worktree are refused in `bash`;
96
+ - remote `git fetch` (the base ref and a PR/MR head) runs with terminal
97
+ prompting disabled, so a credentials prompt fails fast instead of hanging.
98
+
99
+ ### Status text
32
100
 
33
- | Key | |
34
- |---|---|
35
- | `j` / `k`, arrows | move |
36
- | `g` / `G` | first / last row |
37
- | Enter, `l` | open the selected worktree's detail |
38
- | `e` | enter the selected worktree |
39
- | `x` | exit the active worktree |
40
- | `p` | prune eligible worktrees in place |
41
- | Esc, `q` | close |
101
+ `list_worktrees` (and a headless `/worktree`) reports the active worktree, any
102
+ override that is not re-rooted, what isolation does not cover, and the managed
103
+ worktrees:
42
104
 
43
- `e` and `x` close the screen before they act, because an exit asks through the
44
- normal prompt and a prompt cannot open inside a mounted screen. `p` never asks,
45
- so it prunes without closing and reports the result in the footer. In plan mode
46
- the screen still opens and every verb refuses with a notice.
105
+ ```text
106
+ Not isolated:
107
+ ! shell runs in the main checkout; Pi has no mutable session cwd
108
+ powershell allowed by worktree.guard.blockPowershell: false; not re-rooted or guarded
109
+ unverifiable $(...), backticks, eval, and sh -c pass through (blockUnparsableCommands is off); 2 ran this session
110
+ ```
111
+
112
+ `powershell` appears only on Windows, with that tool active, and only when
113
+ `guard.blockPowershell` lets it through — by default the guard refuses the call
114
+ instead, so there is no gap to report. `unverifiable` appears only while
115
+ `guard.blockUnparsableCommands` is off; its trailing count is this session's,
116
+ reset at session start. `doctor` reports the `powershell` switch as `degraded`
117
+ when it is off and notes the two structural gaps, which no setting closes.
47
118
 
48
- ## What it does
49
-
50
- - `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
51
- `worktree-<name>` cut from `origin/main` (which can lag the local `main`), or
52
- enters an existing worktree by `path`, and rebinds the session's working root
53
- to it.
54
- - `exit_worktree` returns to the main checkout. A clean worktree is removed
55
- automatically; one with uncommitted changes or new commits prompts (or is
56
- kept in non-interactive mode). Its result ends with a **handoff**: the branch,
57
- how many commits it holds beyond the merge target, whether that target already
58
- contains them, and the `git merge --no-ff` and `gh pr create` commands that
59
- finish the task. The tool never commits, merges, pushes, or opens the PR. A
60
- branch that still holds work (uncommitted
61
- changes, or commits not reachable from the recorded base) is never
62
- force-deleted, even with `remove: true`.
63
- - While isolated, `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` all resolve
64
- relative paths inside the worktree, and:
65
- - writes/edits outside the worktree are refused, including through a symlink
66
- that points at the main checkout;
67
- - `git -C <main>`, `GIT_DIR=`, `GIT_WORK_TREE=`, `--git-dir=`, `--work-tree=`,
68
- and `cd` outside the worktree are refused in `bash`;
69
- - network `git fetch` runs with terminal prompting disabled, so a credentials
70
- prompt fails fast instead of hanging.
71
- - State is stored as a custom session entry, so `/resume` (and `--continue`)
72
- restores an active worktree, or reports when it is gone.
119
+ Managed worktrees follow the header, one row each, as `path`, branch, and the
120
+ marks in brackets: `current`, `missing`, `dirty (2)`, `ahead 3`, `behind 1`, `no
121
+ commits`, `commits 4 merged`, `locked`, `used 2d ago`. A row with nothing else to
122
+ report says `clean` — the same word the `/worktree` screen prints, because both
123
+ read `statusMarks` rather than each deciding what an empty row means.
73
124
 
74
125
  ## Commands and flags
75
126
 
76
- | | |
127
+ | Command / flag | What it does |
77
128
  |---|---|
78
129
  | `/worktree` | the list screen: managed worktrees, the active one, and its gaps |
79
130
  | `/worktree enter [name]` | enter a worktree (a name may be a PR/MR reference) |
@@ -89,37 +140,42 @@ outside path, exiting a worktree with work) is reported as normal feedback, not
89
140
  an error. An argument a subcommand does not take prints the usage line instead of
90
141
  being ignored.
91
142
 
92
- `list_worktrees` (and a headless `/worktree`) reports the active worktree, any
93
- override that is not re-rooted, what isolation does not cover, and the managed
94
- worktrees:
95
-
96
- ```text
97
- Not isolated:
98
- ! shell runs in the main checkout; Pi has no mutable session cwd
99
- powershell allowed by worktree.guard.blockPowershell: false; not re-rooted or guarded
100
- unverifiable $(...), backticks, eval, and sh -c pass through (blockUnparsableCommands is off); 2 ran this session
101
- ```
102
-
103
- `powershell` appears only on Windows, with that tool active, and only when
104
- `guard.blockPowershell` lets it through — by default the guard refuses the call
105
- instead, so there is no gap to report. `unverifiable` appears only while
106
- `guard.blockUnparsableCommands` is off; its trailing count is this session's,
107
- reset at session start. `doctor` reports the `powershell` switch as `degraded`
108
- when it is off and notes the two structural gaps, which no setting closes.
143
+ ### The /worktree screen
109
144
 
110
- The model surface mirrors this with four tools:
145
+ <img src="../../assets/screens/worktree.png" alt="The /worktree screen: a Worktrees title rule with a `4 worktrees · 1 dirty · 1 ahead` summary, the current dark-mode row carrying its ahead count, a dirty row, a merged row, and a stale-locked row, the focused row's path and base with the Not isolated gap under it, and the key hints" width="100%">
111
146
 
112
- | Tool | Usage |
147
+ `/worktree` on its own opens a list screen: one row per managed worktree with its
148
+ state (current, dirty, ahead, behind, commits, merged, locked, last used), the
149
+ active worktree's base and isolation gaps under the focused row, and a detail
150
+ pane for the selected one. A worktree that has not committed since it was created
151
+ says `no commits` instead of `merged`: it is an ancestor of its base, which git
152
+ alone cannot tell from a branch that landed. When work did land, the row says how
153
+ much — `commits 2 merged`. Outside a terminal — print, RPC, JSON — the same
154
+ facts print as text instead, which is also what the model's `list_worktrees`
155
+ reads.
156
+
157
+ | Key | What it does |
113
158
  |---|---|
114
- | `enter_worktree` | `name` to create a worktree, or `path` to enter an existing one. Errors if already isolated. |
115
- | `exit_worktree` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision and a `handoff` (`branch`, `base`, `ahead`, `merged`). |
116
- | `prune_worktrees` | no parameters. Removes eligible managed worktrees and reports the rest. |
117
- | `list_worktrees` | no parameters. Read-only status: active root, inactive overrides, isolation gaps, managed worktrees. |
159
+ | `j` / `k`, arrows | move |
160
+ | `g` / `G` | first / last row |
161
+ | Enter, `l` | open the selected worktree's detail |
162
+ | `e` | enter the selected worktree |
163
+ | `x` | exit the active worktree |
164
+ | `p` | prune eligible worktrees in place |
165
+ | Esc, `q` | close |
166
+
167
+ `e` and `x` close the screen before they act, because an exit asks through the
168
+ normal prompt and a prompt cannot open inside a mounted screen. `p` never asks,
169
+ so it prunes without closing and reports the result in the footer. In plan mode
170
+ the screen still opens and every verb refuses with a notice.
118
171
 
119
172
  ## Configuration
120
173
 
121
- The `worktree` section of the shared `bytefly.json` (global document, then
122
- `<repo>/.pi/bytefly.json`, project wins):
174
+ The `worktree` section of the shared `bytefly.json`; the project document is read
175
+ from the repository root rather than the active working directory, and the
176
+ project wins. Resolution rules are in
177
+ [`../configuration.md#worktree--isolated-worktrees`](../configuration.md#worktree--isolated-worktrees),
178
+ which also lists every key with its default and accepted values.
123
179
 
124
180
  ```jsonc
125
181
  // bytefly.json → "worktree"
@@ -255,10 +311,11 @@ use, falling back to directory mtime for a worktree with no record.
255
311
  writes are guarded (relative paths are not isolated), and `list_worktrees`
256
312
  reports it. `skipOverrides` is read at load time, from the process working
257
313
  directory.
258
- - `git branch --merged main` calls a worktree branch merged whenever it matches
259
- its base, so a worktree that never committed looks landed while its
260
- uncommitted work is still there. Read `git -C <path> status` before removing
261
- one; `list_worktrees` reports the same clean/merged flags.
314
+ - The commit count behind `no commits` comes from the commit a worktree was
315
+ created at, which the registry records. A worktree this package did not create
316
+ has no fork point on record, so it falls back to what `git branch --merged`
317
+ says: for one of those, a worktree that never committed still reads as merged.
318
+ Read `git -C <path> status` before removing one.
262
319
  - The guard is best-effort at the shell level: command substitution, `eval`, and
263
320
  `sh -c` are only covered when `blockUnparsableCommands` is enabled, and real
264
321
  isolation of arbitrary shell writes needs an OS sandbox. `/worktree` names the
@@ -287,7 +344,7 @@ use, falling back to directory mtime for a worktree with no record.
287
344
  | Contract | Detail |
288
345
  |---|---|
289
346
  | Registration | The default export calls `registerWorktree`: root-tool overrides, the `--worktree` flag, lifecycle listeners, model tools, and the `/worktree` command. Nothing long-lived starts at load. |
290
- | Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details` — `exit_worktree` carries `{ state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, merged }`. |
347
+ | Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details` — `exit_worktree` carries `{ state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, commits, merged }` (`commits` absent when the worktree has no recorded base commit). |
291
348
  | Built-in overrides | `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` are re-registered under the same names with `defaultActive: false`; each spreads the built-in definition and replaces only `execute`. `skipOverrides` is applied at load time. `powershell` is deliberately not overridden: it cannot be re-rooted without a guard, so the `tool_call` handler refuses it (see `guard.blockPowershell`). |
292
349
  | Commands | `/worktree` via `registerCommand`, with argument completions for `enter`, `exit`, and `prune`. The bare command mounts `screen.ts` in a terminal and prints the status text otherwise; `enter`/`exit`/`prune` refuse while plan mode is read-only. |
293
350
  | Flags | `--worktree` via `registerFlag`. |
@@ -364,6 +421,7 @@ one read and two renderings.
364
421
  | `root-tools.ts` | Load-time built-in overrides bound to the active root |
365
422
  | `git.ts` | Git plumbing (shared runner + read helpers) |
366
423
  | `guard.ts` | Path, shell, and git-redirect isolation checks, plus the `powershell` refusal |
424
+ | `shell.ts` | The command-line reader those checks ask: words, segments, and redirection targets |
367
425
  | `gaps.ts` | The isolation gaps a session carries, and how they read (pure) |
368
426
  | `include.ts` | `.worktreeinclude` matcher and copy |
369
427
  | `config.ts` / `state.ts` / `status.ts` | Config, session entry shape, status text and the shared snapshot |
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `/board` — the page's one entry point.
3
+ *
4
+ * Three words, and everything else opens the page: `team` opens it on the Team tab,
5
+ * `status` prints one line, `stop` closes it. The command is a thin parser over the
6
+ * lifecycle in `page.ts`, so the idle grace and `session_shutdown` close the same
7
+ * server this opens.
8
+ */
9
+
10
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
+ import { plural } from "../../lib/board/round-text.ts";
12
+ import type { JoinResult } from "../../lib/session-tool.ts";
13
+ import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
14
+ import { joinLabel, teamLine } from "./join.ts";
15
+ import type { BoardPage, BoardPageStatus } from "./page.ts";
16
+
17
+ /** The command's name, which is also the word a user types. */
18
+ export const BOARD_COMMAND = "board";
19
+
20
+ /** The words the command accepts, in the order a user is likely to want them. */
21
+ export const BOARD_WORDS = ["status", "stop", "team"] as const;
22
+
23
+ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team?: TeamJoin } = {}): void {
24
+ const team = deps.team ?? createTeamJoin(pi);
25
+ /** What the last `/board` did about the team, for the status line. */
26
+ let joined: JoinResult | undefined;
27
+ pi.registerCommand(BOARD_COMMAND, {
28
+ description: "Open the shared board in a local browser: /board [team | status | stop]",
29
+ getArgumentCompletions: (prefix: string) => {
30
+ const text = prefix.trimStart();
31
+ if (text.includes(" ")) return null;
32
+ const items = BOARD_WORDS.filter((word) => word.startsWith(text));
33
+ return items.length > 0 ? items.map((value) => ({ value, label: value })) : null;
34
+ },
35
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
36
+ const word = args.trim().toLowerCase();
37
+ if (word === "status") {
38
+ ctx.ui.notify(statusLine(await page.status(), joined), "info");
39
+ return;
40
+ }
41
+ if (word === "stop") {
42
+ const status = await page.status();
43
+ // A note nobody answers is not a page to leave for the next session to trip over:
44
+ // it is dropped here, and the line says whose it was.
45
+ if (status.stale === true) {
46
+ const dropped = await page.dropStale(ctx);
47
+ ctx.ui.notify(
48
+ dropped
49
+ ? `board: dropped ${status.seat}'s note for :${status.port} — it answered nobody`
50
+ : `board: nothing to stop here — ${status.seat} is answering on :${status.port}`,
51
+ "info",
52
+ );
53
+ return;
54
+ }
55
+ // A page another session serves is that session's to stop, and saying whose it is is the answer.
56
+ if (status.attached === true) {
57
+ ctx.ui.notify(
58
+ `board: nothing to stop here — ${status.seat} serves this repository on :${status.port}`,
59
+ "info",
60
+ );
61
+ return;
62
+ }
63
+ ctx.ui.notify((await page.stop(ctx)) ? "board: stopped" : "board: not running", "info");
64
+ return;
65
+ }
66
+ if (word !== "" && word !== "team") {
67
+ ctx.ui.notify(
68
+ `board: "${word}" is not a word this command knows — try /board, /board team, /board status, /board stop`,
69
+ "warning",
70
+ );
71
+ return;
72
+ }
73
+ const started = await page.start(ctx, word === "team" ? "team" : "board");
74
+ // A failed start has already said why. Otherwise the join happens here, because
75
+ // opening the page is the lead's one action: the seat when this session serves it,
76
+ // this session's own label when it watches a page somebody else serves.
77
+ if (started.kind === "failed") return;
78
+ joined = await team.join(ctx, joinLabel(started.kind === "served"));
79
+ // The address is on the line because the browser is not the only way in: a machine
80
+ // with no default browser prints it here, and a tab closed by mistake is one
81
+ // command from being opened again.
82
+ ctx.ui.notify(
83
+ started.kind === "attached"
84
+ ? `board: ${started.seat} is serving this repository on :${started.port} — viewing · open ${started.url}${teamLine(joined)}`
85
+ : `board: ${started.seat} serving ${started.where} on :${started.port} · open ${started.url}${teamLine(joined)}`,
86
+ "info",
87
+ );
88
+ },
89
+ });
90
+ }
91
+
92
+ /** One line for `/board status`: the page this session serves, watches, or does not have. */
93
+ export function statusLine(status: BoardPageStatus, team?: JoinResult): string {
94
+ if (status.running) {
95
+ const watching = plural(status.watchers ?? 0, "tab");
96
+ return `board: ${status.seat} serving on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
97
+ }
98
+ if (status.stale === true) {
99
+ return `board: watching ${status.seat} on :${status.port} — that note answers nobody; /board stop drops it${teamLine(team)}`;
100
+ }
101
+ if (status.attached === true) {
102
+ return `board: watching ${status.seat} on :${status.port} — nothing to stop here${teamLine(team)}`;
103
+ }
104
+ return "board: not running — /board opens it";
105
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The board's one setting: the port its page is pinned to.
3
+ *
4
+ * The `board` section of the shared `bytefly.json` (global then project, project
5
+ * wins). `0` — the default — takes one free port for the session and remembers it,
6
+ * which is what a session that never bookmarks its URL wants; a positive value pins
7
+ * the page, so a bookmarked URL survives a restart. A pinned port that is taken
8
+ * fails the start rather than moving the page out from under the bookmark.
9
+ *
10
+ * Pin it in the project document when more than one repository runs a board: a
11
+ * global value is one port for every board on the machine.
12
+ */
13
+
14
+ import { clampInteger, loadConfigSection } from "../../lib/config.ts";
15
+
16
+ /** The `board` section. */
17
+ export interface BoardConfig {
18
+ /** TCP port on `127.0.0.1`; `0` picks one for the session, a positive value pins it. */
19
+ port: number;
20
+ }
21
+
22
+ export const DEFAULT_BOARD_CONFIG: BoardConfig = { port: 0 };
23
+
24
+ /** Validate and clamp a raw config object over the defaults. */
25
+ export function normalizeBoardConfig(
26
+ raw: Record<string, unknown> | undefined,
27
+ base: BoardConfig = DEFAULT_BOARD_CONFIG,
28
+ ): BoardConfig {
29
+ if (!raw) return base;
30
+ return { port: clampInteger(raw.port, base.port, 0, 65_535) };
31
+ }
32
+
33
+ /** Load the effective config for a working directory. */
34
+ export function loadBoardConfig(cwd: string): BoardConfig {
35
+ return loadConfigSection(cwd, "board", DEFAULT_BOARD_CONFIG, normalizeBoardConfig);
36
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The model-facing text for one card or one listing.
3
+ *
4
+ * Text is the model's channel, so it carries the facts a next decision needs — the
5
+ * id, the lane, who holds it, whether it is planned, how urgent it is, and what it
6
+ * waits for — and the renderer carries the styling. Ages and sizes stay out: a note is
7
+ * read for what it says, and the page is where "3m ago" belongs.
8
+ *
9
+ * `board` is optional because a single card's answer sometimes comes without the rest
10
+ * of the board: the ids are still named, and only the lane of each dependency is lost.
11
+ * The rollup's text is `lib/board/round-text.ts`, because `team` prints it too.
12
+ */
13
+
14
+ import { type Card, cardRef, DEFAULT_PRIORITY, dependsOf, priorityOf } from "../../lib/board/card.ts";
15
+ import { type BoardIndex, lanesText, openDependentsOf } from "../../lib/board/links.ts";
16
+ import type { Skip } from "../../lib/board/store.ts";
17
+ import { formatAge } from "../../lib/format.ts";
18
+ import { plural } from "../../lib/board/round-text.ts";
19
+
20
+ /** One card: the headline, then the state a reader needs in order to act on it. */
21
+ export function formatCard(card: Card, board?: BoardIndex, now?: number): string {
22
+ return [`${cardRef(card.id)} ${card.status} · ${card.title}`, ...detailLines(card, board, now)].join("\n");
23
+ }
24
+
25
+ /**
26
+ * What a listing cannot show, and therefore has to say.
27
+ *
28
+ * A footnote is the difference between a listing and the board: an omission nobody
29
+ * accounted for reads as a smaller board — or as history that was deleted.
30
+ */
31
+ export interface ListingNotes {
32
+ /** Finished cards the board holds that a lane-less listing left out. */
33
+ hiddenDone: number;
34
+ /** Cards set aside in the archive, which no board read opens. */
35
+ archived: number;
36
+ /** Card files this build could not read. */
37
+ skipped: readonly Skip[];
38
+ }
39
+
40
+ /**
41
+ * A board's listing: the cards, then the footnotes a reader cannot see.
42
+ *
43
+ * One body, because the footnotes belong to the listing: a card file this build cannot
44
+ * read, the finished cards a plain listing left out, and the cards an `archive` set aside.
45
+ */
46
+ export function formatBoard(shown: readonly Card[], notes: ListingNotes): string {
47
+ const lines =
48
+ shown.length === 0 ? [notes.hiddenDone === 0 ? "The board is empty." : "No open cards."] : shown.map(listLine);
49
+ // A finished card is history rather than work, so a listing that names no lane leaves
50
+ // them out; the count is what keeps the omission from reading as a smaller board.
51
+ if (notes.hiddenDone > 0) {
52
+ const them = notes.hiddenDone === 1 ? "it" : "them";
53
+ lines.push(`${plural(notes.hiddenDone, "done card")} hidden; pass status: "done" to read ${them}.`);
54
+ }
55
+ for (const note of [archiveNote(notes.archived), formatSkipped(notes.skipped)]) {
56
+ if (note !== "") lines.push(note);
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+
61
+ /**
62
+ * The archive's listing: what is set aside, and the files there this build refused.
63
+ *
64
+ * The archive is read on request only, so this has no lane footnote of its own — every
65
+ * card in the pile is finished, and a `done` lane would be a second way to say it.
66
+ */
67
+ export function formatArchive(shown: readonly Card[], skipped: readonly Skip[]): string {
68
+ const lines = shown.length === 0 ? ["The archive is empty."] : shown.map(listLine);
69
+ const unreadable = formatSkipped(skipped);
70
+ if (unreadable !== "") lines.push(unreadable);
71
+ return lines.join("\n");
72
+ }
73
+
74
+ /**
75
+ * The one sentence a board listing adds so its archive is not mistaken for deleted history.
76
+ *
77
+ * Empty when nothing is set aside, so the ordinary board says nothing new.
78
+ */
79
+ export function archiveNote(count: number): string {
80
+ if (count === 0) return "";
81
+ return `${plural(count, "card")} ${count === 1 ? "is" : "are"} set aside in the archive; pass archived: true to read ${count === 1 ? "it" : "them"}.`;
82
+ }
83
+
84
+ /**
85
+ * The files the board could not read, or nothing when it read everything.
86
+ *
87
+ * A skipped file is not an absent one: a board written by a newer build would
88
+ * otherwise read as "nobody has planned anything" while the work sits on disk.
89
+ */
90
+ export function formatSkipped(skipped: readonly Skip[]): string {
91
+ if (skipped.length === 0) return "";
92
+ const files = skipped.map((entry) => `${entry.file} (${entry.why})`).join(", ");
93
+ return `${plural(skipped.length, "file")} this build cannot read: ${files}`;
94
+ }
95
+
96
+ /** The dropped id, which is the only thing left of the card. */
97
+ export function formatDropped(id: string): string {
98
+ return `${cardRef(id)} dropped, with its notes.`;
99
+ }
100
+
101
+ /**
102
+ * The ids an archive set aside, and how to read them again.
103
+ *
104
+ * A move has to name the way back or it reads like a deletion: the card is out of the
105
+ * board and its notes, plan, and landed commit are still in its file.
106
+ */
107
+ export function formatArchived(ids: readonly string[]): string {
108
+ const theirs = ids.length === 1 ? "its" : "their";
109
+ return `${plural(ids.length, "card")} archived: ${ids.map(cardRef).join(", ")} — out of the board, ${theirs} notes and plan still readable with archived: true.`;
110
+ }
111
+
112
+ /** A one-line listing: the id, the lane, the title, and who holds it. */
113
+ function listLine(card: Card): string {
114
+ const holder = card.owner === undefined ? "unassigned" : card.owner.label;
115
+ const urgency = priorityOf(card) === DEFAULT_PRIORITY ? "" : ` · ${priorityOf(card)}`;
116
+ const waits = dependsOf(card).length === 0 ? "" : ` · waits on ${dependsOf(card).map(cardRef).join(", ")}`;
117
+ // The two lanes that carry a fact of their own say it here: what a block waits on, and
118
+ // what closed a finished card. Both are one short phrase, so a listing stays one line.
119
+ const blocked = card.blockedBy === undefined ? "" : ` · blocked: ${card.blockedBy}`;
120
+ const landed = card.landed === undefined ? "" : ` · landed ${card.landed.commit}`;
121
+ return `${cardRef(card.id)} ${card.status} · ${card.title} · ${holder}${urgency}${waits}${blocked}${landed}`;
122
+ }
123
+
124
+ /** The state lines under a card's headline. */
125
+ function detailLines(card: Card, board?: BoardIndex, now?: number): string[] {
126
+ const lines: string[] = [];
127
+ if (card.owner !== undefined) lines.push(` owner: ${card.owner.label}`);
128
+ // An offer's age is the fact a reader acts on, which is the one place an age belongs in
129
+ // this text: the value is whether anybody has had time to answer.
130
+ if (card.offeredAt !== undefined && now !== undefined) {
131
+ lines.push(` offered: ${formatAge(now, card.offeredAt)} ago, and nobody took it`);
132
+ }
133
+ if (card.blockedBy !== undefined) lines.push(` blocked: ${card.blockedBy}`);
134
+ if (card.landed !== undefined) lines.push(` landed: ${card.landed.commit}`);
135
+ if (priorityOf(card) !== DEFAULT_PRIORITY) lines.push(` priority: ${priorityOf(card)}`);
136
+ if (card.plan !== undefined) lines.push(` plan: ${card.plan}`);
137
+ if (card.paths.length > 0) lines.push(` paths: ${card.paths.join(", ")}`);
138
+ if (dependsOf(card).length > 0) lines.push(` waits on: ${waitingOf(card, board)}`);
139
+ // The mirror question, for the same board: what leaving this card undone holds up. Only
140
+ // a card with the board in hand can answer it, which is why a bare card read omits it.
141
+ if (board !== undefined) {
142
+ const dependents = openDependentsOf(card.id, board);
143
+ if (dependents.length > 0) {
144
+ const which = dependents.length === 1 ? "card" : "cards";
145
+ lines.push(` waited on by ${dependents.length} ${which}: ${lanesText(dependents)}`);
146
+ }
147
+ }
148
+ if (card.notes.length > 0) {
149
+ const last = card.notes.at(-1);
150
+ if (last) lines.push(` last note (${last.by}): ${last.text}`);
151
+ }
152
+ const dropped = card.notesDropped > 0 ? ` (${card.notesDropped} earlier dropped)` : "";
153
+ lines.push(` ${plural(card.notes.length, "note")}${dropped}`);
154
+ return lines;
155
+ }
156
+
157
+ /**
158
+ * The cards a card waits on, each with its lane when the board is there to say it.
159
+ *
160
+ * The lane is what turns a list of ids into a decision: `#2 (done)` is a card about to
161
+ * start, and `#2 (doing)` is one to leave alone.
162
+ */
163
+ function waitingOf(card: Card, board: BoardIndex | undefined): string {
164
+ return dependsOf(card)
165
+ .map((id) => {
166
+ const blocker = board?.get(id);
167
+ return blocker === undefined ? cardRef(id) : `${cardRef(id)} (${blocker.status})`;
168
+ })
169
+ .join(", ");
170
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * board — one shared feature board per repository.
3
+ *
4
+ * Every Pi session on a repository reads and writes the same cards, and `/board`
5
+ * serves them as a page in the browser that follows the store live. A card is one
6
+ * *feature*, not one step: the steps of the work live in the owner's plan and in
7
+ * `todo`, and the board answers who is doing what and what needs a decision.
8
+ *
9
+ * The factory only registers. The store is opened on first use by the session, the
10
+ * page starts when `/board` asks for it, and `session_shutdown` closes it — so a
11
+ * session that never touches the board writes nothing and binds nothing.
12
+ *
13
+ * Load with: pi --extension ./extensions/board
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { isExtensionEnabled } from "../../lib/env.ts";
18
+ import { registerCommands } from "./commands.ts";
19
+ import { type BoardPage, createBoardPage } from "./page.ts";
20
+ import { type BoardSession, openBoardSession } from "../../lib/board/session.ts";
21
+ import { registerTools } from "./tools.ts";
22
+
23
+ export interface BoardDeps {
24
+ /** Test seam: a session with a temp agent directory and a fixed clock. */
25
+ session?: BoardSession;
26
+ /** Test seam: the page lifecycle, so a test drives it without a socket. */
27
+ page?: BoardPage;
28
+ }
29
+
30
+ export default function board(pi: ExtensionAPI, deps: BoardDeps = {}): void {
31
+ if (!isExtensionEnabled("board")) return;
32
+ const session = deps.session ?? openBoardSession(pi);
33
+ const page = deps.page ?? createBoardPage(session);
34
+ registerTools(pi, session);
35
+ registerCommands(pi, page);
36
+
37
+ pi.on("session_shutdown", async () => {
38
+ await page.closeAll();
39
+ });
40
+ }