@gavin-hu/my-pi-agent 0.21.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 (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  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 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  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 +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  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 +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The board's actions and which of them only read it.
3
+ *
4
+ * The list and the classification are one table because they were once two: the tool
5
+ * registered the actions, and `lib/` compared a call's action to a bare string, so
6
+ * nothing could prove the two agreed — a second read action would have been refused by
7
+ * plan mode and counted as work by the todo nudge, with every suite green.
8
+ *
9
+ * `BOARD_ACTION_KIND` is a `Record<BoardAction, …>`, so an action added to
10
+ * {@link BOARD_ACTIONS} does not compile until somebody says whether it reads. That is
11
+ * the whole point: the answer is a type error, not a comment.
12
+ *
13
+ * The refusal that names the reads is built here too, beside the table it derives from,
14
+ * for the same reason `limits.ts` keeps `wipRefusal` beside its limit: a sentence a model
15
+ * reads is the table said aloud, and a sentence written where it is printed is a second
16
+ * copy of the answer.
17
+ */
18
+
19
+ /** The actions the tool accepts, in the order the description lists them. */
20
+ export const BOARD_ACTIONS = [
21
+ "list",
22
+ "add",
23
+ "claim",
24
+ "release",
25
+ "reclaim",
26
+ "move",
27
+ "note",
28
+ "contract",
29
+ "approve",
30
+ "review",
31
+ "edit",
32
+ "archive",
33
+ "drop",
34
+ ] as const;
35
+
36
+ /** One action. */
37
+ export type BoardAction = (typeof BOARD_ACTIONS)[number];
38
+
39
+ /** Whether an action reads the board or writes it. */
40
+ export type BoardActionKind = "read" | "write";
41
+
42
+ /**
43
+ * What each action does to the board.
44
+ *
45
+ * Exhaustive on purpose: a new action is a compile error here before it can be an
46
+ * unnoticed assumption in the read-only policy.
47
+ */
48
+ export const BOARD_ACTION_KIND: Record<BoardAction, BoardActionKind> = {
49
+ list: "read",
50
+ add: "write",
51
+ claim: "write",
52
+ release: "write",
53
+ reclaim: "write",
54
+ move: "write",
55
+ note: "write",
56
+ contract: "write",
57
+ approve: "write",
58
+ review: "write",
59
+ edit: "write",
60
+ archive: "write",
61
+ drop: "write",
62
+ };
63
+
64
+ /**
65
+ * The actions that only read, in the tool's own order.
66
+ *
67
+ * Plan mode names them in its refusal, so the sentence a model reads is built from the
68
+ * table rather than from a second list of what counts as reading.
69
+ */
70
+ export const BOARD_READ_ACTIONS: readonly BoardAction[] = BOARD_ACTIONS.filter(
71
+ (action) => BOARD_ACTION_KIND[action] === "read",
72
+ );
73
+
74
+ /**
75
+ * The reads as one phrase — `` `list` reads ``, or `` `list` or `archive` read `` — for the
76
+ * refusal that tells a model which action it may still use.
77
+ *
78
+ * The list is a parameter so both branches are testable: today exactly one action reads, so
79
+ * a sentence that inflected off the exported table could only ever ship its singular half
80
+ * tested. The phrase stays "or", not a comma list, because the reads are expected to stay
81
+ * few — one is the common case, and a second is a deliberate change to the policy.
82
+ */
83
+ export function readActionsText(actions: readonly BoardAction[] = BOARD_READ_ACTIONS): string {
84
+ const named = actions.map((action) => `\`${action}\``).join(" or ");
85
+ return `${named} ${actions.length === 1 ? "reads" : "read"}`;
86
+ }
87
+
88
+ /**
89
+ * The refusal a board write meets while plan mode is on.
90
+ *
91
+ * Plan mode owns the rule and supplies its own exit guidance; the sentence is here because
92
+ * it is {@link readActionsText}'s sentence, and because the order it names is the board's:
93
+ * a claim comes before planning, and the plan is recorded once it is approved. Written once
94
+ * so the refusal and the docs cannot teach two different orders.
95
+ */
96
+ export function boardWriteRefusal(rawAction: unknown, guidance: string): string {
97
+ const verb = typeof rawAction === "string" && rawAction.trim() !== "" ? rawAction.trim() : "board";
98
+ return `Plan mode: \`${verb}\` changes the board — only ${readActionsText()} it; claim the card before you plan, and record its plan once the plan is approved. ${guidance}`;
99
+ }
100
+
101
+ /**
102
+ * Whether a value is a board action that only reads.
103
+ *
104
+ * A call's `action` is whatever the model sent, so an unknown string is not a read:
105
+ * the answer has to be safe before the tool has normalized the argument.
106
+ */
107
+ export function isBoardReadAction(raw: unknown): boolean {
108
+ if (typeof raw !== "string") return false;
109
+ if (!BOARD_ACTIONS.includes(raw as BoardAction)) return false;
110
+ return BOARD_ACTION_KIND[raw as BoardAction] === "read";
111
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Whether a path is a plan of this repository: the board's half of the plan store.
3
+ *
4
+ * The board records the file a card is executed against, and that record is only worth
5
+ * something if the file is the one `plan` wrote. The board does not parse a plan — it
6
+ * resolves the path the model passed, requires it to be inside this repository's plan
7
+ * store, and requires it to be there. So a contract cannot name a path to nothing, and a
8
+ * card cannot enter `execute` on a plan somebody deleted after recording it.
9
+ *
10
+ * Two sentences per refusal, because the two moments ask different questions. At `contract`
11
+ * the question is "may this be recorded", and the way out is "write the plan, then pass the
12
+ * path it returned". At `execute` or `done` the question is "is the plan it recorded still
13
+ * there", and the way out follows the card's lane — a card in `plan` can record again, and
14
+ * one already executing has to come back to `plan` first, which is why {@link contractRoute}
15
+ * is here rather than in each caller. One resolution body answers both: the store's rules
16
+ * come from `plans/paths.ts`, so the board and the plan tool cannot disagree about what a
17
+ * plan path is.
18
+ *
19
+ * The board still never reads a plan. A `stat` is the whole check, which is why this module
20
+ * is the only place the board touches the plan store.
21
+ */
22
+
23
+ import { statSync } from "node:fs";
24
+ import { sanitizeLine, shortenPath } from "../format.ts";
25
+ import { isInside } from "../path.ts";
26
+ import { isContainedPlan, isPlanFileName, resolvePlanArg } from "../plans/paths.ts";
27
+ import type { Card } from "./card.ts";
28
+ import { cardRef } from "./card.ts";
29
+
30
+ /** The plan a card is executed against, resolved and proved to exist. */
31
+ export interface PlanArtifact {
32
+ /** The absolute path that was proved. */
33
+ path: string;
34
+ /**
35
+ * The stored plan path the proof was computed for.
36
+ *
37
+ * Only a re-check of a record carries it, and the gate compares it: a fact proved for
38
+ * one path cannot open a card whose recorded path is another, and a card whose path
39
+ * changed between the read and the write is refused rather than forced.
40
+ */
41
+ recorded?: string;
42
+ }
43
+
44
+ /**
45
+ * The resolved path, or the sentence refusing it.
46
+ *
47
+ * A union rather than two fields, so the tool hands the state machine one fact and the
48
+ * state machine decides which refusal a caller hears first — an owner-check that fails
49
+ * should be heard before a path that does not resolve.
50
+ */
51
+ export type ArtifactCheck = PlanArtifact | { error: string };
52
+
53
+ /** What is inside the store at a resolved path. */
54
+ type Located =
55
+ /** Not inside the store at all, or reached through a link that leaves it. */
56
+ | { kind: "outside"; path: string }
57
+ /** Inside the store, but not a markdown plan file. */
58
+ | { kind: "named"; path: string }
59
+ /** A plan name; what is at it, if anything. */
60
+ | { kind: "plan"; path: string; file: boolean; present: boolean };
61
+
62
+ /** Resolve `rawPath` against `planDir`, and say what the store holds there. */
63
+ function locate(planDir: string, rawPath: string): Located {
64
+ const path = resolvePlanArg(planDir, rawPath);
65
+ if (!isContainedPlan(planDir, path)) {
66
+ // `isContainedPlan` answers both questions at once, so the name rule is asked again
67
+ // here: a `.txt` in the store deserves the sentence about its name, not about a link.
68
+ return isInside(planDir, path) && !isPlanFileName(path) ? { kind: "named", path } : { kind: "outside", path };
69
+ }
70
+ try {
71
+ const info = statSync(path);
72
+ return { kind: "plan", path, present: true, file: info.isFile() };
73
+ } catch {
74
+ return { kind: "plan", path, present: false, file: false };
75
+ }
76
+ }
77
+
78
+ /** One path as a refusal prints it: shortened, and never more than one line. */
79
+ function at(path: string): string {
80
+ return sanitizeLine(shortenPath(path));
81
+ }
82
+
83
+ /**
84
+ * How a card records the plan again, which depends on the lane it is in.
85
+ *
86
+ * `contract` is written in `plan` and nowhere else, so a refusal that names it from
87
+ * `execute` would send a reader into another refusal — and from the pool the missing step is
88
+ * the claim. A finished card cannot be re-planned at all; that branch is unreachable through
89
+ * a gate, and says the fact rather than a route so the table is complete for its type.
90
+ */
91
+ export function contractRoute(card: Pick<Card, "lane">): string {
92
+ switch (card.lane) {
93
+ case "plan":
94
+ return "record it with `contract`.";
95
+ case "backlog":
96
+ return "claim it, then record it with `contract`.";
97
+ case "blocked":
98
+ case "execute":
99
+ return "move it back to plan, then record it with `contract`.";
100
+ case "done":
101
+ return "a finished card's plan cannot change.";
102
+ }
103
+ }
104
+
105
+ /** What `contract` may record: a plan of this repository, already written. */
106
+ export function planForContract(planDir: string, rawPath: string): ArtifactCheck {
107
+ const found = locate(planDir, rawPath);
108
+ if (found.kind === "outside") {
109
+ return {
110
+ error: `${at(found.path)} is not in this repository's plan store; \`plan\` action \`write\` saves into ${at(planDir)}.`,
111
+ };
112
+ }
113
+ if (found.kind === "named") {
114
+ return { error: `${at(found.path)} is not a markdown plan; \`plan\` action \`write\` saves into ${at(planDir)}.` };
115
+ }
116
+ if (!found.present) {
117
+ return {
118
+ error: `${at(found.path)} does not exist; \`plan\` action \`write\` saves the plan, and \`contract\` records the path it returns.`,
119
+ };
120
+ }
121
+ if (!found.file)
122
+ return { error: `${at(found.path)} is not a file; record the markdown file \`plan\` action \`write\` saves.` };
123
+ return { path: found.path };
124
+ }
125
+
126
+ /**
127
+ * What a card may enter `execute` or `done` on: the plan it recorded, still in the store.
128
+ *
129
+ * The recorded path may predate this rule — a card written when any string was accepted —
130
+ * which is why an outside path refuses here with the same route out as a missing file.
131
+ */
132
+ export function planForExecute(planDir: string, card: Pick<Card, "id" | "lane" | "planPath">): ArtifactCheck {
133
+ const rawPath = card.planPath ?? "";
134
+ const found = locate(planDir, rawPath);
135
+ const route = ` ${contractRoute(card)}`;
136
+ if (found.kind === "outside") {
137
+ return { error: `${cardRef(card.id)}'s plan is outside this repository's plan store (${at(rawPath)});${route}` };
138
+ }
139
+ if (found.kind === "named") {
140
+ return { error: `${cardRef(card.id)}'s recorded plan is not a markdown file (${at(rawPath)});${route}` };
141
+ }
142
+ if (!found.present) {
143
+ return { error: `the plan recorded for ${cardRef(card.id)} is gone (${at(found.path)});${route}` };
144
+ }
145
+ if (!found.file) {
146
+ return { error: `the plan recorded for ${cardRef(card.id)} is not a file (${at(found.path)});${route}` };
147
+ }
148
+ return { path: found.path, recorded: rawPath };
149
+ }
package/lib/board/card.ts CHANGED
@@ -9,14 +9,36 @@
9
9
  *
10
10
  * The record is untrusted when it comes back off disk: a file may be half-written
11
11
  * by a process that died, hand-edited, or left by an older version. {@link isCard}
12
- * is the one gate every read passes, so the router, the tool, and the page see
13
- * the same record or none. Value-only: no I/O, no state.
12
+ * is the shape every read is held to; the reader that maps an older shape onto this
13
+ * one is `read.ts`, so the router, the tool, and the page see the same record or none.
14
+ * Value-only: no I/O, no state.
14
15
  */
15
16
 
16
17
  import { sanitizeLine } from "../format.ts";
18
+ import { type Contract, isContract, isSign, type Sign } from "./contract.ts";
19
+ import { LANE_RULES, LANES, type Lane } from "./lanes.ts";
17
20
 
18
21
  /** The schema version this build writes. A file with another value is refused, never migrated in place. */
19
- export const CARD_VERSION = 1;
22
+ export const CARD_VERSION = 2;
23
+
24
+ /**
25
+ * The card versions this build can read.
26
+ *
27
+ * A v1 file is the record before the lane and the block were renamed: `readCard` maps it
28
+ * and hands back a v2 card, and the file itself is rewritten only when the card is next
29
+ * written. A version above {@link CARD_VERSION} is a newer build's record, which this one
30
+ * names rather than guesses at.
31
+ */
32
+ export const SUPPORTED_VERSIONS = [1, CARD_VERSION] as const;
33
+
34
+ /**
35
+ * The ended-card record's own version.
36
+ *
37
+ * The ledger is not the card: it is written once, never read back, and must not move when
38
+ * the card record does, or every record a past build wrote would disagree with its own
39
+ * validator.
40
+ */
41
+ export const ENDED_VERSION = 1;
20
42
 
21
43
  /** Longest title, in characters. One line, so it cannot wrap a lane row. */
22
44
  export const MAX_TITLE = 120;
@@ -27,12 +49,32 @@ export const MAX_PATHS = 12;
27
49
  /** Longest path hint, in characters. */
28
50
  export const MAX_PATH = 200;
29
51
 
30
- /** Longest recorded plan path, in characters. */
31
- export const MAX_PLAN = 300;
52
+ /**
53
+ * Longest recorded plan path, in characters.
54
+ *
55
+ * The board records the *resolved* absolute path (a `~/…` form is expanded before it is
56
+ * stored), so this cap holds a machine path rather than a caller's shorthand: a deep agent
57
+ * directory plus a stamped plan name, with room for a platform's own limit.
58
+ */
59
+ export const MAX_PLAN = 1024;
32
60
 
33
61
  /** Longest sentence saying what a blocked card is waiting on: one line, so it fits a row. */
34
62
  export const MAX_BLOCKED = 200;
35
63
 
64
+ /**
65
+ * Why a card is blocked: what a person has to read before it moves again.
66
+ *
67
+ * A block is not one thing: a contract that turned out wrong, a dependency, and a decision
68
+ * a person owes the card are different errands. The kind is the card's own claim — the board
69
+ * records it and draws it, and nothing here can verify that a "contract" block really is
70
+ * one — so it explains the card rather than routing it: where a blocked card may go is
71
+ * decided by what it has, not by what it was parked on.
72
+ */
73
+ export const BLOCKERS = ["contract", "dependency", "decision"] as const;
74
+
75
+ /** One kind of block. */
76
+ export type BlockerKind = (typeof BLOCKERS)[number];
77
+
36
78
  /** Longest single note, in characters. */
37
79
  export const MAX_NOTE = 1000;
38
80
 
@@ -61,12 +103,6 @@ export type Priority = (typeof PRIORITIES)[number];
61
103
  /** What an absent priority means: a card is ordinary until somebody says otherwise. */
62
104
  export const DEFAULT_PRIORITY: Priority = "normal";
63
105
 
64
- /** The lanes, in the order the page and the rollup read them. */
65
- export const CARD_STATUSES = ["todo", "doing", "blocked", "done"] as const;
66
-
67
- /** One lane a card can sit in. */
68
- export type CardStatus = (typeof CARD_STATUSES)[number];
69
-
70
106
  /** Who holds a card: the session id when it is known, and always the label it is addressed by. */
71
107
  export interface CardOwner {
72
108
  /** Session id, when the claim came from a live session. */
@@ -75,6 +111,22 @@ export interface CardOwner {
75
111
  label: string;
76
112
  }
77
113
 
114
+ /**
115
+ * Why a card is blocked: the kind, and the sentence a person reads.
116
+ *
117
+ * One record rather than two sibling keys, because one fact deserves one key. The kind is
118
+ * absent only on a card written before it existed — a v1 block may hold the sentence alone
119
+ * — and every block this build writes carries one, because the move into `blocked`
120
+ * requires it. It says what kind of errand parked the card, and it is not a route: the way
121
+ * out is the lane the card's contract lets it resume in.
122
+ */
123
+ export interface CardBlock {
124
+ /** Why it is blocked, when it was recorded. */
125
+ kind?: BlockerKind;
126
+ /** What it waits on, in one line; absent means the block was recorded without a sentence. */
127
+ note?: string;
128
+ }
129
+
78
130
  /** One note: who wrote it, when, and what it says. */
79
131
  export interface CardNote {
80
132
  by: string;
@@ -91,9 +143,17 @@ export interface CardNote {
91
143
  * page needs and the one a reader has no way to reconstruct.
92
144
  */
93
145
  export interface CardTransition {
94
- status: CardStatus;
146
+ lane: Lane;
95
147
  at: number;
96
148
  by: string;
149
+ /**
150
+ * What the card was parked on, when the lane entered is `blocked`.
151
+ *
152
+ * The present block is deleted when the card leaves `blocked`, so without this the
153
+ * answer to "why did this sit for two days" dies with the resume. The log keeps it for
154
+ * as long as the transition lives, which is the same bounded history as every other transition.
155
+ */
156
+ block?: CardBlock;
97
157
  }
98
158
 
99
159
  /**
@@ -112,25 +172,29 @@ export interface CardLanded {
112
172
  /** One feature on the board. */
113
173
  export interface Card {
114
174
  /** Schema version; an unknown value is refused rather than guessed at. */
115
- v: 1;
175
+ v: 2;
116
176
  /** `"4"`, shown as `#4`. Allocated by the store, never by a caller. */
117
177
  id: string;
118
178
  /** One line, ≤ {@link MAX_TITLE} — the feature's outcome. */
119
179
  title: string;
120
- status: CardStatus;
180
+ lane: Lane;
121
181
  owner?: CardOwner;
122
182
  /** Repository-relative hints, never claims. */
123
183
  paths: string[];
124
- /** The plan file being executed, once one is recorded. */
125
- plan?: string;
184
+ /** The plan file the work is done against, once one is recorded. */
185
+ planPath?: string;
186
+ /** What the card promises before it may be executed, and who approved that wording. */
187
+ contract?: Contract;
188
+ /** Who spot-checked the finished work, and when: what a move to `done` requires. */
189
+ review?: Sign;
126
190
  /**
127
- * What a blocked card is waiting on: the decision, in one line.
191
+ * What the card is blocked on, while it is in `blocked`.
128
192
  *
129
- * `blocked` exists to say a human has to decide something — a dependency is a
130
- * different thing, and `waits` is what refuses a claim. Absent means the card was
131
- * blocked without saying why, which is a state the tool allows and the page names.
193
+ * A dependency is a different thing, and `waits` is what refuses a claim; this is the
194
+ * human decision a card was parked on. Absent unless the card is blocked, and deleted
195
+ * when it leaves — the transition log keeps what it was.
132
196
  */
133
- blockedBy?: string;
197
+ blocked?: CardBlock;
134
198
  /** What closed a finished card: the commit at the move to `done`, and when. */
135
199
  landed?: CardLanded;
136
200
  /**
@@ -174,16 +238,22 @@ export interface Card {
174
238
  * that comes back is worse than an id that is gone — the same number would answer for
175
239
  * two different features. The store keeps one of these per ended card, writes it
176
240
  * before the card file goes, and never allocates its id again.
241
+ *
242
+ * The pile is a ledger, and this build never reads it back: the spent ids are the file
243
+ * names the allocator skips, and the record itself is for a person opening the directory.
244
+ * {@link isEndedCard} is the shape a reader would have to hold one to, and it is why the
245
+ * field is `lane` rather than the `status` an older record spells — nothing migrates a
246
+ * record no code path opens.
177
247
  */
178
248
  export interface EndedCard {
179
- /** Schema version for the record itself; a reader refuses what it does not know. */
249
+ /** Schema version for the record itself; it moves with neither the card record nor time. */
180
250
  v: 1;
181
251
  /** The id that is now spent, without padding. */
182
252
  id: string;
183
253
  /** What the card was called, so a note that names its id still reads. */
184
254
  title: string;
185
255
  /** The lane it was in when it ended. */
186
- status: CardStatus;
256
+ lane: Lane;
187
257
  /** Who ended it. */
188
258
  by: string;
189
259
  /** The owner it had, when it had one. */
@@ -198,10 +268,10 @@ export interface EndedCard {
198
268
  export function isEndedCard(value: unknown): value is EndedCard {
199
269
  if (typeof value !== "object" || value === null) return false;
200
270
  const ended = value as Record<string, unknown>;
201
- if (ended.v !== CARD_VERSION) return false;
271
+ if (ended.v !== ENDED_VERSION) return false;
202
272
  if (typeof ended.id !== "string" || !/^[0-9]+$/.test(ended.id)) return false;
203
273
  if (typeof ended.title !== "string" || ended.title === "") return false;
204
- if (!CARD_STATUSES.includes(ended.status as CardStatus)) return false;
274
+ if (!LANES.includes(ended.lane as Lane)) return false;
205
275
  if (typeof ended.by !== "string" || ended.by === "") return false;
206
276
  if (!isOwner(ended.owner)) return false;
207
277
  if (typeof ended.at !== "number" || !Number.isInteger(ended.at)) return false;
@@ -268,7 +338,7 @@ export function priorityOf(card: Card): Priority {
268
338
  }
269
339
 
270
340
  /** A card's dependencies, with the absent field read as its meaning. */
271
- export function dependsOf(card: Card): readonly string[] {
341
+ export function dependsOf(card: Pick<Card, "dependsOn">): readonly string[] {
272
342
  return card.dependsOn ?? [];
273
343
  }
274
344
 
@@ -283,7 +353,7 @@ export function dependsOf(card: Card): readonly string[] {
283
353
  */
284
354
  export function laneSince(card: Card): number | undefined {
285
355
  const last = card.log?.at(-1);
286
- return last !== undefined && last.status === card.status ? last.at : undefined;
356
+ return last !== undefined && last.lane === card.lane ? last.at : undefined;
287
357
  }
288
358
 
289
359
  /** Where a card's priority sits in {@link PRIORITIES}: the lane sort's first key after the lane. */
@@ -309,7 +379,7 @@ export function matchesQuery(card: Card, query: string): boolean {
309
379
  if (card.title.toLowerCase().includes(needle)) return true;
310
380
  if (card.owner !== undefined && card.owner.label.toLowerCase().includes(needle)) return true;
311
381
  if (card.paths.some((path) => path.toLowerCase().includes(needle))) return true;
312
- if (card.plan !== undefined && card.plan.toLowerCase().includes(needle)) return true;
382
+ if (card.planPath !== undefined && card.planPath.toLowerCase().includes(needle)) return true;
313
383
  return card.notes.some((note) => note.text.toLowerCase().includes(needle));
314
384
  }
315
385
 
@@ -328,8 +398,10 @@ export function normalizeNote(raw: unknown): { text: string } | FieldError {
328
398
  /**
329
399
  * The recorded plan path: one line, and nothing more.
330
400
  *
331
- * The board never reads the plan, so it does not resolve or check the path — this
332
- * is a note to the reader about which plan the card is executing.
401
+ * The board never reads the plan: `artifact.ts` resolves the caller's path against this
402
+ * repository's plan store and proves it is there, and `contract` stores what came back, so
403
+ * what arrives here is already an absolute path. This only holds it to one line and
404
+ * {@link MAX_PLAN}.
333
405
  */
334
406
  export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
335
407
  if (typeof raw !== "string") return { error: "path is required." };
@@ -340,19 +412,26 @@ export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
340
412
  }
341
413
 
342
414
  /**
343
- * The sentence a block records: one line, and nothing more.
415
+ * The block a move records: one kind, and the sentence when one is given.
344
416
  *
345
- * The board never resolves it against anything — it is the reader's explanation of what
346
- * a person has to decide — so this only keeps it to a length a row can hold.
417
+ * The refusals name the tool's parameters (`blocker`, `blockedBy`) rather than the record's
418
+ * fields, because this is the boundary the model's arguments pass, and the model wrote a
419
+ * parameter. The kind is required here — a block this build writes always says what kind it
420
+ * is — and the sentence is not: a card may be parked with nothing written down.
347
421
  */
348
- export function normalizeBlockedBy(raw: unknown): { blockedBy: string } | FieldError {
349
- if (typeof raw !== "string") return { error: "blockedBy must be text." };
350
- const blockedBy = sanitizeLine(raw).trim();
351
- if (blockedBy === "") return { error: "blockedBy must say what the card waits on." };
352
- if (blockedBy.length > MAX_BLOCKED) {
422
+ export function normalizeBlock(rawKind: unknown, rawNote: unknown): { blocked: CardBlock } | FieldError {
423
+ const allowed = BLOCKERS.join(", ");
424
+ const kind = typeof rawKind === "string" ? sanitizeLine(rawKind).trim().toLowerCase() : "";
425
+ const known = BLOCKERS.find((entry) => entry === kind);
426
+ if (known === undefined) return { error: `blocker must be one of ${allowed}.` };
427
+ if (rawNote === undefined) return { blocked: { kind: known } };
428
+ if (typeof rawNote !== "string") return { error: "blockedBy must be text." };
429
+ const note = sanitizeLine(rawNote).trim();
430
+ if (note === "") return { error: "blockedBy must say what the card waits on." };
431
+ if (note.length > MAX_BLOCKED) {
353
432
  return { error: `blockedBy must be at most ${MAX_BLOCKED} characters.` };
354
433
  }
355
- return { blockedBy };
434
+ return { blocked: { kind: known, note } };
356
435
  }
357
436
 
358
437
  /** A card id as a caller writes it (`4`, `#4`), or undefined when it is not one. */
@@ -381,13 +460,28 @@ export function signatureOf(cards: readonly Card[]): string {
381
460
  .join(",");
382
461
  }
383
462
 
463
+ /**
464
+ * Whether `actor` is the owner of `card`.
465
+ *
466
+ * The id decides when both sides have one — a session that renamed itself is still the same
467
+ * holder — and the label decides otherwise, case-insensitively, the way the bus resolves a
468
+ * target. A signature that takes the actor structurally is what lets the record own this
469
+ * rule while `rules.ts` and `steps.ts` share it without importing each other.
470
+ */
471
+ export function holds(card: Card, actor: { id?: string; label: string }): boolean {
472
+ const owner = card.owner;
473
+ if (owner === undefined) return false;
474
+ if (owner.id !== undefined && actor.id !== undefined) return owner.id === actor.id;
475
+ return owner.label.toLowerCase() === actor.label.toLowerCase();
476
+ }
477
+
384
478
  /**
385
479
  * Whether `value` is a card this build can trust.
386
480
  *
387
- * Shape *and* the two invariants a bug could otherwise smuggle past the rules: a
388
- * `todo` card has no owner, and the other three lanes always have one. A card that
389
- * fails here is dropped by the reader rather than repaired, because a repair would
390
- * guess at which half of the record was meant.
481
+ * Shape *and* the invariants a bug could otherwise smuggle past the rules: the one
482
+ * ownerless lane really has no owner, and the others always have one. A card that fails
483
+ * here is dropped by the reader rather than repaired, because a repair would guess at
484
+ * which half of the record was meant.
391
485
  */
392
486
  export function isCard(value: unknown): value is Card {
393
487
  if (typeof value !== "object" || value === null) return false;
@@ -395,12 +489,17 @@ export function isCard(value: unknown): value is Card {
395
489
  if (card.v !== CARD_VERSION) return false;
396
490
  if (typeof card.id !== "string" || !/^[0-9]+$/.test(card.id)) return false;
397
491
  if (typeof card.title !== "string" || card.title === "") return false;
398
- if (!CARD_STATUSES.includes(card.status as CardStatus)) return false;
492
+ if (!LANES.includes(card.lane as Lane)) return false;
493
+ // The lane was just proved to be one of `LANES`, which is the whole guarantee the cast
494
+ // names: from here the table can be addressed by it.
495
+ const lane = card.lane as Lane;
399
496
  if (!isOwner(card.owner)) return false;
400
497
  if (!Array.isArray(card.paths) || card.paths.length > MAX_PATHS) return false;
401
498
  if (!card.paths.every((path) => typeof path === "string")) return false;
402
- if (card.plan !== undefined && typeof card.plan !== "string") return false;
403
- if (card.blockedBy !== undefined && !isBlockedBy(card.blockedBy)) return false;
499
+ if (card.planPath !== undefined && typeof card.planPath !== "string") return false;
500
+ if (card.contract !== undefined && !isContract(card.contract)) return false;
501
+ if (card.review !== undefined && !isSign(card.review)) return false;
502
+ if (card.blocked !== undefined && !isBlock(card.blocked)) return false;
404
503
  if (card.landed !== undefined && !isLanded(card.landed)) return false;
405
504
  if (card.offeredAt !== undefined && (typeof card.offeredAt !== "number" || !Number.isInteger(card.offeredAt))) {
406
505
  return false;
@@ -416,12 +515,23 @@ export function isCard(value: unknown): value is Card {
416
515
  if (typeof card.rev !== "number" || !Number.isInteger(card.rev) || card.rev < 1) return false;
417
516
  if (typeof card.createdAt !== "number" || typeof card.updatedAt !== "number") return false;
418
517
  if (typeof card.by !== "string") return false;
419
- return (card.status === "todo") === (card.owner === undefined);
518
+ // One ownerless lane, and the table says which: a pool card has no holder, and every
519
+ // card that is somebody's work has one.
520
+ return LANE_RULES[lane].ownerless === (card.owner === undefined);
420
521
  }
421
522
 
422
- /** A recorded block: one line the reader can hold, within the cap this build writes. */
423
- function isBlockedBy(value: unknown): value is string {
424
- return typeof value === "string" && value !== "" && value.length <= MAX_BLOCKED;
523
+ /**
524
+ * A recorded block: a kind this build knows when one is there, and a sentence a row can
525
+ * hold when one is there — at least one of the two, because an empty block says nothing.
526
+ */
527
+ function isBlock(value: unknown): value is CardBlock {
528
+ if (typeof value !== "object" || value === null) return false;
529
+ const block = value as Record<string, unknown>;
530
+ if (block.kind !== undefined && !BLOCKERS.includes(block.kind as BlockerKind)) return false;
531
+ if (block.note !== undefined) {
532
+ if (typeof block.note !== "string" || block.note === "" || block.note.length > MAX_BLOCKED) return false;
533
+ }
534
+ return block.kind !== undefined || block.note !== undefined;
425
535
  }
426
536
 
427
537
  /** A recorded landing: a commit id and the moment it closed the card. */
@@ -454,12 +564,13 @@ function isDepends(value: unknown): boolean {
454
564
  return true;
455
565
  }
456
566
 
457
- /** A recorded lane change: a lane, a moment, and who moved it. */
567
+ /** A recorded lane change: a lane, a moment, and who moved it, and what it parked on. */
458
568
  function isTransition(value: unknown): value is CardTransition {
459
569
  if (typeof value !== "object" || value === null) return false;
460
570
  const entry = value as Record<string, unknown>;
571
+ if (entry.block !== undefined && !isBlock(entry.block)) return false;
461
572
  return (
462
- CARD_STATUSES.includes(entry.status as CardStatus) &&
573
+ LANES.includes(entry.lane as Lane) &&
463
574
  typeof entry.at === "number" &&
464
575
  Number.isInteger(entry.at) &&
465
576
  typeof entry.by === "string" &&