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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -1,6 +1,6 @@
1
1
  /**
2
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.
3
+ * operation, who asked, the clock, the board's other cards, and the board's limits.
4
4
  *
5
5
  * One table decides who may do what — the tool, the router, the page, and `team` all
6
6
  * route through {@link applyOp} — so a lane change cannot be reachable from one
@@ -8,41 +8,47 @@
8
8
  * ownership is advisory (the bus's path claims are the lock), and the store owns the
9
9
  * compare-and-set that makes a concurrent writer lose.
10
10
  *
11
+ * Two shapes of failure, and the difference matters. A refusal is `{ ok: false }` with
12
+ * the reason, and the caller returns the card untouched. A *no-op* is `{ ok: true,
13
+ * changed: false }`: the operation's preconditions already hold (claiming a card you
14
+ * already hold), so nothing is written and the revision does not move.
15
+ *
16
+ * {@link mayOp} is the same decision without writing it, which is what `team` asks
17
+ * before it sends a handoff: a dry run is a question — "would this be allowed" — not a
18
+ * mutation performed as a fake session.
19
+ *
11
20
  * The board index is a parameter rather than a lookup because three operations need a
12
21
  * *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.
22
+ * a drop is refused while somebody waits on the card. `lib/board/links.ts` answers those
23
+ * questions; this module decides what to do with the answer. The board's settings — its WIP
24
+ * limits and the name its two signatures must carry — are one parameter for the same reason:
25
+ * the rule decides, the reader of `bytefly.json` supplies them.
21
26
  *
22
27
  * `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
28
+ * transition — and because a card is born in `backlog` with no owner, which is the one
24
29
  * state the other operations never produce.
25
30
  */
26
31
 
32
+ import type { ArtifactCheck } from "./artifact.ts";
33
+ import { type Card, type CardBlock, CARD_VERSION, DEFAULT_PRIORITY, MAX_LOG, type Priority } from "./card.ts";
34
+ import { type BoardConfig, DEFAULT_BOARD_CONFIG } from "./config.ts";
35
+ import type { Lane } from "./lanes.ts";
36
+ import type { BoardIndex } from "./links.ts";
27
37
  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";
38
+ approve,
39
+ archive,
40
+ assign,
41
+ claim,
42
+ contractOp,
43
+ drop,
44
+ edit,
45
+ move,
46
+ note,
47
+ offer,
48
+ reclaim,
49
+ release,
50
+ review,
51
+ } from "./steps.ts";
46
52
 
47
53
  /** Who is attempting an operation: a session when it has one, and the label it is addressed by. */
