@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -9,35 +9,33 @@ root changes. It is one of two sibling git extensions in this package
9
9
 
10
10
  ## Capabilities
11
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
12
  ### Isolating work
15
13
 
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 |
14
+ | Capability | How | What you get |
15
+ |---|---|---|
16
+ | Isolated checkout | `worktree` action `enter`, `/worktree enter`, `--worktree` | A new checkout at `<repo>/.pi/worktrees/<name>` on branch `worktree-<name>`, cut from `origin/main`. |
17
+ | Root-following built-ins | `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls` | The built-in tools keep taking ordinary relative paths, resolved inside the worktree. |
18
+ | Escape refusal | `guard.blockFileEscapes`, `guard.blockGitRedirects`, `guard.blockSymlinkEscapes` | Writes, `cd`, and `git` redirects that reach the main checkout are refused, symlink escapes included. |
19
+ | Plan-mode refusal | `/worktree`, `worktree` action `enter` | In plan mode the screen opens read-only and every verb refuses until the plan is approved. |
20
+ | PowerShell refusal | `powershell` while isolated | The unparseable shell is refused instead of becoming an unguarded escape. |
21
+ | Seeded working copy | `.worktreeinclude`, `include` | Gitignored files such as `.env` are copied into each new worktree. |
22
+ | Ignore bootstrap | `worktree` action `enter` | The first worktree offers to add `.pi/worktrees` to `.gitignore`, so the checkout does not pollute status. |
23
+ | PR/MR checkout | `worktree` action `enter` with `name`, `--worktree` | A pull or merge request's head is fetched and checked out as `pr-<n>`. |
24
+ | 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. |
27
25
 
28
26
  ### Returning and managing
29
27
 
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 |
28
+ | Capability | How | What you get |
29
+ |---|---|---|
30
+ | Return with a handoff | `worktree` action `exit`, `/worktree exit` | Back in the main checkout, with the branch, its commits, and the merge or PR command that finishes them. |
31
+ | Work that survives | `worktree` action `exit`, `worktree.onExit` | A worktree holding changes or commits is asked about or kept, never force-deleted. |
32
+ | Status inventory | `worktree` action `list`, `/worktree` | The active root, the gaps isolation does not cover, and every managed worktree with its state. |
33
+ | Worktree screen | `/worktree` | One list screen with the focused worktree's path, base, and gaps, and the verbs to act on it. |
34
+ | Cleanup | `worktree` action `prune`, `/worktree prune` | Clean, unused, old worktrees are removed; every other one is kept with a reason. |
35
+ | Concurrent-session safety | `worktree` actions `enter` and `prune` | A worktree in use is locked, so another session's sweep cannot remove it. |
36
+ | Session restore | `/resume`, `--continue` | An active worktree is restored from the branch, or reported gone. |
37
+ | Extension signal | `worktree:changed` | Another extension follows the active root without polling. |
38
+ | Doctor finding | `run_doctor` | A degraded `guard.blockPowershell` setting is named in the health report instead of only at use. |
41
39
 
42
40
  ## Quickstart
43
41
 
@@ -51,30 +49,32 @@ pi install ./ # install the package
51
49
  /worktree # the list screen, or the same facts as text
52
50
  ```
53
51
 
54
- The model enters with `enter_worktree`; `pi -e . --worktree feature-auth` starts
52
+ The model enters with `worktree` action `enter`; `pi -e . --worktree feature-auth` starts
55
53
  the session already isolated.
56
54
 
57
55
  ## Tools
58
56
 
59
- <img src="../../assets/tools/list_worktrees.png" alt="The list_worktrees tool's call and result, drawn by the generic fallback" width="100%">
57
+ <img src="../../assets/tools/worktree-list.png" alt="The worktree tool's list action: the call line and the indented status it reports" width="100%">
60
58
 
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.
59
+ One tool with four actions — `enter`, `exit`, `list`, `prune` — because they are
60
+ one lifecycle. It is `executionMode: "sequential"` and takes the strictest
61
+ annotation of the four, `destructiveHint: true` with `openWorldHint: true`,
62
+ because `enter` writes into the checkout the session is bound to; `list` writes
63
+ nothing, and the fold cannot say that with one hint.
64
64
 
65
- | Tool | Usage |
65
+ | Action | Usage |
66
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. |
67
+ | `enter` | `name` to create a worktree, or `path` to enter an existing one. Errors if already isolated. |
68
+ | `exit` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision and a `handoff` (`branch`, `base`, `ahead`, `commits`, `merged`). |
69
+ | `list` | no fields. Read-only status: active root, inactive overrides, isolation gaps, managed worktrees. |
70
+ | `prune` | no fields. Removes eligible managed worktrees and reports the rest. |
71
71
 
72
- `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
72
+ `enter` creates `<repo>/.pi/worktrees/<name>` on branch
73
73
  `worktree-<name>` cut from `origin/main` (which can lag the local `main`), or
