@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,497 @@
1
+ /**
2
+ * The board store: one directory of card files per repository, and the only code
3
+ * that writes one.
4
+ *
5
+ * A board is a directory — `<agentDir>/board/<repo-key>/cards/0004.json` — so a
6
+ * reader needs no index, no daemon, and no dependency; the listing *is* the
7
+ * index. The key comes from `lib/repo-key.ts`, so every worktree of one repository
8
+ * shares one board, and a directory outside git keys on itself.
9
+ *
10
+ * Two writes race safely without a lock. **Create** hard-links a temporary file
11
+ * onto the target name, which fails when the name is taken, so two adds cannot
12
+ * share an id and the loser simply retries with the next one. **Update** re-reads,
13
+ * re-checks the operation's preconditions, and renames over the old file, so a
14
+ * card that changed underneath is re-evaluated rather than overwritten: an
15
+ * operation says "apply when this holds", never "force this state".
16
+ *
17
+ * A write reads the whole board once, because `rules.ts` needs another card to answer
18
+ * three of its questions: is this dependency finished, would this link close a loop,
19
+ * and does anything wait on the card being dropped. The listing is the index here —
20
+ * a board is tens of files, so the read is cheaper than the second source of truth a
21
+ * cached index would be.
22
+ *
23
+ * Nothing prunes by itself. `cards/` is the board and `archive/` is where a finished card
24
+ * is set aside by `archive` — its file moved rather than rewritten, so the record is
25
+ * unchanged and the board stops paying for it — and `ended/` keeps the record of a `drop`
26
+ * alone. The page shows the store's size, and `drop` is the only deletion.
27
+ */
28
+
29
+ import { link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
30
+ import { join } from "node:path";
31
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
32
+ import { resolveEffectiveCwd } from "../env.ts";
33
+ import { hasErrorCode, messageOf } from "../errors.ts";
34
+ import { shortenPath } from "../format.ts";
35
+ import { repoKeyFor } from "../repo-key.ts";
36
+ import {
37
+ type Card,
38
+ CARD_STATUSES,
39
+ CARD_VERSION,
40
+ cardRef,
41
+ type EndedCard,
42
+ isCard,
43
+ type Priority,
44
+ priorityRank,
45
+ } from "./card.ts";
46
+ import { indexOf } from "./links.ts";
47
+ import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
48
+
49
+ /** The directory under the agent directory that holds every board. */
50
+ export const BOARD_DIR_NAME = "board";
51
+
52
+ /** The subdirectory one board's cards live in. */
53
+ export const CARDS_DIR_NAME = "cards";
54
+
55
+ /** The subdirectory a dropped card leaves its record in. */
56
+ export const ENDED_DIR_NAME = "ended";
57
+
58
+ /**
59
+ * The subdirectory a finished card is set aside in.
60
+ *
61
+ * A second pile, not a second state: the file is moved here unchanged, so its notes, plan,
62
+ * and landed commit stay readable, and the board's own reads stop opening it.
63
+ */
64
+ export const ARCHIVE_DIR_NAME = "archive";
65
+
66
+ /** Card files are the user's work; only their owner reads them. */
67
+ const FILE_MODE = 0o600;
68
+
69
+ /** A board directory is one session's private state. */
70
+ const DIR_MODE = 0o700;
71
+
72
+ /** An in-flight temp file older than this was left by a process that died. */
73
+ const TEMP_STALE_MS = 60 * 60 * 1_000;
74
+
75
+ /** How many times an id collision or a changed card is retried before giving up. */
76
+ const ATTEMPTS = 4;
77
+
78
+ /** A card to add. The fields are already normalized by `card.ts`. */
79
+ export interface CreateInput {
80
+ title: string;
81
+ paths: string[];
82
+ note?: string;
83
+ /** Absent is `normal`, so an add that says nothing about urgency says the ordinary thing. */
84
+ priority?: Priority;
85
+ }
86
+
87
+ /** What a mutation did, or why it was refused. */
88
+ export type MutateResult =
89
+ | { ok: true; card: Card; changed: boolean; dropped: boolean; archived: boolean }
90
+ | { ok: false; error: string; card?: Card };
91
+
92
+ /**
93
+ * A card file the reader refused, and why.
94
+ *
95
+ * The lanes skip these, so without this the board reads as if they were never there:
96
+ * "nobody has planned anything" instead of "this build cannot read your work".
97
+ */
98
+ export interface Skip {
99
+ /** The file name, e.g. `0042.json`. */
100
+ file: string;
101
+ /** The id the file name spells, when it spells one, so a caller can match a refusal. */
102
+ id?: string;
103
+ /** What is wrong, phrased for a reader: `bad JSON`, `version 2`. */
104
+ why: string;
105
+ }
106
+
107
+ /** One repository's board, as the runtime, the router, and the tool use it. */
108
+ export interface BoardStore {
109
+ /** The board's own directory, for `where` and for a refusal that names it. */
110
+ readonly dir: string;
111
+ /** Create the directories and sweep a temp file a dead process left. */
112
+ ensure(): Promise<void>;
113
+ /** Every card on the board, in lane order: todo, doing, blocked, done. */
114
+ list(): Promise<Card[]>;
115
+ /**
116
+ * The cards set aside in the archive, in the same order, and the files there this build
117
+ * refused. Read only when a caller asks: no other read opens the pile.
118
+ */
119
+ archived(): Promise<{ cards: Card[]; skipped: Skip[] }>;
120
+ /** How many card files the archive holds, without opening one: the count a listing prints. */
121
+ archivedCount(): Promise<number>;
122
+ /** One card by its unpadded id, from whichever pile its file lies in. */
123
+ read(id: string): Promise<Card | undefined>;
124
+ /** Add a card, allocating the next id. */
125
+ create(input: CreateInput, actor: Actor, now: number): Promise<Card>;
126
+ /**
127
+ * Apply one operation, re-checking it if the card changed while it was read. A card is
128
+ * found in whichever pile it lies in, and written back to that one.
129
+ */
130
+ mutate(id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult>;
131
+ /** Bytes the board's cards occupy on disk; the archive is not counted. */
132
+ bytes(): Promise<number>;
133
+ /** Card files this build refused to read, and why. */
134
+ skipped(): Promise<Skip[]>;
135
+ /** A short, user-facing name for the directory. */
136
+ where(): string;
137
+ }
138
+
139
+ /** Where one repository's board lives. */
140
+ export function boardDirFor(agentDir: string, key: string): string {
141
+ return join(agentDir, BOARD_DIR_NAME, key);
142
+ }
143
+
144
+ /** The board for `cwd`'s repository. The one construction point. */
145
+ export async function openBoard(
146
+ pi: Pick<ExtensionAPI, "exec">,
147
+ cwd: string,
148
+ options: { agentDir?: string } = {},
149
+ ): Promise<BoardStore> {
150
+ const agentDir = options.agentDir ?? getAgentDir();
151
+ // A tool receives the session's cwd, not the worktree the user entered; the
152
+ // environment carries the active root, so the two are reconciled before the
153
+ // repository lookup — the same path `plan` and `session-bus` take.
154
+ const repo = await repoKeyFor(pi, resolveEffectiveCwd(cwd));
155
+ return createStore(boardDirFor(agentDir, repo.key));
156
+ }
157
+
158
+ /** Build a store over one directory. Exported for tests and for a second backend. */
159
+ export function createStore(dir: string): BoardStore {
160
+ const cardsDir = join(dir, CARDS_DIR_NAME);
161
+ const endedDir = join(dir, ENDED_DIR_NAME);
162
+ const archiveDir = join(dir, ARCHIVE_DIR_NAME);
163
+
164
+ const ensure = async (): Promise<void> => {
165
+ await mkdir(cardsDir, { recursive: true, mode: DIR_MODE });
166
+ await sweepTemps(cardsDir);
167
+ // An archived card that was edited leaves a temp file in its own pile, and a process
168
+ // that died mid-write there is the same mess as one on the board.
169
+ await sweepTemps(archiveDir);
170
+ };
171
+
172
+ /** Every card file of one pile, read once: what it shows and what it refused. */
173
+ const scan = async (pileDir: string): Promise<{ cards: Card[]; skipped: Skip[] }> => {
174
+ const entries = await readdirSafe(pileDir);
175
+ const cards: Card[] = [];
176
+ const skipped: Skip[] = [];
177
+ for (const entry of entries) {
178
+ const read = await readCardFile(pileDir, entry);
179
+ if ("card" in read) cards.push(read.card);
180
+ // Anything that is not a card-named file is not the board's to explain.
181
+ else if (entry.endsWith(".json")) skipped.push(read.skip);
182
+ }
183
+ // Sorted by file name so two reads of one board say the same thing in the same order.
184
+ return { cards: sortForLane(cards), skipped: skipped.sort((a, b) => a.file.localeCompare(b.file)) };
185
+ };
186
+
187
+ const list = async (): Promise<Card[]> => (await scan(cardsDir)).cards;
188
+
189
+ const archived = async (): Promise<{ cards: Card[]; skipped: Skip[] }> => scan(archiveDir);
190
+
191
+ // A directory listing and a name pattern, with no file opened: a plain `list` prints this
192
+ // count so the archive it left out cannot read as history that was deleted.
193
+ const archivedCount = async (): Promise<number> =>
194
+ (await readdirSafe(archiveDir)).filter((entry) => CARD_FILE.test(entry)).length;
195
+
196
+ const skipped = async (): Promise<Skip[]> => (await scan(cardsDir)).skipped;
197
+
198
+ /**
199
+ * One card and the pile its file is in, the board first.
200
+ *
201
+ * A card is where it lies: the archive is not a state a record carries, so every read by
202
+ * id has to look in both places, and every write goes back to the one it was read from.
203
+ */
204
+ const locate = async (id: string): Promise<{ card: Card; pile: string } | undefined> => {
205
+ for (const pile of [cardsDir, archiveDir]) {
206
+ const result = await readCardFile(pile, fileNameFor(id));
207
+ if ("card" in result) return { card: result.card, pile };
208
+ }
209
+ return undefined;
210
+ };
211
+
212
+ const read = async (id: string): Promise<Card | undefined> => (await locate(id))?.card;
213
+
214
+ const create = async (input: CreateInput, actor: Actor, now: number): Promise<Card> => {
215
+ await ensure();
216
+ let lastError: unknown;
217
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
218
+ const id = await nextCardId(cardsDir, endedDir, archiveDir);
219
+ const card = createCard(id, input, actor, now);
220
+ const temp = tempPath(cardsDir);
221
+ await writeFile(temp, serialize(card), { mode: FILE_MODE });
222
+ try {
223
+ // The exclusive create: two adds cannot produce one card, and the
224
+ // loser retries with the id it just learned was taken.
225
+ await link(temp, join(cardsDir, fileNameFor(id)));
226
+ return card;
227
+ } catch (error) {
228
+ lastError = error;
229
+ if (!hasErrorCode(error, "EEXIST")) throw error;
230
+ } finally {
231
+ await rm(temp, { force: true });
232
+ }
233
+ }
234
+ throw new Error(`The board could not allocate a card id (${messageOf(lastError)}).`);
235
+ };
236
+
237
+ const mutate = async (id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult> => {
238
+ let changedUnderneath = false;
239
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
240
+ const found = await locate(id);
241
+ if (!found) {
242
+ if (changedUnderneath) return { ok: false, error: `${cardRef(id)} was dropped.` };
243
+ // A file that is there and refused is not a card that does not exist: the
244
+ // reader is told which file to look at instead of being sent to add one.
245
+ const skip = (await scan(cardsDir)).skipped.find((entry) => entry.id === id);
246
+ return {
247
+ ok: false,
248
+ error:
249
+ skip === undefined
250
+ ? `${cardRef(id)} does not exist.`
251
+ : `${cardRef(id)} is there and this build cannot read it (${skip.file}: ${skip.why}).`,
252
+ };
253
+ }
254
+ const { card, pile } = found;
255
+ // The preconditions are checked against the card as it is *now*, and against the
256
+ // board as it is now, so an operation re-applied to a card that changed is refused
257
+ // rather than forced.
258
+ const result = applyOp(card, op, actor, now, indexOf(await list()));
259
+ if (!result.ok) return { ok: false, error: result.error, card };
260
+ if (!result.changed) {
261
+ return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
262
+ }
263
+ if (op.kind === "drop") {
264
+ // The record lands first: a crash between the two leaves a card that still
265
+ // exists with a note explaining it, never a card that is gone unexplained.
266
+ await writeEnded(endedDir, result.card, actor, now);
267
+ await rm(join(pile, fileNameFor(id)), { force: true });
268
+ return { ok: true, card: result.card, changed: true, dropped: true, archived: false };
269
+ }
270
+ if (op.kind === "archive") {
271
+ // A card the board does not hold is already aside, and two sessions asking at
272
+ // once are one move rather than a second one.
273
+ if (pile === archiveDir) {
274
+ return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
275
+ }
276
+ await moveCard(cardsDir, archiveDir, id);
277
+ return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
278
+ }
279
+ // Last look before the rename: a writer that landed in between is the
280
+ // re-read the next pass applies against.
281
+ const current = await read(id);
282
+ if (!current || current.rev !== card.rev) {
283
+ changedUnderneath = true;
284
+ continue;
285
+ }
286
+ await writeCardFile(pile, id, result.card);
287
+ return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
288
+ }
289
+ return {
290
+ ok: false,
291
+ error: `${cardRef(id)} kept changing while it was written; read it again.`,
292
+ card: await read(id),
293
+ };
294
+ };
295
+
296
+ const bytes = async (): Promise<number> => {
297
+ const entries = await readdirSafe(cardsDir);
298
+ let total = 0;
299
+ for (const entry of entries) {
300
+ if (!CARD_FILE.test(entry)) continue;
301
+ const info = await stat(join(cardsDir, entry)).catch(() => undefined);
302
+ if (info) total += info.size;
303
+ }
304
+ return total;
305
+ };
306
+
307
+ return {
308
+ dir,
309
+ ensure,
310
+ list,
311
+ archived,
312
+ archivedCount,
313
+ read,
314
+ create,
315
+ mutate,
316
+ bytes,
317
+ skipped,
318
+ where: () => shortenPath(dir),
319
+ };
320
+ }
321
+
322
+ /** `<id>.json`, zero-padded to four digits so a directory lists in card order. */
323
+ function fileNameFor(id: string): string {
324
+ return `${id.padStart(4, "0")}.json`;
325
+ }
326
+
327
+ /** The card files the reader accepts. */
328
+ const CARD_FILE = /^(\d{4,})\.json$/;
329
+
330
+ /** One card file: the card, or the reason this build refused it. */
331
+ async function readCardFile(pileDir: string, entry: string): Promise<{ card: Card } | { skip: Skip }> {
332
+ const match = CARD_FILE.exec(entry);
333
+ if (!match) return { skip: { file: entry, why: "not a card id" } };
334
+ const id = String(Number(match[1]));
335
+ const text = await readFile(join(pileDir, entry), "utf-8").catch(() => undefined);
336
+ if (text === undefined) return { skip: { file: entry, id, why: "cannot be read" } };
337
+ let parsed: unknown;
338
+ try {
339
+ parsed = JSON.parse(text);
340
+ } catch {
341
+ return { skip: { file: entry, id, why: "bad JSON" } };
342
+ }
343
+ // A version above this build's is the one refusal a reader can act on: the file was
344
+ // written by a newer build, so the board is not empty, it is not readable here.
345
+ const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
346
+ if (typeof version === "number" && version !== CARD_VERSION) {
347
+ return { skip: { file: entry, id, why: `version ${version}` } };
348
+ }
349
+ if (!isCard(parsed)) return { skip: { file: entry, id, why: "not a card record" } };
350
+ // The file name is the id's canonical spelling: a padded id inside the record
351
+ // (or a record moved under another name) is a disagreement, not a card.
352
+ if (parsed.id !== String(Number(match[1]))) {
353
+ return { skip: { file: entry, id, why: `names card ${parsed.id}` } };
354
+ }
355
+ return { card: parsed };
356
+ }
357
+
358
+ /** The highest id a directory holds, or 0 when it holds none. */
359
+ async function highestId(dir: string): Promise<number> {
360
+ const entries = await readdirSafe(dir);
361
+ let highest = 0;
362
+ for (const entry of entries) {
363
+ const match = CARD_FILE.exec(entry);
364
+ if (match) highest = Math.max(highest, Number(match[1]));
365
+ }
366
+ return highest;
367
+ }
368
+
369
+ /**
370
+ * `1`, `2`, `3` … as the highest id the board has ever used plus one.
371
+ *
372
+ * Ended and archived cards count: a dropped card's record keeps its id spent, and an
373
+ * archived card's file is still named by it, so a note or a handoff that names `#29`
374
+ * cannot come to mean a different feature tomorrow.
375
+ */
376
+ async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string): Promise<string> {
377
+ return String(Math.max(await highestId(cardsDir), await highestId(endedDir), await highestId(archiveDir)) + 1);
378
+ }
379
+
380
+ /**
381
+ * Write the record of a card that has ended.
382
+ *
383
+ * Exclusive like a create, so the id ends once: a retry that finds the record already
384
+ * there is the same end, not a second one.
385
+ */
386
+ async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
387
+ await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
388
+ const ended: EndedCard = {
389
+ v: 1,
390
+ id: card.id,
391
+ title: card.title,
392
+ status: card.status,
393
+ by: actor.label,
394
+ ...(card.owner === undefined ? {} : { owner: card.owner }),
395
+ at: now,
396
+ why: "dropped",
397
+ };
398
+ const temp = tempPath(endedDir);
399
+ await writeFile(temp, serialize(ended), { mode: FILE_MODE });
400
+ try {
401
+ await link(temp, join(endedDir, fileNameFor(card.id)));
402
+ } catch (error) {
403
+ if (!hasErrorCode(error, "EEXIST")) throw error;
404
+ } finally {
405
+ await rm(temp, { force: true });
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Move one card's file into the archive.
411
+ *
412
+ * A rename, not a write: the record does not change, and the pile it lies in is the whole
413
+ * of what an archive means.
414
+ */
415
+ async function moveCard(from: string, to: string, id: string): Promise<void> {
416
+ await mkdir(to, { recursive: true, mode: DIR_MODE });
417
+ await renameInto(join(from, fileNameFor(id)), join(to, fileNameFor(id)));
418
+ }
419
+
420
+ /**
421
+ * Rename one file over or onto another, with the fallback this platform needs.
422
+ *
423
+ * Windows refuses a rename onto an open file, so removing the target first is the only
424
+ * way through there; every caller has already read what it is replacing, which is what
425
+ * bounds what the removal can lose.
426
+ */
427
+ async function renameInto(source: string, target: string): Promise<void> {
428
+ try {
429
+ await rename(source, target);
430
+ } catch (error) {
431
+ if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) throw error;
432
+ await rm(target, { force: true });
433
+ await rename(source, target);
434
+ }
435
+ }
436
+
437
+ /** Write one card: a temp file in the same directory, then a rename over the old one. */
438
+ async function writeCardFile(dir: string, id: string, card: Card): Promise<void> {
439
+ const temp = tempPath(dir);
440
+ await writeFile(temp, serialize(card), { mode: FILE_MODE });
441
+ try {
442
+ await renameInto(temp, join(dir, fileNameFor(id)));
443
+ } catch (error) {
444
+ await rm(temp, { force: true });
445
+ throw error;
446
+ }
447
+ }
448
+
449
+ /** One card's bytes, ending in a newline so the file reads well in an editor. */
450
+ /** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
451
+ function serialize(record: Card | EndedCard): string {
452
+ return `${JSON.stringify(record, null, "\t")}\n`;
453
+ }
454
+
455
+ /** A temp path in the cards directory: never matched by the reader's own pattern. */
456
+ function tempPath(cardsDir: string): string {
457
+ return join(cardsDir, `.tmp-${process.pid}-${Math.random().toString(16).slice(2, 10)}`);
458
+ }
459
+
460
+ /** Remove a temp file a process that died mid-write left behind. */
461
+ async function sweepTemps(cardsDir: string): Promise<void> {
462
+ const entries = await readdirSafe(cardsDir);
463
+ const cutoff = Date.now() - TEMP_STALE_MS;
464
+ for (const entry of entries) {
465
+ if (!entry.startsWith(".tmp-")) continue;
466
+ const path = join(cardsDir, entry);
467
+ const info = await stat(path).catch(() => undefined);
468
+ if (info && info.mtimeMs < cutoff) await unlink(path).catch(() => undefined);
469
+ }
470
+ }
471
+
472
+ async function readdirSafe(dir: string): Promise<string[]> {
473
+ return readdir(dir).catch((error: unknown) => {
474
+ if (hasErrorCode(error, "ENOENT")) return [];
475
+ throw error;
476
+ });
477
+ }
478
+
479
+ /**
480
+ * Lane order, then the lead's order inside it, then oldest first, with the id as the
481
+ * tie-break.
482
+ *
483
+ * Priority sorts inside a lane rather than beside it: a `high` card in Done is still
484
+ * history, and a lane that mixed the two would have to say so in its head.
485
+ */
486
+ function sortForLane(cards: Card[]): Card[] {
487
+ const lane = (card: Card): number => CARD_STATUSES.indexOf(card.status);
488
+ return cards.sort((a, b) => {
489
+ const byLane = lane(a) - lane(b);
490
+ if (byLane !== 0) return byLane;
491
+ const byPriority = priorityRank(a) - priorityRank(b);
492
+ if (byPriority !== 0) return byPriority;
493
+ const byAge = a.updatedAt - b.updatedAt;
494
+ if (byAge !== 0) return byAge;
495
+ return Number(a.id) - Number(b.id);
496
+ });
497
+ }