48
54
  export interface Actor {
@@ -52,27 +58,45 @@ export interface Actor {
52
58
 
53
59
  /** One named mutation of one card. */
54
60
  export type CardOp =
61
+ /** Pull a backlog card into `plan`: the only way a card starts moving. */
55
62
  | { kind: "claim" }
56
63
  /**
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.
64
+ * Hand a pool card to `to`: the owner is recorded and the card enters `plan`. A held card is
65
+ * refused — `reclaim` is how it changes hands — and `team assign` sends the handoff, which
66
+ * the state machine knows nothing about.
59
67
  */
60
- | { kind: "assign"; to: Actor; note?: unknown }
68
+ | { kind: "assign"; to: Actor }
61
69
  | { kind: "release" }
62
70
  /**
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.
71
+ * Change lane. `blockedBy` and `blocker` are what a block records, `landed` says which
72
+ * commit closed a card, and `artifact` is the plan proof a card entering `execute` or
73
+ * `done` carries: each is read only by the lanes that ask for it.
67
74
  */
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.
75
+ | {
76
+ kind: "move";
77
+ lane: Lane;
78
+ blockedBy?: unknown;
79
+ blocker?: unknown;
80
+ landed?: { commit: string };
81
+ artifact?: ArtifactCheck;
82
+ }
83
+ // `text` and the contract fields are whatever the model sent: the modules that own
84
+ // them validate them, and the refusal they produce is the one the caller shows.
71
85
  | { kind: "note"; text: unknown }
72
- | { kind: "plan"; path: unknown }
86
+ /**
87
+ * Write the contract and the plan it is executed against. `artifact` is what the caller
88
+ * proved: a resolved, existing plan file, or the sentence refusing it. The state machine
89
+ * decides — nothing here touches a filesystem — and a contract naming neither is refused,
90
+ * so a caller cannot skip the check by omitting it.
91
+ */
92
+ | { kind: "contract"; artifact?: ArtifactCheck; goal?: unknown; acceptance?: unknown; scopeOut?: unknown }
93
+ /** Read the contract and approve it: the lead, and never the card's holder; `text` is noted with the sign. */
94
+ | { kind: "approve"; text?: unknown }
95
+ /** Spot-check the finished work: the lead, in `execute`, and never the card's holder; `text` is noted with the sign. */
96
+ | { kind: "review"; text?: unknown }
73
97
  // Every field but `kind` is optional, and an absent one is left as it was: the page
74
98
  // 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 }
99
+ | { kind: "edit"; title?: unknown; paths?: unknown; priority?: unknown; dependsOn?: unknown }
76
100
  | { kind: "reclaim" }
77
101
  /**
78
102
  * Record that the card was offered to the team: `team offer` broadcasts it on the bus
@@ -89,7 +113,10 @@ export type CardOp =
89
113
  /** What an operation did: the new card, or the reason it was refused. */
90
114
  export type OpResult = { ok: true; card: Card; changed: boolean } | { ok: false; error: string };
91
115
 
92
- /** A card's first state: `todo`, no owner, one note at most. */
116
+ /** What one operation would write, or why it may not. */
117
+ export type Prepared = { error: string } | { fields: Partial<Card> | null; changed: boolean };
118
+
119
+ /** A card's first state: `backlog`, no owner, one note at most. */
93
120
  export function createCard(
94
121
  id: string,
95
122
  input: { title: string; paths: string[]; note?: string; priority?: Priority },
@@ -97,19 +124,19 @@ export function createCard(
97
124
  now: number,
98
125
  ): Card {
99
126
  return {
100
- v: 1,
127
+ v: CARD_VERSION,
101
128
  id,
102
129
  title: input.title,
103
- status: "todo",
130
+ lane: "backlog",
104
131
  paths: [...input.paths],
105
132
  // A new card says its priority out loud, even when it is the ordinary one, so a
106
133
  // file on disk tells a reader the whole story without a default to know.
107
134
  priority: input.priority ?? DEFAULT_PRIORITY,
108
135
  notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
109
136
  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 }],
137
+ // A card is born in `backlog`, and that is a transition like any other: a reader
138
+ // asking "how long has this been waiting" gets an answer rather than an absence.
139
+ log: [{ lane: "backlog", at: now, by: actor.label }],
113
140
  logDropped: 0,
114
141
  rev: 1,
115
142
  createdAt: now,
@@ -119,17 +146,21 @@ export function createCard(
119
146
  }
120
147
 
121
148
  /**
122
- * Whether `actor` is the owner of `card`.
149
+ * Why `actor` may not apply `op` to `card`, or `undefined` when it may.
123
150
  *
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.
151
+ * The answer a dry run asks for: it computes everything a write would check and nothing
152
+ * a write would store, so `team` can find out whether a handoff is deliverable before it
153
+ * sends one.
127
154
  */
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();
155
+ export function mayOp(
156
+ card: Card,
157
+ op: CardOp,
158
+ actor: Actor,
159
+ board: BoardIndex,
160
+ config: BoardConfig = DEFAULT_BOARD_CONFIG,
161
+ ): string | undefined {
162
+ const prepared = prepare(card, op, actor, 0, board, config);
163
+ return "error" in prepared ? prepared.error : undefined;
133
164
  }
134
165
 
135
166
  /**
@@ -138,151 +169,75 @@ export function holds(card: Card, actor: Actor): boolean {
138
169
  * A refused operation returns the reason and no card: the caller already holds the
139
170
  * card it read, and a "refused" copy of it would be a second version of the truth.
140
171
  */
141
- export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board: BoardIndex): OpResult {
172
+ export function applyOp(
173
+ card: Card,
174
+ op: CardOp,
175
+ actor: Actor,
176
+ now: number,
177
+ board: BoardIndex,
178
+ config: BoardConfig = DEFAULT_BOARD_CONFIG,
179
+ ): OpResult {
180
+ const prepared = prepare(card, op, actor, now, board, config);
181
+ if ("error" in prepared) return { ok: false, error: prepared.error };
182
+ if (!prepared.changed) return { ok: true, card, changed: false };
183
+ // `archive` changes the pile a card's file lies in and no field at all, which is why
184
+ // its step has nothing to write and the store's move is the whole of the operation.
185
+ if (prepared.fields === null) return { ok: true, card, changed: true };
186
+ return { ok: true, card: write(card, actor, now, prepared.fields), changed: true };
187
+ }
188
+
189
+ /** The one place an operation becomes a write: each `step*` decides, this applies. */
190
+ function prepare(card: Card, op: CardOp, actor: Actor, now: number, board: BoardIndex, config: BoardConfig): Prepared {
142
191
  switch (op.kind) {
143
192
  case "claim":
144
- return claim(card, actor, now, board);
193
+ return claim(card, actor, board, config.wip);
145
194
  case "assign":
146
- return assign(card, op.to, op.note, actor, now, board);
195
+ return assign(card, op.to, board, config.wip);
147
196
  case "release":
148
- return release(card, actor, now);
197
+ return release(card, actor);
149
198
  case "move":
150
- return move(card, op, actor, now, board);
199
+ return move(card, op, actor, now, board, config);
151
200
  case "note":
152
201
  return note(card, op.text, actor, now);
153
- case "plan":
154
- return plan(card, op.path, actor, now);
202
+ case "contract":
203
+ return contractOp(card, op, actor);
204
+ case "approve":
205
+ return approve(card, actor, now, config, op.text);
206
+ case "review":
207
+ return review(card, actor, now, config, op.text);
155
208
  case "edit":
156
- return edit(card, op, actor, now, board);
209
+ return edit(card, op, actor, board);
157
210
  case "reclaim":
158
- return reclaim(card, actor, now);
211
+ return reclaim(card);
159
212
  case "offer":
160
- return offer(card, actor, now, board);
213
+ return offer(card, now, board, config.wip);
161
214
  case "archive":
162
215
  return archive(card);
163
216
  case "drop":
164
217
  return drop(card, actor, board);
218
+ default:
219
+ // A caller from an older build, or a hand-written fixture, names an operation this
220
+ // one does not know: that is a refusal, not a crash on `undefined`.
221
+ return { error: "unknown board operation." };
165
222
  }
166
223
  }
167
224
 
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
225
  /**
244
- * The fields one lane change writes.
226
+ * A card's log after one more transition, with the oldest dropped and counted.
245
227
  *
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.
228
+ * The block travels with the entry that parked the card, because the card's own `blocked`
229
+ * is deleted on the way out: without it the answer to "why did this sit for two days" dies
230
+ * with the resume, and the notes are the only other place it could have been written.
250
231
  */
251
- function laneFields(
252
- status: Exclude<CardStatus, "todo">,
253
- blockedBy: string | undefined,
254
- op: Extract<CardOp, { kind: "move" }>,
232
+ function appendTransition(
233
+ card: Card,
234
+ lane: Lane,
235
+ actor: Actor,
255
236
  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 }];
237
+ block: CardBlock | undefined,
238
+ ): Pick<Card, "log" | "logDropped"> {
239
+ const entry = block === undefined ? { lane, at: now, by: actor.label } : { lane, at: now, by: actor.label, block };
240
+ const log = [...(card.log ?? []), entry];
286
241
  const overflow = Math.max(0, log.length - MAX_LOG);
287
242
  return {
288
243
  log: overflow === 0 ? log : log.slice(overflow),
@@ -290,148 +245,37 @@ function appendTransition(card: Card, status: CardStatus, actor: Actor, now: num
290
245
  };
291
246
  }
292
247
 
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
248
  /**
405
249
  * A card with `fields` applied and its write marks moved.
406
250
  *
407
251
  * 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,
252
+ * transition ends: a note, a contract, or an edit writes fields and leaves the lane alone,
409
253
  * while a claim, an assign, a release, a reclaim, and a move all name the new one. So
410
254
  * the log is appended from here rather than from each operation, and an operation added
411
255
  * later cannot forget it.
412
256
  */
413
257
  function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
414
- const status = fields.status;
258
+ const lane = fields.lane;
415
259
  const transition =
416
- status !== undefined && status !== card.status ? appendTransition(card, status, actor, now) : undefined;
260
+ lane !== undefined && lane !== card.lane
261
+ ? appendTransition(card, lane, actor, now, lane === "blocked" ? fields.blocked : undefined)
262
+ : undefined;
417
263
  const next: Card = { ...card, ...fields, ...transition, rev: card.rev + 1, updatedAt: now, by: actor.label };
418
264
  // `owner: undefined` must clear the field rather than store a key holding it, so a
419
265
  // released card reads back exactly like one that was never claimed. Only an
420
266
  // explicit `owner` in `fields` may delete it: every other write keeps the holder.
421
267
  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;
268
+ // The same for a block and a spot-check: a card that left `blocked` has no reason and no
269
+ // kind, and one that left `execute` is not reviewed, so a key holding undefined would be
270
+ // one more shape for a reader to know about.
271
+ for (const field of ["blocked", "offeredAt", "review"] as const) {
272
+ if (field in fields && fields[field] === undefined) delete next[field];
273
+ }
274
+ // `blocked` is a fact about the `blocked` lane and nothing else: a card back in the pool, or
275
+ // one claimed out of it, is not parked on that decision any more. This is here rather than
276
+ // in each operation because every lane change ends in this one write — so a release, a
277
+ // reclaim, and a claim clean it without any of them having to remember, and the transition
278
+ // log keeps what the park was.
279
+ if (next.lane !== "blocked") delete next.blocked;
426
280
  return next;
427
281
  }
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,35 @@
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 session
5
+ * taking the page over would otherwise change who the page says it is. So the page is a *seat*
6
+ * rather than a session: one name for its whole life, one id derived from it, and the same
7
+ * identity however many sessions have served it.
8
+ *
9
+ * That name is `board.lead` — the lead's team name, read by `config.ts` — because the two
10
+ * questions have one answer: the page belongs to the lead, and serving it is how a session
11
+ * *becomes* the lead (the seat outranks the session's own name while it serves, so the gate
12
+ * accepts it). There is no built-in seat name here: a name in this module would be a second
13
+ * answer to a question the config already answers.
14
+ *
15
+ * The page writes nothing, so the id decides no ownership: it is what the page's own roster and
16
+ * the `you` chip are read against, and it keeps a page's reads the same across a handover. The
17
+ * board's tools write as the session — which is why the label is shared with the session
18
+ * serving the page and the ids are not.
19
+ */
20
+
21
+ import type { Lead } from "./lead.ts";
22
+ import type { Actor } from "./rules.ts";
23
+
24
+ /** The prefix of a page's actor id, so one is never mistaken for a session id. */
25
+ export const SEAT_ID_PREFIX = "board:";
26
+
27
+ /**
28
+ * The actor a page reads as: the lead's name, and an id derived from it.
29
+ *
30
+ * The name arrives already normalized: `board.lead` passes `normalizeLabel` on its way out of
31
+ * `config.ts`, so this only derives the id — one rule for a label, in one place.
32
+ */
33
+ export function pageActor(lead: Lead): Actor {
34
+ return { id: `${SEAT_ID_PREFIX}${lead.toLowerCase()}`, label: lead };
35
+ }