74
74
  enters an existing worktree by `path`, and rebinds the session's working root to
75
75
  it.
76
76
 
77
- `exit_worktree` returns to the main checkout. A clean worktree is removed
77
+ `exit` returns to the main checkout. A clean worktree is removed
78
78
  automatically; one with uncommitted changes or new commits prompts (or is kept
79
79
  in non-interactive mode). Its result ends with a **handoff**: the branch, how
80
80
  many commits it holds beyond the merge target, how many it made since it was
@@ -98,7 +98,7 @@ relative paths inside the worktree, and:
98
98
 
99
99
  ### Status text
100
100
 
101
- `list_worktrees` (and a headless `/worktree`) reports the active worktree, any
101
+ `list` (and a headless `/worktree`) reports the active worktree, any
102
102
  override that is not re-rooted, what isolation does not cover, and the managed
103
103
  worktrees:
104
104
 
@@ -151,7 +151,7 @@ pane for the selected one. A worktree that has not committed since it was create
151
151
  says `no commits` instead of `merged`: it is an ancestor of its base, which git
152
152
  alone cannot tell from a branch that landed. When work did land, the row says how
153
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`
154
+ facts print as text instead, which is also what the model's `list` action
155
155
  reads.
156
156
 
157
157
  | Key | What it does |
@@ -204,10 +204,10 @@ which also lists every key with its default and accepted values.
204
204
  tool names: Pi refuses to load two extensions that register the same tool, so
205
205
  exclude the name here to load alongside it. It is read when the extension
206
206
  loads, from the process working directory; reload after changing it. `/worktree`
207
- and `list_worktrees` list any overridden tool that is not re-rooted.
207
+ and `worktree` action `list` name any overridden tool that is not re-rooted.
208
208
 
209
209
  `.gitignore` must cover the managed directory, or worktrees show up as untracked
210
- files in the main checkout. `enter_worktree` offers once per session to append
210
+ files in the main checkout. `enter` offers once per session to append
211
211
  `.pi/worktrees/`, and skips the offer when `git check-ignore` already reports the
212
212
  directory as ignored, so a wider rule is enough and no literal line is needed:
213
213
 
@@ -266,7 +266,7 @@ config/secrets.json
266
266
 
267
267
  ## Pull / merge requests
268
268
 
269
- `enter_worktree` accepts a PR/MR reference in `name`, as does the startup flag:
269
+ `enter` accepts a PR/MR reference in `name`, as does the startup flag:
270
270
 
271
271
  ```
272
272
  pi --worktree '#1234'
@@ -282,8 +282,8 @@ While isolated, the extension exports `PI_WORKTREE_ROOT`, `PI_WORKTREE_BRANCH`,
282
282
  and `PI_WORKTREE_MAIN` (the main checkout) into the process environment. A child
283
283
  `pi` process — a subagent spawned by an extension, or any `pi` you start from a
284
284
  tool — inherits it and binds to the same worktree instead of the main checkout.
285
- The child is read-only with respect to lifecycle: `enter_worktree` and
286
- `exit_worktree` refuse, and the parent owns cleanup.
285
+ The child is read-only with respect to lifecycle: `enter` and `exit`
286
+ refuse, and the parent owns cleanup.
287
287
 
288
288
  Extensions can also follow changes on the `pi.events` channel
289
289
  `worktree:changed`, which receives `{ active, path, branch, borrowed }`.
