@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,437 @@
1
+ /**
2
+ * The card lifecycle: every transition, as a pure function of the card, the
3
+ * operation, who asked, the clock, and the board's other cards.
4
+ *
5
+ * One table decides who may do what — the tool, the router, the page, and `team` all
6
+ * route through {@link applyOp} — so a lane change cannot be reachable from one
7
+ * surface and refused from another. Nothing here touches the filesystem or the bus:
8
+ * ownership is advisory (the bus's path claims are the lock), and the store owns the
9
+ * compare-and-set that makes a concurrent writer lose.
10
+ *
11
+ * The board index is a parameter rather than a lookup because three operations need a
12
+ * *different* card: a claim or a move is refused while a dependency is unfinished, and
13
+ * a drop is refused while somebody waits on the card. `lib/board/links.ts` answers
14
+ * those questions; this module decides what to do with the answer.
15
+ *
16
+ * Two shapes of failure, and the difference matters. A refusal is `{ ok: false }`
17
+ * with the reason, and the caller returns the card untouched. A *no-op* is
18
+ * `{ ok: true, changed: false }`: the operation's preconditions already hold
19
+ * (claiming a card you already hold), so nothing is written and the revision does
20
+ * not move.
21
+ *
22
+ * `add` is {@link createCard} rather than an operation, because it has no card to
23
+ * transition — and because a card is born `todo` with no owner, which is the one
24
+ * state the other operations never produce.
25
+ */
26
+
27
+ import {
28
+ type Card,
29
+ type CardOwner,
30
+ type CardStatus,
31
+ cardRef,
32
+ DEFAULT_PRIORITY,
33
+ dependsOf,
34
+ MAX_LOG,
35
+ MAX_NOTES,
36
+ normalizeBlockedBy,
37
+ normalizeNote,
38
+ normalizePathHints,
39
+ normalizePlanPath,
40
+ normalizePriority,
41
+ normalizeTitle,
42
+ type Priority,
43
+ priorityOf,
44
+ } from "./card.ts";
45
+ import { type BoardIndex, dependedOn, normalizeDepends, waitingOn } from "./links.ts";
46
+
47
+ /** Who is attempting an operation: a session when it has one, and the label it is addressed by. */
48
+ export interface Actor {
49
+ id?: string;
50
+ label: string;
51
+ }
52
+
53
+ /** One named mutation of one card. */
54
+ export type CardOp =
55
+ | { kind: "claim" }
56
+ /**
57
+ * Hand a card to `to`, and — from the page, which cannot message a peer — a note that
58
+ * says so. `team assign` sends a handoff instead and passes none.
59
+ */
60
+ | { kind: "assign"; to: Actor; note?: unknown }
61
+ | { kind: "release" }
62
+ /**
63
+ * Change lane. `blockedBy` says what a block waits on, and `landed` says which commit
64
+ * closed a card: both are read only by the lane they belong to, and both are optional,
65
+ * because a card may be blocked with nothing recorded and a `done` card may predate the
66
+ * field or live outside a repository.
67
+ */
68
+ | { kind: "move"; status: Exclude<CardStatus, "todo">; blockedBy?: unknown; landed?: { commit: string } }
69
+ // `text` and `path` are whatever the model sent: `card.ts` validates them, and the
70
+ // refusal it produces is the one the caller shows.
71
+ | { kind: "note"; text: unknown }
72
+ | { kind: "plan"; path: unknown }
73
+ // Every field but `kind` is optional, and an absent one is left as it was: the page
74
+ // sends the three it can show, and omitting a link must not clear it.
75
+ | { kind: "edit"; title: unknown; paths: unknown; priority: unknown; dependsOn: unknown }
76
+ | { kind: "reclaim" }
77
+ /**
78
+ * Record that the card was offered to the team: `team offer` broadcasts it on the bus
79
+ * and then writes this, because an event nobody answers otherwise leaves no trace.
80
+ */
81
+ | { kind: "offer" }
82
+ /**
83
+ * Set a finished card aside: the store moves its file out of the board, and the record
84
+ * itself is unchanged, because the pile its file lies in is the whole of the state.
85
+ */
86
+ | { kind: "archive" }
87
+ | { kind: "drop" };
88
+
89
+ /** What an operation did: the new card, or the reason it was refused. */
90
+ export type OpResult = { ok: true; card: Card; changed: boolean } | { ok: false; error: string };
91
+
92
+ /** A card's first state: `todo`, no owner, one note at most. */
93
+ export function createCard(
94
+ id: string,
95
+ input: { title: string; paths: string[]; note?: string; priority?: Priority },
96
+ actor: Actor,
97
+ now: number,
98
+ ): Card {
99
+ return {
100
+ v: 1,
101
+ id,
102
+ title: input.title,
103
+ status: "todo",
104
+ paths: [...input.paths],
105
+ // A new card says its priority out loud, even when it is the ordinary one, so a
106
+ // file on disk tells a reader the whole story without a default to know.
107
+ priority: input.priority ?? DEFAULT_PRIORITY,
108
+ notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
109
+ notesDropped: 0,
110
+ // A card is born in `todo`, and that is a transition like any other: a reader asking
111
+ // "how long has this been waiting" gets an answer rather than an absence.
112
+ log: [{ status: "todo", at: now, by: actor.label }],
113
+ logDropped: 0,
114
+ rev: 1,
115
+ createdAt: now,
116
+ updatedAt: now,
117
+ by: actor.label,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Whether `actor` is the owner of `card`.
123
+ *
124
+ * The id decides when both sides have one — a session that renamed itself is still
125
+ * the same holder — and the label decides otherwise, case-insensitively, the way
126
+ * the bus resolves a target.
127
+ */
128
+ export function holds(card: Card, actor: Actor): boolean {
129
+ const owner = card.owner;
130
+ if (owner === undefined) return false;
131
+ if (owner.id !== undefined && actor.id !== undefined) return owner.id === actor.id;
132
+ return owner.label.toLowerCase() === actor.label.toLowerCase();
133
+ }
134
+
135
+ /**
136
+ * Apply one operation.
137
+ *
138
+ * A refused operation returns the reason and no card: the caller already holds the
139
+ * card it read, and a "refused" copy of it would be a second version of the truth.
140
+ */
141
+ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board: BoardIndex): OpResult {
142
+ switch (op.kind) {
143
+ case "claim":
144
+ return claim(card, actor, now, board);
145
+ case "assign":
146
+ return assign(card, op.to, op.note, actor, now, board);
147
+ case "release":
148
+ return release(card, actor, now);
149
+ case "move":
150
+ return move(card, op, actor, now, board);
151
+ case "note":
152
+ return note(card, op.text, actor, now);
153
+ case "plan":
154
+ return plan(card, op.path, actor, now);
155
+ case "edit":
156
+ return edit(card, op, actor, now, board);
157
+ case "reclaim":
158
+ return reclaim(card, actor, now);
159
+ case "offer":
160
+ return offer(card, actor, now, board);
161
+ case "archive":
162
+ return archive(card);
163
+ case "drop":
164
+ return drop(card, actor, board);
165
+ }
166
+ }
167
+
168
+ function claim(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
169
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be claimed.` };
170
+ if (holds(card, actor)) return { ok: true, card, changed: false };
171
+ if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
172
+ const waiting = waitingOn(card, board);
173
+ if (waiting !== undefined) return { ok: false, error: waiting };
174
+ return {
175
+ ok: true,
176
+ card: write(card, actor, now, { status: "doing", owner: ownerOf(actor), offeredAt: undefined }),
177
+ changed: true,
178
+ };
179
+ }
180
+
181
+ function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: number, board: BoardIndex): OpResult {
182
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be assigned.` };
183
+ if (card.owner !== undefined && !holds(card, to)) return { ok: false, error: heldBy(card) };
184
+ if (holds(card, to) && card.status === "doing") return { ok: true, card, changed: false };
185
+ const waiting = waitingOn(card, board);
186
+ if (waiting !== undefined) return { ok: false, error: waiting };
187
+ const fields: Partial<Card> = { status: "doing", owner: ownerOf(to), offeredAt: undefined };
188
+ if (rawNote !== undefined) {
189
+ const parsed = normalizeNote(rawNote);
190
+ if ("error" in parsed) return { ok: false, error: parsed.error };
191
+ // One revision, not two: the history says who it went to in the same write that
192
+ // recorded the owner, so a reader never sees the assignment without the sentence.
193
+ Object.assign(fields, appendNote(card, actor, now, parsed.text));
194
+ }
195
+ return { ok: true, card: write(card, actor, now, fields), changed: true };
196
+ }
197
+
198
+ function release(card: Card, actor: Actor, now: number): OpResult {
199
+ // Finished work is history: every other transition refuses it, and a release that did
200
+ // not would be the one way a `done` card could go backwards — reopening the order a
201
+ // dependency was recorded against.
202
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be released.` };
203
+ if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} is not claimed.` };
204
+ if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Release is the owner's decision.` };
205
+ return {
206
+ ok: true,
207
+ card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
208
+ changed: true,
209
+ };
210
+ }
211
+
212
+ function move(
213
+ card: Card,
214
+ op: Extract<CardOp, { kind: "move" }>,
215
+ actor: Actor,
216
+ now: number,
217
+ board: BoardIndex,
218
+ ): OpResult {
219
+ const status = op.status;
220
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be moved.` };
221
+ if (card.owner === undefined) {
222
+ return { ok: false, error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${status}.` };
223
+ }
224
+ if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner can move it.` };
225
+ // A block's reason is checked before the no-op: a caller that repeats a move with a new
226
+ // sentence meant to change it, and a refusal is clearer than a silent nothing.
227
+ let blockedBy: string | undefined;
228
+ if (status === "blocked" && op.blockedBy !== undefined) {
229
+ const parsed = normalizeBlockedBy(op.blockedBy);
230
+ if ("error" in parsed) return { ok: false, error: parsed.error };
231
+ blockedBy = parsed.blockedBy;
232
+ }
233
+ if (card.status === status && blockedBy === undefined) return { ok: true, card, changed: false };
234
+ // Parking is what a link is for, so only the two lanes that put a card back in flight
235
+ // are gated: a card that waits may be blocked, it may not be taken or finished.
236
+ if (status !== "blocked") {
237
+ const waiting = waitingOn(card, board);
238
+ if (waiting !== undefined) return { ok: false, error: waiting };
239
+ }
240
+ return { ok: true, card: write(card, actor, now, laneFields(status, blockedBy, op, now)), changed: true };
241
+ }
242
+
243
+ /**
244
+ * The fields one lane change writes.
245
+ *
246
+ * `blockedBy` belongs to `blocked` alone: a card that leaves for `doing` or `done` has
247
+ * had its decision or stopped needing one, and a stale sentence would be read as a
248
+ * current one. `landed` belongs to `done` alone, and only when the caller could name a
249
+ * commit — a board outside git still finishes cards, it just cannot say with what.
250
+ */
251
+ function laneFields(
252
+ status: Exclude<CardStatus, "todo">,
253
+ blockedBy: string | undefined,
254
+ op: Extract<CardOp, { kind: "move" }>,
255
+ now: number,
256
+ ): Partial<Card> {
257
+ const fields: Partial<Card> = { status };
258
+ if (status === "blocked") {
259
+ if (blockedBy !== undefined) fields.blockedBy = blockedBy;
260
+ } else {
261
+ fields.blockedBy = undefined;
262
+ }
263
+ if (status === "done" && op.landed !== undefined) fields.landed = { commit: op.landed.commit, at: now };
264
+ return fields;
265
+ }
266
+
267
+ function note(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
268
+ const parsed = normalizeNote(raw);
269
+ if ("error" in parsed) return { ok: false, error: parsed.error };
270
+ return { ok: true, card: write(card, actor, now, appendNote(card, actor, now, parsed.text)), changed: true };
271
+ }
272
+
273
+ /** A card's notes after one more, with the oldest dropped and counted. */
274
+ function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<Card, "notes" | "notesDropped"> {
275
+ const notes = [...card.notes, { by: actor.label, at: now, text }];
276
+ const overflow = Math.max(0, notes.length - MAX_NOTES);
277
+ return {
278
+ notes: overflow === 0 ? notes : notes.slice(overflow),
279
+ notesDropped: card.notesDropped + overflow,
280
+ };
281
+ }
282
+
283
+ /** A card's log after one more transition, with the oldest dropped and counted. */
284
+ function appendTransition(card: Card, status: CardStatus, actor: Actor, now: number): Pick<Card, "log" | "logDropped"> {
285
+ const log = [...(card.log ?? []), { status, at: now, by: actor.label }];
286
+ const overflow = Math.max(0, log.length - MAX_LOG);
287
+ return {
288
+ log: overflow === 0 ? log : log.slice(overflow),
289
+ logDropped: (card.logDropped ?? 0) + overflow,
290
+ };
291
+ }
292
+
293
+ function plan(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
294
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; its plan cannot change.` };
295
+ if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner records a plan.` };
296
+ const parsed = normalizePlanPath(raw);
297
+ if ("error" in parsed) return { ok: false, error: parsed.error };
298
+ if (card.plan === parsed.path) return { ok: true, card, changed: false };
299
+ return { ok: true, card: write(card, actor, now, { plan: parsed.path }), changed: true };
300
+ }
301
+
302
+ /**
303
+ * Fix what a card says: its title, its hints, its priority, and what it waits for.
304
+ *
305
+ * The holder decides for a held card and anyone decides for an unclaimed one — and
306
+ * that includes a `done` card, because a title is what a reader reads, not a step of
307
+ * the work: a finished feature whose title says the wrong thing would stay wrong for
308
+ * as long as the board keeps history.
309
+ *
310
+ * Every field is optional and an absent one is left alone; a field that already holds
311
+ * the value asked for is not a write, so re-editing the same card is a no-op.
312
+ */
313
+ function edit(
314
+ card: Card,
315
+ op: Extract<CardOp, { kind: "edit" }>,
316
+ actor: Actor,
317
+ now: number,
318
+ board: BoardIndex,
319
+ ): OpResult {
320
+ if (card.owner !== undefined && !holds(card, actor)) {
321
+ return { ok: false, error: `${heldBy(card)} Only its owner can edit it.` };
322
+ }
323
+ const fields: Partial<Card> = {};
324
+ if (op.title !== undefined) {
325
+ const parsed = normalizeTitle(op.title);
326
+ if ("error" in parsed) return { ok: false, error: parsed.error };
327
+ if (parsed.title !== card.title) fields.title = parsed.title;
328
+ }
329
+ if (op.paths !== undefined) {
330
+ const parsed = normalizePathHints(op.paths);
331
+ if ("error" in parsed) return { ok: false, error: parsed.error };
332
+ if (!sameList(parsed.paths, card.paths)) fields.paths = parsed.paths;
333
+ }
334
+ if (op.priority !== undefined) {
335
+ const parsed = normalizePriority(op.priority);
336
+ if ("error" in parsed) return { ok: false, error: parsed.error };
337
+ if (parsed.priority !== priorityOf(card)) fields.priority = parsed.priority;
338
+ }
339
+ if (op.dependsOn !== undefined) {
340
+ const parsed = normalizeDepends(op.dependsOn, card.id, board);
341
+ if ("error" in parsed) return { ok: false, error: parsed.error };
342
+ if (!sameList(parsed.dependsOn, dependsOf(card))) fields.dependsOn = parsed.dependsOn;
343
+ }
344
+ if (Object.keys(fields).length === 0) return { ok: true, card, changed: false };
345
+ return { ok: true, card: write(card, actor, now, fields), changed: true };
346
+ }
347
+
348
+ /**
349
+ * Record that the card was offered to the team, and nobody has taken it.
350
+ *
351
+ * The same preconditions a claim would meet, because the offer asks a peer to claim: a
352
+ * held card, a finished one, or one waiting on unfinished work is refused before anything
353
+ * is broadcast. Re-offering refreshes the age rather than refusing as a no-op — a lead
354
+ * who offers again is asking again, and the age is the record of when they last did.
355
+ */
356
+ function offer(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
357
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be offered.` };
358
+ if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
359
+ const waiting = waitingOn(card, board);
360
+ if (waiting !== undefined) return { ok: false, error: waiting };
361
+ return { ok: true, card: write(card, actor, now, { offeredAt: now }), changed: true };
362
+ }
363
+
364
+ function reclaim(card: Card, actor: Actor, now: number): OpResult {
365
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
366
+ if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} has no owner to reclaim.` };
367
+ return {
368
+ ok: true,
369
+ card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
370
+ changed: true,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Set a finished card aside, out of the board and into the archive beside it.
376
+ *
377
+ * Only a finished card leaves this way: `done` is terminal, and a card still in flight
378
+ * has an owner, a plan, and a decision in it. No field moves and no transition is
379
+ * appended — the record is unchanged, and what changed is which pile its file lies in,
380
+ * which is the store's write. `changed` is still true, because the board lost a card.
381
+ */
382
+ function archive(card: Card): OpResult {
383
+ if (card.status !== "done") {
384
+ return { ok: false, error: `${cardRef(card.id)} is in ${card.status}; only a finished card can be archived.` };
385
+ }
386
+ return { ok: true, card, changed: true };
387
+ }
388
+
389
+ function drop(card: Card, actor: Actor, board: BoardIndex): OpResult {
390
+ // A drop takes its id with it, so a card another one waits on cannot leave: the
391
+ // remaining link would name nothing, and nothing on the board would say so.
392
+ const depended = dependedOn(card.id, board);
393
+ if (depended !== undefined) return { ok: false, error: depended };
394
+ if (card.status === "todo" || card.status === "done") return { ok: true, card, changed: true };
395
+ if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} It cannot be dropped while someone holds it.` };
396
+ return { ok: true, card, changed: true };
397
+ }
398
+
399
+ /** Whether two string lists are the same list, in the same order. */
400
+ function sameList(left: readonly string[], right: readonly string[]): boolean {
401
+ return left.length === right.length && left.every((entry, index) => entry === right[index]);
402
+ }
403
+
404
+ /**
405
+ * A card with `fields` applied and its write marks moved.
406
+ *
407
+ * This is the one place a lane change is observed, because it is the one place every
408
+ * transition ends: a note, a plan, or an edit writes fields and leaves the lane alone,
409
+ * while a claim, an assign, a release, a reclaim, and a move all name the new one. So
410
+ * the log is appended from here rather than from each operation, and an operation added
411
+ * later cannot forget it.
412
+ */
413
+ function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
414
+ const status = fields.status;
415
+ const transition =
416
+ status !== undefined && status !== card.status ? appendTransition(card, status, actor, now) : undefined;
417
+ const next: Card = { ...card, ...fields, ...transition, rev: card.rev + 1, updatedAt: now, by: actor.label };
418
+ // `owner: undefined` must clear the field rather than store a key holding it, so a
419
+ // released card reads back exactly like one that was never claimed. Only an
420
+ // explicit `owner` in `fields` may delete it: every other write keeps the holder.
421
+ if ("owner" in fields && fields.owner === undefined) delete next.owner;
422
+ // The same for the block: a card that left `blocked` has no reason, and a key holding
423
+ // undefined would be one more shape for a reader to know about.
424
+ if ("blockedBy" in fields && fields.blockedBy === undefined) delete next.blockedBy;
425
+ if ("offeredAt" in fields && fields.offeredAt === undefined) delete next.offeredAt;
426
+ return next;
427
+ }
428
+
429
+ /** The owner recorded for an actor: the id only when this actor has one. */
430
+ function ownerOf(actor: Actor): CardOwner {
431
+ return actor.id === undefined ? { label: actor.label } : { id: actor.id, label: actor.label };
432
+ }
433
+
434
+ /** The refusal for an operation on a card someone else holds. */
435
+ function heldBy(card: Card): string {
436
+ return `${cardRef(card.id)} is held by ${card.owner?.label ?? "another session"}.`;
437
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * What a board call works on, and who it writes as.
3
+ *
4
+ * One store per working directory, opened on first use and kept for the session:
5
+ * a tool call is frequent and the repository lookup shells out to git, so the
6
+ * answer is remembered per `cwd`. The identity is the same pair a card records —
7
+ * the session id Pi knows, and the label `session-bus` would settle by default —
8
+ * which is why the board can name its owner without reading the bus.
9
+ *
10
+ * The store and its cache live in this closure, not in a module variable: Pi loads
11
+ * each extension with an isolated module cache, so module state would be one copy
12
+ * per extension and none of them the same one.
13
+ */
14
+
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+ import { basename, join } from "node:path";
18
+ import { defaultLabel, sessionNameOf } from "../session-label.ts";
19
+ import { BUS_DIR_NAME } from "../session-roster.ts";
20
+ import type { Actor } from "./rules.ts";
21
+ import { type BoardStore, openBoard } from "./store.ts";
22
+
23
+ /** What a tool, a page, and a round need from the session, without any of them owning it. */
24
+ export interface BoardSession {
25
+ /** The board for `cwd`'s repository. */
26
+ store(cwd: string): Promise<BoardStore>;
27
+ /** The session a write is made as. */
28
+ who(ctx: ExtensionContext, cwd: string): Actor;
29
+ /** The clock, so a test drives a deadline instead of waiting on one. */
30
+ now(): number;
31
+ /**
32
+ * Where this repository's session bus files its records, for the page's roster.
33
+ *
34
+ * A path, not a dependency: the board reads those files when they are there and
35
+ * behaves exactly as before when they are not, which is what keeps the page working
36
+ * with `session-bus` unloaded.
37
+ */
38
+ busDir(cwd: string): Promise<string>;
39
+ /** Whether this session has already joined the team, so a card write joins once. */
40
+ joined(): boolean;
41
+ /** Record that the join landed; a failed one leaves this false so the next write asks. */
42
+ markJoined(): void;
43
+ }
44
+
45
+ /** Test seams and the one place a caller may redirect the store. */
46
+ export interface BoardSessionOptions {
47
+ now?: () => number;
48
+ agentDir?: string;
49
+ }
50
+
51
+ /**
52
+ * Build the session around one extension's `pi`.
53
+ *
54
+ * Shared by `board` and `team`: both work on the same store and must name a write
55
+ * as the same session, and two copies are how a card's owner label drifts from the
56
+ * label the board writes.
57
+ */
58
+ export function openBoardSession(pi: ExtensionAPI, options: BoardSessionOptions = {}): BoardSession {
59
+ const stores = new Map<string, Promise<BoardStore>>();
60
+ const busDirs = new Map<string, Promise<string>>();
61
+ let teamJoined = false;
62
+ const agentDir = options.agentDir;
63
+ const session: BoardSession = {
64
+ store(cwd) {
65
+ const cached = stores.get(cwd);
66
+ if (cached) return cached;
67
+ const opened = openBoard(pi, cwd, agentDir === undefined ? {} : { agentDir });
68
+ stores.set(cwd, opened);
69
+ return opened;
70
+ },
71
+ who(ctx, cwd) {
72
+ const id = sessionIdOf(ctx);
73
+ const label = defaultLabel(sessionNameOf(ctx), cwd, id ?? "session");
74
+ return id === undefined ? { label } : { id, label };
75
+ },
76
+ now: options.now ?? Date.now,
77
+ // The board's own directory is `<agentDir>/board/<key>` and the bus's is
78
+ // `<agentDir>/session-bus/<key>`, both keyed by the same repository, so the key is
79
+ // read back off the board rather than resolved a second time through git.
80
+ busDir(cwd) {
81
+ const cached = busDirs.get(cwd);
82
+ if (cached) return cached;
83
+ const resolved = session
84
+ .store(cwd)
85
+ .then((store) => join(agentDir ?? getAgentDir(), BUS_DIR_NAME, basename(store.dir)));
86
+ busDirs.set(cwd, resolved);
87
+ return resolved;
88
+ },
89
+ joined: () => teamJoined,
90
+ markJoined: () => {
91
+ teamJoined = true;
92
+ },
93
+ };
94
+ return session;
95
+ }
96
+
97
+ /** The session id, or undefined when the host does not report one. */
98
+ function sessionIdOf(ctx: ExtensionContext): string | undefined {
99
+ try {
100
+ const id = ctx.sessionManager.getSessionId();
101
+ return typeof id === "string" && id !== "" ? id : undefined;
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }