@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
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The `board` tool's parameters.
3
+ *
4
+ * One action-shaped tool, so every field is optional except the action and each
5
+ * action reads only its own — the description says which. `card` takes `4` or
6
+ * `#4`; the schema stays a string because `#` is how a person writes it and the
7
+ * boundary (`card.ts`) is where that is normalized.
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+ import { CARD_STATUSES, PRIORITIES } from "../../lib/board/card.ts";
12
+ import { BOARD_ACTIONS } from "./types.ts";
13
+
14
+ export const BoardParams = Type.Object({
15
+ action: Type.Union(
16
+ BOARD_ACTIONS.map((action) => Type.Literal(action)),
17
+ {
18
+ // The verbs and their meanings live in the tool description, built from
19
+ // `BOARD_ACTION_DOCS`; this says only what a caller needs to choose one.
20
+ description:
21
+ "`list` reads the board, or one card when `card` names it; every other verb changes it. The tool description says what each one does.",
22
+ },
23
+ ),
24
+ card: Type.Optional(
25
+ Type.String({
26
+ description:
27
+ 'The card id for one card, e.g. "4" or "#4". `list` reads that card; every other action but `add` acts on it.',
28
+ }),
29
+ ),
30
+ status: Type.Optional(
31
+ Type.Union(
32
+ CARD_STATUSES.map((status) => Type.Literal(status)),
33
+ {
34
+ description:
35
+ "`list`: show only this lane, and not skip the `done` cards. `move`: the lane to move to (not `todo` — use `release`).",
36
+ },
37
+ ),
38
+ ),
39
+ blockedBy: Type.Optional(
40
+ Type.String({
41
+ description:
42
+ "`move` to `blocked`: what the card waits on — the decision a person has to make, one line, at most 200 characters.",
43
+ }),
44
+ ),
45
+ title: Type.Optional(Type.String({ description: "`add` and `edit`: the feature's outcome, one line." })),
46
+ paths: Type.Optional(
47
+ Type.Array(Type.String(), {
48
+ description:
49
+ "`add` and `edit`: repository-relative hints (directories or files) where the work happens. Not claims.",
50
+ }),
51
+ ),
52
+ note: Type.Optional(Type.String({ description: "`add`: a first note, e.g. why the card exists." })),
53
+ text: Type.Optional(Type.String({ description: "`note`: the note to append." })),
54
+ path: Type.Optional(Type.String({ description: "`plan`: the plan file this card is executing." })),
55
+ priority: Type.Optional(
56
+ Type.Union(
57
+ PRIORITIES.map((priority) => Type.Literal(priority)),
58
+ {
59
+ description:
60
+ "`add` and `edit`: how urgent the feature is. `high` sorts first inside its lane; `normal` is the default.",
61
+ },
62
+ ),
63
+ ),
64
+ dependsOn: Type.Optional(
65
+ Type.Array(Type.String(), {
66
+ description:
67
+ "`edit`: the cards this one waits for, as ids. It cannot be claimed or moved to doing or done while one of them is unfinished; `[]` removes every link.",
68
+ }),
69
+ ),
70
+ mine: Type.Optional(
71
+ Type.Boolean({ description: "`list`: show only the cards this session holds — the queue a teammate works from." }),
72
+ ),
73
+ query: Type.Optional(
74
+ Type.String({
75
+ description:
76
+ "`list`: show only the cards whose title, holder, path hint, plan, or a note's text matches this text, case-insensitively.",
77
+ }),
78
+ ),
79
+ round: Type.Optional(
80
+ Type.Boolean({
81
+ description: "`list`: also return the lead's rollup — owners, unassigned, unplanned, blocked, idle.",
82
+ }),
83
+ ),
84
+ archived: Type.Optional(
85
+ Type.Boolean({
86
+ description:
87
+ "`list`: read the archive instead of the board — the finished cards `archive` set aside, which no other read opens. `query` and `mine` narrow it; `status` and `round` do not apply to a pile that is out of the board.",
88
+ }),
89
+ ),
90
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The seat: the name the page serves as, and the actor it is shown as.
3
+ *
4
+ * A page outlives the session that opened it — a `/resume`, a rename, or a fresh
5
+ * session taking the page over would otherwise change who the page says it is. So the
6
+ * page is a *seat* rather than a session: one fixed name, one fixed id derived from it,
7
+ * and the same identity however many sessions have served it.
8
+ *
9
+ * The page writes nothing, so the id decides no ownership: it is what the page's own
10
+ * roster and the `you` chip are read against, and it keeps a page's reads the same
11
+ * across a handover. The board's tools write as the session — which is why the label is
12
+ * shared with the session serving the page and the ids are not.
13
+ */
14
+
15
+ import type { Actor } from "../../lib/board/rules.ts";
16
+ import { normalizeLabel } from "../../lib/session-label.ts";
17
+
18
+ /** The name a repository's page always serves under, so the lead is a role and not a session. */
19
+ export const DEFAULT_SEAT = "team-lead";
20
+
21
+ /** The prefix of a page's actor id, so one is never mistaken for a session id. */
22
+ export const SEAT_ID_PREFIX = "board:";
23
+
24
+ /** The actor a page reads as: the seat, and an id derived from it. */
25
+ export function pageActor(label: string = DEFAULT_SEAT): Actor {
26
+ const seat = normalizeLabel(label, DEFAULT_SEAT);
27
+ return { id: `${SEAT_ID_PREFIX}${seat.toLowerCase()}`, label: seat };
28
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The page's snapshot: the one shape the browser is allowed to know, assembled from
3
+ * what this session can read.
4
+ *
5
+ * `lib/board/view.ts` builds the shape; this module is the wiring that feeds it — the
6
+ * cards, the bus's records when they are there, the store's size, and the clock. The
7
+ * bus is read as data in its own directory, so the page lists teammates when the bus
8
+ * has recorded them and draws exactly what it drew before when it has not.
9
+ */
10
+
11
+ import type { Actor } from "../../lib/board/rules.ts";
12
+ import type { BoardStore } from "../../lib/board/store.ts";
13
+ import { buildView, type BoardView } from "../../lib/board/view.ts";
14
+ import { readRoster } from "../../lib/session-roster.ts";
15
+
16
+ /** What a snapshot needs, all of it supplied by the page's lifecycle. */
17
+ export interface SnapshotContext {
18
+ store: BoardStore;
19
+ /** The actor every request is made and reported as: the page's seat. */
20
+ actor: () => Actor;
21
+ /** The session serving the page, which is who is reading, not who it writes as. */
22
+ session?: () => Actor;
23
+ now: () => number;
24
+ /** Where this repository's bus files its records. */
25
+ busDir: () => Promise<string>;
26
+ }
27
+
28
+ /** The snapshot the page renders, and the body of `/api/board`. */
29
+ export async function viewOf(context: SnapshotContext): Promise<BoardView> {
30
+ const roster = await readRoster(await context.busDir());
31
+ const session = context.session?.();
32
+ return buildView({
33
+ me: context.actor(),
34
+ ...(session === undefined ? {} : { session }),
35
+ cards: await context.store.list(),
36
+ now: context.now(),
37
+ bytes: await context.store.bytes(),
38
+ skipped: (await context.store.skipped()).length,
39
+ where: context.store.where(),
40
+ ...(roster === undefined ? {} : { roster }),
41
+ });
42
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * The `board` tool: the shared feature list for one repository.
3
+ *
4
+ * One action-shaped tool, because every action is work on the same record and a
5
+ * model that has read the board should not have to learn a second surface to change
6
+ * it. The tool owns nothing but the dispatch: the workspace comes from
7
+ * `lib/board/session.ts`, the transition from `lib/board/rules.ts`, the bytes from
8
+ * `lib/board/store.ts`, and the words from `format.ts` and `result.ts`.
9
+ *
10
+ * A refusal is a failed result that still carries the card, so the next call starts
11
+ * from the truth — and it is the same refusal the page shows, because both go
12
+ * through `applyOp`.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import type { Static } from "typebox";
17
+ import { cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
18
+ import { indexOf } from "../../lib/board/links.ts";
19
+ import { type CardOp, holds } from "../../lib/board/rules.ts";
20
+ import { buildRound } from "../../lib/board/round.ts";
21
+ import type { BoardStore } from "../../lib/board/store.ts";
22
+ import { messageOf } from "../../lib/errors.ts";
23
+ import { revParse } from "../../lib/git/read.ts";
24
+ import { strayField } from "../../lib/stray-fields.ts";
25
+ import { createExecRunner } from "../../lib/git/runner.ts";
26
+ import { failedResult, textResult } from "../../lib/tool-result.ts";
27
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
28
+ import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
29
+ import { formatRound } from "../../lib/board/round-text.ts";
30
+ import {
31
+ archiveNote,
32
+ formatArchive,
33
+ formatArchived,
34
+ formatBoard,
35
+ formatCard,
36
+ formatDropped,
37
+ formatSkipped,
38
+ } from "./format.ts";
39
+ import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
40
+ import { BoardResult, collapsedLine, resultInput } from "./result.ts";
41
+ import { createInput } from "./input.ts";
42
+ import type { BoardSession } from "../../lib/board/session.ts";
43
+ import { BoardParams } from "./schema.ts";
44
+ import {
45
+ ACTION_FIELDS,
46
+ BOARD_ACTION_DOCS,
47
+ BOARD_ACTIONS,
48
+ BOARD_MUTATING_ACTIONS,
49
+ BOARD_TOOL,
50
+ type BoardAction,
51
+ type BoardDetails,
52
+ normalizeBoardAction,
53
+ } from "./types.ts";
54
+
55
+ /** The verbs as one sentence, in `BOARD_ACTIONS` order; the table owns the words. */
56
+ function verbHelp(): string {
57
+ return `${BOARD_ACTIONS.map((action) => `\`${action}\` ${BOARD_ACTION_DOCS[action]}`).join("; ")}.`;
58
+ }
59
+
60
+ /** The parameters as the schema validates them. */
61
+ type BoardArgs = Static<typeof BoardParams>;
62
+
63
+ /**
64
+ * The call line's subject: the verb and what it acts on.
65
+ *
66
+ * `add` shows the title because the title is the identity of the thing being
67
+ * created; every other action names the card it touches, which is what a reader
68
+ * scanning the transcript is looking for.
69
+ */
70
+ function callSubject(action: BoardAction, args: Record<string, unknown>): string {
71
+ const card = typeof args.card === "string" && args.card.trim() !== "" ? cardRef(args.card.trim()) : "";
72
+ if (action === "add") {
73
+ const title = typeof args.title === "string" ? args.title.trim() : "";
74
+ return title === "" ? "add" : `add ${title}`;
75
+ }
76
+ if (action === "list") {
77
+ const parts = ["list"];
78
+ if (args.round === true) parts.push("round");
79
+ if (typeof args.status === "string") parts.push(args.status);
80
+ if (typeof args.query === "string" && args.query.trim() !== "") parts.push(`"${args.query.trim()}"`);
81
+ if (card !== "") parts.push(card);
82
+ return parts.join(" ");
83
+ }
84
+ if (card === "") return action;
85
+ const status = typeof args.status === "string" ? ` ${args.status}` : "";
86
+ return `${action} ${card}${status}`;
87
+ }
88
+
89
+ /** One refusal: the reason for the model, the state for the renderer. */
90
+ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, card?: BoardDetails["card"]) {
91
+ return failedResult(`Error: ${message}`, {
92
+ action,
93
+ me,
94
+ error: message,
95
+ ...(card === undefined ? {} : { card }),
96
+ });
97
+ }
98
+
99
+ /**
100
+ * The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
101
+ *
102
+ * A finished card records what closed it, and the commit is the one fact a repository can
103
+ * answer that the card cannot. A directory that is not a repository, or one with no
104
+ * commits, answers nothing — the move still happens, because finishing a card must not
105
+ * depend on git.
106
+ */
107
+ async function headCommit(pi: ExtensionAPI, cwd: string): Promise<{ commit: string } | undefined> {
108
+ const id = await revParse(createExecRunner(pi), cwd, "HEAD");
109
+ return id === undefined ? undefined : { commit: id.slice(0, 7) };
110
+ }
111
+
112
+ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { team?: TeamJoin } = {}): void {
113
+ const team = deps.team ?? createTeamJoin(pi);
114
+ pi.registerTool({
115
+ name: BOARD_TOOL,
116
+ label: "Board",
117
+ description:
118
+ "The shared feature list for this repository: one card per feature, which any session reads and writes. " +
119
+ "A card carries the feature's outcome, the session that holds it, path hints, the plan it is executing, " +
120
+ `its priority, what it waits for, and its notes. ${verbHelp()} A card's steps do not belong on the ` +
121
+ "board - they belong in the plan and in `todo`.",
122
+ promptSnippet: `The shared feature list for this repository: ${BOARD_ACTIONS.join(", ")}.`,
123
+ promptGuidelines: [
124
+ "Claim a card before working on it; release or finish it when you stop, and reclaim a card whose owner is gone.",
125
+ "Record the plan file with `board` action `plan` once the plan is approved, then execute it.",
126
+ "Note each phase of the work, and move the card to `done` or `blocked` when it changes state.",
127
+ "A card that waits on another is linked with `edit` and `dependsOn`, so the order is on the board instead of in a refusal.",
128
+ "A feature's steps belong in `todo`, not on the board: one card is one feature, whatever it takes.",
129
+ "A `handoff` in your inbox is work you now own: read it with `session inbox`, record the plan, and start it.",
130
+ "`paths` are hints for a reader, not claims; `session`'s path claims are what protect a file.",
131
+ ],
132
+ parameters: BoardParams,
133
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
134
+ executionMode: "sequential",
135
+
136
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
137
+ const args = params as BoardArgs;
138
+ const action = normalizeBoardAction(args.action);
139
+ if (!action) {
140
+ return refusal(
141
+ "list",
142
+ { label: "board" },
143
+ `unknown action "${String(args.action)}"; use ${BOARD_ACTIONS.join(", ")}.`,
144
+ );
145
+ }
146
+ const me = runtime.who(ctx, ctx.cwd);
147
+ try {
148
+ // Before the join and the store: a refused call has done nothing, so it cannot
149
+ // have enlisted this session as a teammate on its way to the refusal.
150
+ const stray = strayField(action, args, ACTION_FIELDS);
151
+ if (stray !== undefined) return refusal(action, me, stray);
152
+ // Working a card makes this session a teammate, once: the board is the user's
153
+ // surface for the team, so nobody has to know the bus exists. A failed join is
154
+ // not a failed card write — the board works with no bus at all — and it is not
155
+ // marked, so the next write asks again.
156
+ if (BOARD_MUTATING_ACTIONS.has(action) && !runtime.joined()) {
157
+ if ((await team.join(ctx)).ok) runtime.markJoined();
158
+ }
159
+ const store = await runtime.store(ctx.cwd);
160
+ const now = runtime.now();
161
+ switch (action) {
162
+ case "list":
163
+ return await read(store, action, me, args, now);
164
+ case "add":
165
+ return await add(store, me, args, now);
166
+ case "claim":
167
+ return await mutate(store, action, me, now, args, { kind: "claim" });
168
+ case "release":
169
+ return await mutate(store, action, me, now, args, { kind: "release" });
170
+ case "reclaim":
171
+ return await mutate(store, action, me, now, args, { kind: "reclaim" });
172
+ case "move": {
173
+ if (args.card === undefined) return refusal(action, me, 'card is required: pass the id, e.g. "4".');
174
+ const status = args.status;
175
+ if (status === undefined || status === "todo") {
176
+ return refusal(
177
+ action,
178
+ me,
179
+ 'move needs a target lane: "doing", "blocked", or "done". Use release to return a card to todo.',
180
+ );
181
+ }
182
+ // Only the lane that has something to say reads for it: a move to `done`
183
+ // records the commit that closed the card, and a board outside a
184
+ // repository simply has none to record.
185
+ const landed = status === "done" ? await headCommit(pi, ctx.cwd) : undefined;
186
+ return await mutate(store, action, me, now, args, {
187
+ kind: "move",
188
+ status,
189
+ ...(args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy }),
190
+ ...(landed === undefined ? {} : { landed }),
191
+ });
192
+ }
193
+ case "note":
194
+ return await mutate(store, action, me, now, args, { kind: "note", text: args.text });
195
+ case "plan":
196
+ return await mutate(store, action, me, now, args, { kind: "plan", path: args.path });
197
+ case "edit": {
198
+ if (
199
+ args.title === undefined &&
200
+ args.paths === undefined &&
201
+ args.priority === undefined &&
202
+ args.dependsOn === undefined
203
+ ) {
204
+ return refusal(action, me, "edit needs a title, paths, priority, or dependsOn.");
205
+ }
206
+ return await mutate(store, action, me, now, args, {
207
+ kind: "edit",
208
+ title: args.title,
209
+ paths: args.paths,
210
+ priority: args.priority,
211
+ dependsOn: args.dependsOn,
212
+ });
213
+ }
214
+ case "drop":
215
+ return await dropCard(store, me, args, now);
216
+ case "archive":
217
+ return await archiveCards(store, me, args, now);
218
+ }
219
+ } catch (error) {
220
+ return refusal(action, me, messageOf(error));
221
+ }
222
+ },
223
+
224
+ renderCall(args, theme, context) {
225
+ const action = normalizeBoardAction(args.action) ?? "list";
226
+ return callLine(theme, context.lastComponent, BOARD_TOOL, callSubject(action, args as Record<string, unknown>));
227
+ },
228
+
229
+ renderResult(result, options, theme, context) {
230
+ const text = reuseText(context?.lastComponent);
231
+ const details = toolDetails<BoardDetails>(result.details);
232
+ const outcome = detailsOrLine(theme, result, options.expanded, details);
233
+ if ("line" in outcome) {
234
+ text.setText(outcome.line);
235
+ return text;
236
+ }
237
+ const input = resultInput(outcome.details);
238
+ if (!input) {
239
+ text.setText(collapsedLine(theme, outcome.details));
240
+ return text;
241
+ }
242
+ const view =
243
+ context?.lastComponent instanceof BoardResult ? context.lastComponent : new BoardResult(input, theme);
244
+ view.setInput(input, theme);
245
+ return view;
246
+ },
247
+ });
248
+ }
249
+
250
+ /** `list`: the board, one card, the lead's rollup, or the archive. */
251
+ async function read(
252
+ store: BoardStore,
253
+ action: BoardAction,
254
+ me: BoardDetails["me"],
255
+ args: BoardArgs,
256
+ now: number,
257
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
258
+ if (args.archived === true) return readArchive(store, action, me, args, now);
259
+ const cards = await store.list();
260
+ // Read once: a card file the reader refused is the difference between "not there"
261
+ // and "there, and this build cannot read it".
262
+ const skipped = await store.skipped();
263
+ // A directory listing, not a read: a listing says what it left aside without opening one.
264
+ const archived = await store.archivedCount();
265
+ if (args.card !== undefined) {
266
+ const id = normalizeCardId(args.card);
267
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
268
+ const card = cards.find((entry) => entry.id === id);
269
+ if (!card) {
270
+ // Gone and set aside are different answers, and one read by id tells them apart.
271
+ if ((await store.read(id)) !== undefined) {
272
+ return refusal(action, me, `${cardRef(id)} is archived; pass archived: true to read it.`);
273
+ }
274
+ const unreadable = skipped.find((entry) => entry.id === id);
275
+ return refusal(
276
+ action,
277
+ me,
278
+ unreadable === undefined
279
+ ? `${cardRef(id)} does not exist.`
280
+ : `${cardRef(id)} is there and this build cannot read it (${unreadable.file}: ${unreadable.why}).`,
281
+ );
282
+ }
283
+ return textResult(formatCard(card, indexOf(cards), now), { action, me, card });
284
+ }
285
+ const listed = cards.filter(
286
+ (card) =>
287
+ (args.status === undefined || card.status === args.status) &&
288
+ (args.query === undefined || matchesQuery(card, args.query)) &&
289
+ // The same rule a claim is checked against, so "mine" cannot list a card this
290
+ // session could not release.
291
+ (args.mine !== true || holds(card, me)),
292
+ );
293
+ // A finished card is history rather than work, and nothing prunes it — this
294
+ // repository's own board is mostly finished cards — so a listing that names no lane
295
+ // leaves them out and says how many. A named lane, `card`, and `round` are unchanged;
296
+ // the rollup counts the whole board, which is what makes it a rollup.
297
+ const hiddenDone = args.status === undefined ? listed.filter((card) => card.status === "done").length : 0;
298
+ const shown = hiddenDone === 0 ? listed : listed.filter((card) => card.status !== "done");
299
+ if (args.round !== true) {
300
+ return textResult(formatBoard(shown, { hiddenDone, archived, skipped }), { action, me, cards: shown });
301
+ }
302
+ const round = buildRound(cards, now);
303
+ // One line under the rollup, because a reader who cannot see a card file should know why,
304
+ // and one for the archive, because a rollup that silently left it out would be a board
305
+ // smaller than the one on disk.
306
+ const notes = [formatSkipped(skipped), archiveNote(archived)].filter((line) => line !== "");
307
+ const text = [formatRound(cards, round), ...notes].join("\n");
308
+ return textResult(text, { action, me, cards: listed, round });
309
+ }
310
+
311
+ /**
312
+ * `list archived: true`: the cards set aside, which no board read opens.
313
+ *
314
+ * A lane and a rollup are answers about the board, so asking for the archive with either
315
+ * is refused rather than quietly ignored: the pile is one thing, and every card in it is
316
+ * finished. A query and `mine` narrow it, because both are questions about a card.
317
+ */
318
+ async function readArchive(
319
+ store: BoardStore,
320
+ action: BoardAction,
321
+ me: BoardDetails["me"],
322
+ args: BoardArgs,
323
+ now: number,
324
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
325
+ const wrong = (["status", "round"] as const).filter((field) => args[field] !== undefined);
326
+ if (wrong.length > 0) {
327
+ return refusal(
328
+ action,
329
+ me,
330
+ `archived does not read ${wrong.join(", ")}: the archive is one pile of finished cards, out of the board.`,
331
+ );
332
+ }
333
+ const pile = await store.archived();
334
+ if (args.card !== undefined) {
335
+ const id = normalizeCardId(args.card);
336
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
337
+ const card = pile.cards.find((entry) => entry.id === id);
338
+ if (!card) {
339
+ const onBoard = (await store.list()).some((entry) => entry.id === id);
340
+ return refusal(
341
+ action,
342
+ me,
343
+ onBoard ? `${cardRef(id)} is on the board, not in the archive.` : `${cardRef(id)} is not in the archive.`,
344
+ );
345
+ }
346
+ return textResult(formatCard(card, indexOf(pile.cards), now), { action, me, card });
347
+ }
348
+ const shown = pile.cards.filter(
349
+ (card) => (args.query === undefined || matchesQuery(card, args.query)) && (args.mine !== true || holds(card, me)),
350
+ );
351
+ return textResult(formatArchive(shown, pile.skipped), { action, me, cards: shown });
352
+ }
353
+
354
+ /** `add`: the only action that creates a card. */
355
+ async function add(
356
+ store: BoardStore,
357
+ me: BoardDetails["me"],
358
+ args: BoardArgs,
359
+ now: number,
360
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
361
+ const parsed = createInput({ title: args.title, paths: args.paths, priority: args.priority, note: args.note });
362
+ if ("error" in parsed) return refusal("add", me, parsed.error);
363
+ const card = await store.create(parsed.input, me, now);
364
+ return textResult(formatCard(card), { action: "add", me, card });
365
+ }
366
+
367
+ /** The actions that change a card the caller names. */
368
+ async function mutate(
369
+ store: BoardStore,
370
+ action: BoardAction,
371
+ me: BoardDetails["me"],
372
+ now: number,
373
+ args: BoardArgs,
374
+ op: CardOp,
375
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
376
+ if (args.card === undefined) return refusal(action, me, 'card is required: pass the id, e.g. "4".');
377
+ const id = normalizeCardId(args.card);
378
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
379
+ const result = await store.mutate(id, op, me, now);
380
+ if (!result.ok) return refusal(action, me, result.error, result.card);
381
+ return textResult(formatCard(result.card), { action, me, card: result.card });
382
+ }
383
+
384
+ /**
385
+ * `archive`: a finished card leaves the board without being destroyed.
386
+ *
387
+ * One card, or — with no card named — every finished card, because clearing a board is
388
+ * the errand and a lead should not have to run sixty calls to do it. Each move is its own
389
+ * compare-and-set, so a sweep is best-effort: it reports what it actually set aside.
390
+ */
391
+ async function archiveCards(
392
+ store: BoardStore,
393
+ me: BoardDetails["me"],
394
+ args: BoardArgs,
395
+ now: number,
396
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
397
+ if (args.card !== undefined) {
398
+ const id = normalizeCardId(args.card);
399
+ if (id === undefined) return refusal("archive", me, `"${args.card}" is not a card id.`);
400
+ const result = await store.mutate(id, { kind: "archive" }, me, now);
401
+ if (!result.ok) return refusal("archive", me, result.error, result.card);
402
+ // Asked twice is one state, not an error: the card is where the caller wanted it.
403
+ if (!result.changed) return textResult(`${cardRef(id)} is already archived.`, { action: "archive", me });
404
+ return textResult(formatArchived([id]), { action: "archive", me, card: result.card, archived: [id] });
405
+ }
406
+ const finished = (await store.list()).filter((card) => card.status === "done");
407
+ const moved: string[] = [];
408
+ const failed: Array<{ card: string; error: string }> = [];
409
+ for (const card of finished) {
410
+ const result = await store.mutate(card.id, { kind: "archive" }, me, now);
411
+ if (result.ok && result.changed) moved.push(card.id);
412
+ // A sweep is best-effort, and a card that changed underneath it is the one thing it
413
+ // cannot report by silence: "every finished card" must not read as fewer, unexplained.
414
+ else if (!result.ok) failed.push({ card: card.id, error: result.error });
415
+ }
416
+ if (moved.length === 0 && failed.length === 0) {
417
+ return textResult("No finished cards to archive.", { action: "archive", me });
418
+ }
419
+ const text = [
420
+ ...(moved.length === 0 ? [] : [formatArchived(moved)]),
421
+ ...failed.map((entry) => `${cardRef(entry.card)} was not archived (${entry.error})`),
422
+ ].join(" · ");
423
+ return textResult(text, {
424
+ action: "archive",
425
+ me,
426
+ ...(moved.length === 0 ? {} : { archived: moved }),
427
+ ...(failed.length === 0 ? {} : { failed }),
428
+ });
429
+ }
430
+
431
+ /** `drop`: the card and its notes leave together, so the report is the id. */
432
+ async function dropCard(
433
+ store: BoardStore,
434
+ me: BoardDetails["me"],
435
+ args: BoardArgs,
436
+ now: number,
437
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
438
+ if (args.card === undefined) return refusal("drop", me, 'card is required: pass the id, e.g. "4".');
439
+ const id = normalizeCardId(args.card);
440
+ if (id === undefined) return refusal("drop", me, `"${args.card}" is not a card id.`);
441
+ const result = await store.mutate(id, { kind: "drop" }, me, now);
442
+ if (!result.ok) return refusal("drop", me, result.error, result.card);
443
+ return textResult(formatDropped(id), { action: "drop", me, dropped: id });
444
+ }