@@ -292,7 +292,7 @@ Extensions can also follow changes on the `pi.events` channel
292
292
 
293
293
  Entering a managed worktree takes a `git worktree lock` (reason
294
294
  `pi:<pid>:<session>`), so a concurrent sweep cannot remove it; exiting and
295
- shutdown release it. `/worktree prune` (or the `prune_worktrees` tool) removes
295
+ shutdown release it. `/worktree prune` (or `worktree` action `prune`) removes
296
296
  managed worktrees that are clean, have no new commits, are not the current or a
297
297
  live-locked one, and are older than `pruneAfterDays`. It releases locks whose
298
298
  owning process is gone first, and any failure keeps the worktree with a reason.
@@ -308,7 +308,7 @@ use, falling back to directory mtime for a worktree with no record.
308
308
  extension owns `bash` (or another overridden name), set
309
309
  `skipOverrides: ["bash"]` so this extension does not register it; that tool
310
310
  then runs in the main checkout with **no re-rooting**, so only its absolute
311
- writes are guarded (relative paths are not isolated), and `list_worktrees`
311
+ writes are guarded (relative paths are not isolated), and `list`
312
312
  reports it. `skipOverrides` is read at load time, from the process working
313
313
  directory.
314
314
  - The commit count behind `no commits` comes from the commit a worktree was
@@ -323,11 +323,15 @@ use, falling back to directory mtime for a worktree with no record.
323
323
  - Only the model's `bash` tool is re-rooted and guarded. A shell command the
324
324
  user runs directly (`!` in the TUI / the `user_bash` event) is not re-rooted
325
325
  and still runs in the main checkout; Pi has no mutable session cwd to change
326
- that. `/worktree` and `list_worktrees` report the active root, and list the
326
+ that. `/worktree` and `list` report the active root, and list the
327
327
  shell under `Not isolated:` on every status read.
328
328
  - Windows `powershell` is refused while isolated, or — with
329
329
  `guard.blockPowershell: false` — allowed and unguarded. There is no third
330
330
  state: a parsed PowerShell command that keeps the tool.
331
+ - **`list` is not available while planning.** One annotation set cannot be both
332
+ read-only and destructive, so plan mode hides the whole tool and a planning
333
+ session reads the inventory with `/worktree` instead. A per-tool filter is
334
+ all-or-nothing for the same reason, so disabling `worktree` loses every verb.
331
335
 
332
336
  ## Non-goals
333
337
 
@@ -344,7 +348,7 @@ use, falling back to directory mtime for a worktree with no record.
344
348
  | Contract | Detail |
345
349
  |---|---|
346
350
  | 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. |
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). |
351
+ | Tools | one `worktree` tool via `registerTool`, with four actions (`enter`, `exit`, `list`, `prune`), `executionMode: "sequential"`, and one annotation set for all four: `destructiveHint: true`, `openWorldHint: true` — the strictest, because `enter` writes into the checkout the session is bound to. The block is bytefly's generic fallback, as the four tools' was: the verb reads as the subject and its fields as the aside (`worktree enter (name feat)`, `worktree list`). No `outputSchema`; structured state is returned in tool-result `details` discriminated by `action` — `exit` carries `{ action, state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, commits, merged }` (`commits` absent when the worktree has no recorded base commit). |
348
352
  | 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`). |
349
353
  | 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. |
350
354
  | Flags | `--worktree` via `registerFlag`. |
@@ -389,7 +393,7 @@ one read and two renderings.
389
393
  `--continue` restore the binding or report accurately when the directory is
390
394
  gone; the registry is reconciled against `git worktree list` on every
391
395
  management read and is never the source of truth.
392
- - **Never force out work.** `exit_worktree` never force-deletes a branch that
396
+ - **Never force out work.** `exit` never force-deletes a branch that
393
397
  still holds uncommitted changes or commits not reachable from the recorded
394
398
  base, even with `remove: true`.
395
399
  - **A handoff is read, not performed.** The exit reports the branch's state and
@@ -416,7 +420,7 @@ one read and two renderings.
416
420
  | `errors.ts` | Cancellation error that distinguishes a user decline from a failure |
417
421
  | `enter.ts` / `exit.ts` / `prune.ts` | The lifecycle phases |
418
422
  | `handoff.ts` | The `Handoff:` block an exit leaves: the branch's lead, whether the base contains it, and the merge or PR command. |
419
- | `tools.ts` / `commands.ts` | Model tools / slash commands |
423
+ | `tools.ts` / `commands.ts` | The one model tool and its four actions / slash commands |
420
424
  | `screen.ts` | The `/worktree` list screen: rows, keys, detail pane, and verbs |
421
425
  | `root-tools.ts` | Load-time built-in overrides bound to the active root |
422
426
  | `git.ts` | Git plumbing (shared runner + read helpers) |
@@ -436,7 +440,9 @@ bun run test:watch
436
440
  ```
437
441
 
438
442
  The suite covers the pure logic (`include.ts` gitignore matcher, `guard.ts` path
439
- and command checks, `parsePrReference`), drives the `/worktree` screen against a
443
+ and command checks, `parsePrReference`), the folded tool's own surface
444
+ (`tools.test.ts`: the four actions in one schema and its annotations), drives the
445
+ `/worktree` screen against a
440
446
  snapshot (`ui.test.ts`: rows, marks, verbs, and width bounds), and drives the
441
447
  extension end-to-end against real temporary git repositories through a mocked
442
448
  `pi` API: enter/exit, isolation guards, borrowed worktrees, prune, and
@@ -1,31 +1,39 @@
1
1
  /**
2
- * `/board` — the page's one entry point.
2
+ * `/board` — the page's one entry point, and the person's door to the two signs.
3
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.
4
+ * `team` opens the page on the Team tab, `status` prints one line, `stop` closes it, and
5
+ * `approve <id>` / `review <id>` write the two signs as the person at the keyboard, which is what
6
+ * a session that is not the board's lead cannot do. Everything else opens the page. The command
7
+ * is a thin parser — the lifecycle is `page.ts`'s, the transitions are `lib/board/steps.ts`'s —
8
+ * so the idle grace and `session_shutdown` close the same server this opens.
8
9
  */
9
10
 
10
11
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { cardRef, normalizeCardId } from "../../lib/board/card.ts";
13
+ import { PERSON_LABEL, personActor, type Sign, signOf } from "../../lib/board/lead.ts";
11
14
  import { plural } from "../../lib/board/round-text.ts";
15
+ import { type BoardSession } from "../../lib/board/session.ts";
12
16
  import type { JoinResult } from "../../lib/session-tool.ts";
13
17
  import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
14
- import { joinLabel, teamLine } from "./join.ts";
18
+ import { teamLine } from "./join.ts";
15
19
  import type { BoardPage, BoardPageStatus } from "./page.ts";
16
20
 
17
21
  /** The command's name, which is also the word a user types. */
18
22
  export const BOARD_COMMAND = "board";
19
23
 
20
24
  /** 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;
25
+ const BOARD_WORDS = ["approve", "review", "status", "stop", "team"] as const;
22
26
 
23
- export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team?: TeamJoin } = {}): void {
27
+ export function registerCommands(
28
+ pi: ExtensionAPI,
29
+ session: BoardSession,
30
+ page: BoardPage,
31
+ deps: { team?: TeamJoin } = {},
32
+ ): void {
24
33
  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
34
  pi.registerCommand(BOARD_COMMAND, {
28
- description: "Open the shared board in a local browser: /board [team | status | stop]",
35
+ description:
36
+ "Open the shared board in a local browser, or sign its two gates as the person: /board [team | status | stop | approve <id> | review <id>]",
29
37
  getArgumentCompletions: (prefix: string) => {
30
38
  const text = prefix.trimStart();
31
39
  if (text.includes(" ")) return null;
@@ -33,9 +41,14 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
33
41
  return items.length > 0 ? items.map((value) => ({ value, label: value })) : null;
34
42
  },
35
43
  handler: async (args: string, ctx: ExtensionCommandContext) => {
36
- const word = args.trim().toLowerCase();
44
+ const [word = "", rawCard] = args.trim().toLowerCase().split(/\s+/);
45
+ const sign = signOf(word);
46
+ if (sign !== undefined) {
47
+ ctx.ui.notify(await signLine(session, sign, rawCard, ctx), "info");
48
+ return;
49
+ }
37
50
  if (word === "status") {
38
- ctx.ui.notify(statusLine(await page.status(), joined), "info");
51
+ ctx.ui.notify(statusLine(await page.status(), session.team.last()), "info");
39
52
  return;
40
53
  }
41
54
  if (word === "stop") {
@@ -60,40 +73,79 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
60
73
  );
61
74
  return;
62
75
  }
63
- ctx.ui.notify((await page.stop(ctx)) ? "board: stopped" : "board: not running", "info");
76
+ const stopped = await page.stop();
77
+ // The word is this session saying it serves nothing now: the page clears the seat too
78
+ // when it closes itself (the idle grace), and both writes are the same fact.
79
+ session.serve(undefined);
80
+ ctx.ui.notify(stopped ? "board: stopped" : "board: not running", "info");
64
81
  return;
65
82
  }
66
83
  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
- );
84
+ ctx.ui.notify(`board: "${word}" is not a word this command knows — try ${wordHelp()}`, "warning");
71
85
  return;
72
86
  }
73
87
  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.
88
+ // A failed start has already said why. Otherwise the seat is set from the page's own
89
+ // answer — the page a session serves is a role, and every name this session writes and
90
+ // is addressed by follows from it — and the join happens here, because opening the page
91
+ // is the lead's one action.
77
92
  if (started.kind === "failed") return;
78
- joined = await team.join(ctx, joinLabel(started.kind === "served"));
93
+ session.serve(started.kind === "served" ? started.seat : undefined);
94
+ const joined = await team.join(ctx, { label: (await session.who(ctx, ctx.cwd)).label });
95
+ session.team.record(joined);
79
96
  // The address is on the line because the browser is not the only way in: a machine
80
97
  // with no default browser prints it here, and a tab closed by mistake is one
81
98
  // command from being opened again.
82
99
  ctx.ui.notify(
83
100
  started.kind === "attached"
84
101
  ? `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)}`,
102
+ : `board: ${started.seat} serving ${started.repo} on :${started.port} · open ${started.url}${teamLine(joined)}`,
86
103
  "info",
87
104
  );
88
105
  },
89
106
  });
90
107
  }
91
108
 
109
+ /** The words, as the warning that names them: a sign takes a card, and nothing else does. */
110
+ function wordHelp(): string {
111
+ return BOARD_WORDS.map((entry) => (signOf(entry) === undefined ? `/board ${entry}` : `/board ${entry} <id>`)).join(
112
+ ", ",
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Sign one of the card's two gates as the person at the keyboard.
118
+ *
119
+ * The write is the same `mutate` the tool uses, so the rules are the state machine's: an
120
+ * `approve` outside `plan`, a `review` outside `execute`, an unknown id, and a card already
121
+ * signed are each refused with the sentence the tool would give. The one thing that differs
122
+ * is the actor — the person, whose id no session can have — which is what makes the sign a
123
+ * sign even when the session that typed it holds the card.
124
+ */
125
+ async function signLine(
126
+ session: BoardSession,
127
+ sign: Sign,
128
+ rawCard: string | undefined,
129
+ ctx: ExtensionCommandContext,
130
+ ): Promise<string> {
131
+ const id = rawCard === undefined ? undefined : normalizeCardId(rawCard);
132
+ if (id === undefined) {
133
+ return `board: ${sign} needs a card id — try /board ${sign} 4`;
134
+ }
135
+ const store = await session.store(ctx.cwd);
136
+ const result = await store.mutate(id, { kind: sign }, personActor(), session.now());
137
+ if (!result.ok) return `board: ${result.error}`;
138
+ const done = sign === "approve" ? "approved" : "reviewed";
139
+ return result.changed
140
+ ? `board: ${cardRef(id)} ${done} by ${PERSON_LABEL}`
141
+ : `board: ${cardRef(id)} is already ${done} by ${PERSON_LABEL}`;
142
+ }
143
+
92
144
  /** One line for `/board status`: the page this session serves, watches, or does not have. */
93
145
  export function statusLine(status: BoardPageStatus, team?: JoinResult): string {
94
146
  if (status.running) {
95
147
  const watching = plural(status.watchers ?? 0, "tab");
96
- return `board: ${status.seat} serving on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
148
+ return `board: ${status.seat} serving ${status.repo} on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
97
149
  }
98
150
  if (status.stale === true) {
99
151
  return `board: watching ${status.seat} on :${status.port} — that note answers nobody; /board stop drops it${teamLine(team)}`;
@@ -101,5 +153,7 @@ export function statusLine(status: BoardPageStatus, team?: JoinResult): string {
101
153
  if (status.attached === true) {
102
154
  return `board: watching ${status.seat} on :${status.port} — nothing to stop here${teamLine(team)}`;
103
155
  }
104
- return "board: not running — /board opens it";
156
+ // The team outlives the page: a session that claimed a card is on it with no page open, so
157
+ // the line says which team this session is on whichever door put it there.
158
+ return `board: not running — /board opens it${teamLine(team)}`;
105
159
  }
@@ -11,15 +11,26 @@
11
11
  * The rollup's text is `lib/board/round-text.ts`, because `team` prints it too.
12
12
  */
13
13
 
14
- import { type Card, cardRef, DEFAULT_PRIORITY, dependsOf, priorityOf } from "../../lib/board/card.ts";
14
+ import { type Card, type CardBlock, cardRef, DEFAULT_PRIORITY, dependsOf, priorityOf } from "../../lib/board/card.ts";
15
15
  import { type BoardIndex, lanesText, openDependentsOf } from "../../lib/board/links.ts";
16
+ import { skippedText } from "../../lib/board/skipped.ts";
16
17
  import type { Skip } from "../../lib/board/store.ts";
17
- import { formatAge } from "../../lib/format.ts";
18
+ import { formatAge, shortenPath } from "../../lib/format.ts";
18
19
  import { plural } from "../../lib/board/round-text.ts";
19
20
 
21
+ /**
22
+ * What a claim adds to the card: the next step, in the order the board enforces.
23
+ *
24
+ * A claim is the one call that commits a session to a card, and the board will not record
25
+ * that card's plan until one exists — so the call that opens the work is the one place the
26
+ * order has to be said. It is a line of the transcript, not a field: nothing reads it back.
27
+ */
28
+ export const CLAIM_NEXT =
29
+ " next: design it in plan mode, `plan` action `write`, then `contract` with the path it returned";
30
+
20
31
  /** One card: the headline, then the state a reader needs in order to act on it. */
21
32
  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");
33
+ return [`${cardRef(card.id)} ${card.lane} · ${card.title}`, ...detailLines(card, board, now)].join("\n");
23
34
  }
24
35
 
25
36
  /**
@@ -50,9 +61,9 @@ export function formatBoard(shown: readonly Card[], notes: ListingNotes): string
50
61
  // them out; the count is what keeps the omission from reading as a smaller board.
51
62
  if (notes.hiddenDone > 0) {
52
63
  const them = notes.hiddenDone === 1 ? "it" : "them";
53
- lines.push(`${plural(notes.hiddenDone, "done card")} hidden; pass status: "done" to read ${them}.`);
64
+ lines.push(`${plural(notes.hiddenDone, "done card")} hidden; pass lane: "done" to read ${them}.`);
54
65
  }
55
- for (const note of [archiveNote(notes.archived), formatSkipped(notes.skipped)]) {
66
+ for (const note of [archiveNote(notes.archived), skippedText(notes.skipped)]) {
56
67
  if (note !== "") lines.push(note);
57
68
  }
58
69
  return lines.join("\n");
@@ -66,7 +77,7 @@ export function formatBoard(shown: readonly Card[], notes: ListingNotes): string
66
77
  */
67
78
  export function formatArchive(shown: readonly Card[], skipped: readonly Skip[]): string {
68
79
  const lines = shown.length === 0 ? ["The archive is empty."] : shown.map(listLine);
69
- const unreadable = formatSkipped(skipped);
80
+ const unreadable = skippedText(skipped);
70
81
  if (unreadable !== "") lines.push(unreadable);
71
82
  return lines.join("\n");
72
83
  }
@@ -81,18 +92,6 @@ export function archiveNote(count: number): string {
81
92
  return `${plural(count, "card")} ${count === 1 ? "is" : "are"} set aside in the archive; pass archived: true to read ${count === 1 ? "it" : "them"}.`;
82
93
  }
83
94
 
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
95
  /** The dropped id, which is the only thing left of the card. */
97
96
  export function formatDropped(id: string): string {
98
97
  return `${cardRef(id)} dropped, with its notes.`;
@@ -109,6 +108,12 @@ export function formatArchived(ids: readonly string[]): string {
109
108
  return `${plural(ids.length, "card")} archived: ${ids.map(cardRef).join(", ")} — out of the board, ${theirs} notes and plan still readable with archived: true.`;
110
109
  }
111
110
 
111
+ /** A block as one line: the sentence, and the kind beside it when one was recorded. */
112
+ function blockText(block: CardBlock): string {
113
+ const note = block.note ?? "nothing recorded";
114
+ return block.kind === undefined ? note : `${note} (${block.kind})`;
115
+ }
116
+
112
117
  /** A one-line listing: the id, the lane, the title, and who holds it. */
113
118
  function listLine(card: Card): string {
114
119
  const holder = card.owner === undefined ? "unassigned" : card.owner.label;
@@ -116,9 +121,9 @@ function listLine(card: Card): string {
116
121
  const waits = dependsOf(card).length === 0 ? "" : ` · waits on ${dependsOf(card).map(cardRef).join(", ")}`;
117
122
  // The two lanes that carry a fact of their own say it here: what a block waits on, and
118
123
  // 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}`;
124
+ const blocked = card.blocked === undefined ? "" : ` · blocked: ${blockText(card.blocked)}`;
120
125
  const landed = card.landed === undefined ? "" : ` · landed ${card.landed.commit}`;
121
- return `${cardRef(card.id)} ${card.status} · ${card.title} · ${holder}${urgency}${waits}${blocked}${landed}`;
126
+ return `${cardRef(card.id)} ${card.lane} · ${card.title} · ${holder}${urgency}${waits}${blocked}${landed}`;
122
127
  }
123
128
 
124
129
  /** The state lines under a card's headline. */
@@ -130,10 +135,24 @@ function detailLines(card: Card, board?: BoardIndex, now?: number): string[] {
130
135
  if (card.offeredAt !== undefined && now !== undefined) {
131
136
  lines.push(` offered: ${formatAge(now, card.offeredAt)} ago, and nobody took it`);
132
137
  }
133
- if (card.blockedBy !== undefined) lines.push(` blocked: ${card.blockedBy}`);
138
+ if (card.blocked !== undefined) lines.push(` blocked: ${blockText(card.blocked)}`);
134
139
  if (card.landed !== undefined) lines.push(` landed: ${card.landed.commit}`);
135
140
  if (priorityOf(card) !== DEFAULT_PRIORITY) lines.push(` priority: ${priorityOf(card)}`);
136
- if (card.plan !== undefined) lines.push(` plan: ${card.plan}`);
141
+ // The card stores the path resolved, so the line is shortened the way the `write` action shows
142
+ // it: an absolute home directory is noise in every read of the same card.
143
+ if (card.planPath !== undefined) lines.push(` plan: ${shortenPath(card.planPath)}`);
144
+ // The contract is what a reader is asked to agree to, so the block says it in full and
145
+ // says who signed it: an approval is a fact about that wording, and a review about the
146
+ // work, and neither is inferable from the lane.
147
+ if (card.contract !== undefined) {
148
+ lines.push(` goal: ${card.contract.goal}`);
149
+ lines.push(` acceptance: ${card.contract.acceptance}`);
150
+ lines.push(` scope out: ${card.contract.scopeOut}`);
151
+ const approved = card.contract.approved;
152
+ lines.push(` approved: ${approved === undefined ? "nobody yet" : approved.by}`);
153
+ }
154
+ if (card.review !== undefined) lines.push(` reviewed: ${card.review.by}`);
155
+
137
156
  if (card.paths.length > 0) lines.push(` paths: ${card.paths.join(", ")}`);
138
157
  if (dependsOf(card).length > 0) lines.push(` waits on: ${waitingOf(card, board)}`);
139
158
  // The mirror question, for the same board: what leaving this card undone holds up. Only
@@ -158,13 +177,13 @@ function detailLines(card: Card, board?: BoardIndex, now?: number): string[] {
158
177
  * The cards a card waits on, each with its lane when the board is there to say it.
159
178
  *
160
179
  * 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.
180
+ * start, and `#2 (execute)` is one to leave alone.
162
181
  */
163
182
  function waitingOf(card: Card, board: BoardIndex | undefined): string {
164
183
  return dependsOf(card)
165
184
  .map((id) => {
166
185
  const blocker = board?.get(id);
167
- return blocker === undefined ? cardRef(id) : `${cardRef(id)} (${blocker.status})`;
186
+ return blocker === undefined ? cardRef(id) : `${cardRef(id)} (${blocker.lane})`;
168
187
  })
169
188
  .join(", ");
170
189
  }
@@ -4,7 +4,7 @@
4
4
  * Every Pi session on a repository reads and writes the same cards, and `/board`
5
5
  * serves them as a page in the browser that follows the store live. A card is one
6
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.
7
+ * `todo`, and the board answers who is holding what and what needs a decision.
8
8
  *
9
9
  * The factory only registers. The store is opened on first use by the session, the
10
10
  * page starts when `/board` asks for it, and `session_shutdown` closes it — so a
@@ -15,8 +15,10 @@
15
15
 
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { isExtensionEnabled } from "../../lib/env.ts";
18
+ import { requestMount } from "../../lib/http/mount.ts";
18
19
  import { registerCommands } from "./commands.ts";
19
- import { type BoardPage, createBoardPage } from "./page.ts";
20
+ import { createLazyBoardPage } from "./lazy-page.ts";
21
+ import type { BoardPage } from "./page.ts";
20
22
  import { type BoardSession, openBoardSession } from "../../lib/board/session.ts";
21
23
  import { registerTools } from "./tools.ts";
22
24
 
@@ -30,9 +32,20 @@ export interface BoardDeps {
30
32
  export default function board(pi: ExtensionAPI, deps: BoardDeps = {}): void {
31
33
  if (!isExtensionEnabled("board")) return;
32
34
  const session = deps.session ?? openBoardSession(pi);
33
- const page = deps.page ?? createBoardPage(session);
35
+ /** The session the page serves as; the mount lands on this session's one server. */
36
+ let sessionId = "";
37
+ const page =
38
+ deps.page ??
39
+ createLazyBoardPage(session, {
40
+ mount: (spec) => requestMount(pi, spec),
41
+ session: () => sessionId,
42
+ });
34
43
  registerTools(pi, session);
35
- registerCommands(pi, page);
44
+ registerCommands(pi, session, page);
45
+
46
+ pi.on("session_start", async (_event, ctx) => {
47
+ sessionId = ctx.sessionManager.getSessionId();
48
+ });
36
49
 
37
50
  pi.on("session_shutdown", async () => {
38
51
  await page.closeAll();
@@ -1,14 +1,9 @@
1
1
  /**
2
- * A card body as both surfaces read it: the tool's arguments and the page's JSON.
2
+ * A card body as `add` reads it: the four fields the tool creates a card from.
3
3
  *
4
- * `add` is reachable two ways, and the fields it takes are the same four — title,
5
- * path hints, priority, and an optional first note. The normalization, the order the
6
- * refusals come in, and the shape handed to the store were written twice, which is how
7
- * a field added to one surface reaches the store unnormalized from the other. This is
8
- * that body's one owner.
9
- *
10
- * Only the *fields* are shared: a refusal is translated per surface, because the tool
11
- * answers a model with a failed result and the page answers a browser with a status.
4
+ * The normalization, the order the refusals come in, and the shape handed to the store are
5
+ * one body, so a field added to `add` cannot reach the store unnormalized. This is the
6
+ * boundary in front of `store.create`; a refusal is the first field that cannot be one.
12
7
  */
13
8
 
14
9
  import {