@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
@@ -2,7 +2,7 @@
2
2
  * The board store: one directory of card files per repository, and the only code
3
3
  * that writes one.
4
4
  *
5
- * A board is a directory — `<agentDir>/board/<repo-key>/cards/0004.json` — so a
5
+ * A board is a directory — `<agent-dir>/board/<repo-key>/cards/0004.json` — so a
6
6
  * reader needs no index, no daemon, and no dependency; the listing *is* the
7
7
  * index. The key comes from `lib/repo-key.ts`, so every worktree of one repository
8
8
  * shares one board, and a directory outside git keys on itself.
@@ -35,15 +35,17 @@ import { shortenPath } from "../format.ts";
35
35
  import { repoKeyFor } from "../repo-key.ts";
36
36
  import {
37
37
  type Card,
38
- CARD_STATUSES,
39
- CARD_VERSION,
40
38
  cardRef,
41
39
  type EndedCard,
42
- isCard,
40
+ ENDED_VERSION,
43
41
  type Priority,
44
42
  priorityRank,
43
+ SUPPORTED_VERSIONS,
45
44
  } from "./card.ts";
45
+ import { LANES } from "./lanes.ts";
46
+ import { readCard } from "./read.ts";
46
47
  import { indexOf } from "./links.ts";
48
+ import { type BoardConfig, DEFAULT_BOARD_CONFIG, readBoardConfig } from "./config.ts";
47
49
  import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
48
50
 
49
51
  /** The directory under the agent directory that holds every board. */
@@ -108,9 +110,26 @@ export interface Skip {
108
110
  export interface BoardStore {
109
111
  /** The board's own directory, for `where` and for a refusal that names it. */
110
112
  readonly dir: string;
113
+ /**
114
+ * The repository key this board is filed under.
115
+ *
116
+ * Carried rather than read back off {@link dir}'s last segment: the same key is what
117
+ * `session-bus` files this repository's records under, so a caller that needs it must
118
+ * not learn it from the board's layout — otherwise regrouping the board's own files
119
+ * moves the roster to a directory no bus writes to, and the page reads "no record" for
120
+ * every peer instead of failing where someone would notice.
121
+ */
122
+ readonly key: string;
123
+ /**
124
+ * The settings this board works by: its WIP limits and the name its two signatures carry.
125
+ *
126
+ * Read once when the store opened, and handed to every operation: the page shows the same
127
+ * numbers a refusal would, so a head cannot claim room a claim would refuse.
128
+ */
129
+ config(): BoardConfig;
111
130
  /** Create the directories and sweep a temp file a dead process left. */
112
131
  ensure(): Promise<void>;
113
- /** Every card on the board, in lane order: todo, doing, blocked, done. */
132
+ /** Every card on the board, in lane order: backlog, plan, execute, blocked, done. */
114
133
  list(): Promise<Card[]>;
115
134
  /**
116
135
  * The board in one read: the cards, and the files it refused.
@@ -170,14 +189,41 @@ export async function openBoard(
170
189
  // environment carries the active root, so the two are reconciled before the
171
190
  // repository lookup — the same path `plan` and `session-bus` take.
172
191
  const repo = await repoKeyFor(pi, resolveEffectiveCwd(cwd));
173
- return createStore(boardDirFor(agentDir, repo.key), { root: repo.root });
192
+ // Read once per store: the settings are a setting, not a card, and a board that re-read
193
+ // a file on every write would spend a stat to answer a question nobody changed.
194
+ const config = readBoardConfig(resolveEffectiveCwd(cwd), { agentDir });
195
+ // The key is handed on, not left to be read off the directory it named: `session-bus`
196
+ // files this repository's records under the same key, so the two must not be able to
197
+ // disagree about it.
198
+ return createStore(boardDirFor(agentDir, repo.key), { root: repo.root, key: repo.key, config });
174
199
  }
175
200
 
176
201
  /** Build a store over one directory. Exported for tests and for a second backend. */
177
- export function createStore(dir: string, options: { root?: string } = {}): BoardStore {
202
+ export function createStore(
203
+ dir: string,
204
+ options: {
205
+ root?: string;
206
+ key?: string;
207
+ config?: BoardConfig;
208
+ /**
209
+ * A suite's seam: called once an operation is ready to write and before the re-read that
210
+ * decides whether it still may.
211
+ *
212
+ * A race between a write and a pile move cannot be made deterministic from outside — the
213
+ * window is inside one `mutate` — so a test stands here and lets the other write land. The
214
+ * product supplies nothing.
215
+ */
216
+ beforeWrite?: (id: string, kind: CardOp["kind"]) => Promise<void> | void;
217
+ } = {},
218
+ ): BoardStore {
219
+ // A store built over a directory by hand has no repository to ask, so it is keyed by
220
+ // that directory's own name: the one place this fallback lives, and the one reason it
221
+ // is not a proof about `openBoard`'s boards.
222
+ const key = options.key ?? basename(dir);
178
223
  // A store built over a directory by hand has no repository to name, so it names its
179
224
  // own directory: the page then says something true instead of something blank.
180
225
  const root = options.root ?? dir;
226
+ const config = options.config ?? DEFAULT_BOARD_CONFIG;
181
227
  const cardsDir = join(dir, CARDS_DIR_NAME);
182
228
  const endedDir = join(dir, ENDED_DIR_NAME);
183
229
  const archiveDir = join(dir, ARCHIVE_DIR_NAME);
@@ -278,11 +324,22 @@ export function createStore(dir: string, options: { root?: string } = {}): Board
278
324
  // The preconditions are checked against the card as it is *now*, and against the
279
325
  // board as it is now, so an operation re-applied to a card that changed is refused
280
326
  // rather than forced.
281
- const result = applyOp(card, op, actor, now, indexOf(await list()));
327
+ const result = applyOp(card, op, actor, now, indexOf(await list()), config);
282
328
  if (!result.ok) return { ok: false, error: result.error, card };
283
329
  if (!result.changed) {
284
330
  return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
285
331
  }
332
+ // Last look before the write, asked for every path that writes: the card must still be
333
+ // where it was read and at the revision it had. A writer that landed in between is the
334
+ // re-read the next pass applies against — and the pile is part of the answer, because
335
+ // `archive` moves the file without moving its revision, so a write carrying a stale
336
+ // pile would recreate the card on the board beside its copy in the archive.
337
+ await options.beforeWrite?.(id, op.kind);
338
+ const current = await locate(id);
339
+ if (current === undefined || current.card.rev !== card.rev || current.pile !== pile) {
340
+ changedUnderneath = true;
341
+ continue;
342
+ }
286
343
  if (op.kind === "drop") {
287
344
  // The record lands first: a crash between the two leaves a card that still
288
345
  // exists with a note explaining it, never a card that is gone unexplained.
@@ -299,13 +356,6 @@ export function createStore(dir: string, options: { root?: string } = {}): Board
299
356
  await moveCard(cardsDir, archiveDir, id);
300
357
  return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
301
358
  }
302
- // Last look before the rename: a writer that landed in between is the
303
- // re-read the next pass applies against.
304
- const current = await read(id);
305
- if (!current || current.rev !== card.rev) {
306
- changedUnderneath = true;
307
- continue;
308
- }
309
359
  await writeCardFile(pile, id, result.card);
310
360
  return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
311
361
  }
@@ -329,6 +379,8 @@ export function createStore(dir: string, options: { root?: string } = {}): Board
329
379
 
330
380
  return {
331
381
  dir,
382
+ key,
383
+ config: () => config,
332
384
  ensure,
333
385
  list,
334
386
  scan,
@@ -366,19 +418,23 @@ async function readCardFile(pileDir: string, entry: string): Promise<{ card: Car
366
418
  } catch {
367
419
  return { skip: { file: entry, id, why: "bad JSON" } };
368
420
  }
369
- // A version above this build's is the one refusal a reader can act on: the file was
370
- // written by a newer build, so the board is not empty, it is not readable here.
421
+ // A version this build does not read is the one refusal a reader can act on: the file was
422
+ // written by a newer build, so the board is not empty, it is not readable here. Every
423
+ // version `readCard` can map is read; the number is named rather than guessed at.
371
424
  const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
372
- if (typeof version === "number" && version !== CARD_VERSION) {
425
+ if (typeof version === "number" && !SUPPORTED_VERSIONS.some((known) => known === version)) {
373
426
  return { skip: { file: entry, id, why: `version ${version}` } };
374
427
  }
375
- if (!isCard(parsed)) return { skip: { file: entry, id, why: "not a card record" } };
428
+ // The one gate a file read passes: an older spelling of the lane field is read as
429
+ // this build's, and everything else is a shape this build does not trust.
430
+ const card = readCard(parsed);
431
+ if (card === undefined) return { skip: { file: entry, id, why: "not a card record" } };
376
432
  // The file name is the id's canonical spelling: a padded id inside the record
377
433
  // (or a record moved under another name) is a disagreement, not a card.
378
- if (parsed.id !== String(Number(match[1]))) {
379
- return { skip: { file: entry, id, why: `names card ${parsed.id}` } };
434
+ if (card.id !== String(Number(match[1]))) {
435
+ return { skip: { file: entry, id, why: `names card ${card.id}` } };
380
436
  }
381
- return { card: parsed };
437
+ return { card };
382
438
  }
383
439
 
384
440
  /** The highest id a directory holds, or 0 when it holds none. */
@@ -412,10 +468,10 @@ async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string
412
468
  async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
413
469
  await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
414
470
  const ended: EndedCard = {
415
- v: 1,
471
+ v: ENDED_VERSION,
416
472
  id: card.id,
417
473
  title: card.title,
418
- status: card.status,
474
+ lane: card.lane,
419
475
  by: actor.label,
420
476
  ...(card.owner === undefined ? {} : { owner: card.owner }),
421
477
  at: now,
@@ -472,7 +528,6 @@ async function writeCardFile(dir: string, id: string, card: Card): Promise<void>
472
528
  }
473
529
  }
474
530
 
475
- /** One card's bytes, ending in a newline so the file reads well in an editor. */
476
531
  /** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
477
532
  function serialize(record: Card | EndedCard): string {
478
533
  return `${JSON.stringify(record, null, "\t")}\n`;
@@ -503,21 +558,23 @@ async function readdirSafe(dir: string): Promise<string[]> {
503
558
  }
504
559
 
505
560
  /**
506
- * Lane order, then the lead's order inside it, then oldest first, with the id as the
507
- * tie-break.
561
+ * Lane order, then the lead's order inside it, then the order the cards were added in: the
562
+ * id, which is allocated once and never reissued.
508
563
  *
509
564
  * Priority sorts inside a lane rather than beside it: a `high` card in Done is still
510
565
  * history, and a lane that mixed the two would have to say so in its head.
566
+ *
567
+ * The id carries the age, and `updatedAt` deliberately does not: every write moves that
568
+ * stamp, so ordering by it makes a note, a claim, or a contract push a card to the end of
569
+ * its priority and reshuffle the listing a reader was working down.
511
570
  */
512
571
  function sortForLane(cards: Card[]): Card[] {
513
- const lane = (card: Card): number => CARD_STATUSES.indexOf(card.status);
572
+ const lane = (card: Card): number => LANES.indexOf(card.lane);
514
573
  return cards.sort((a, b) => {
515
574
  const byLane = lane(a) - lane(b);
516
575
  if (byLane !== 0) return byLane;
517
576
  const byPriority = priorityRank(a) - priorityRank(b);
518
577
  if (byPriority !== 0) return byPriority;
519
- const byAge = a.updatedAt - b.updatedAt;
520
- if (byAge !== 0) return byAge;
521
578
  return Number(a.id) - Number(b.id);
522
579
  });
523
580
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What tone a lane, a reason, and a Team decision row wear.
3
+ *
4
+ * The page and the terminal each decide a colour, and before this table they decided it in
5
+ * seven places: a lane role in the lane table, a reason class in the browser bundle, two
6
+ * hand-written `[data-kind]` edge rules, a second `blocked` rule for the queue's why line,
7
+ * and a transcript that painted every lane the same accent. A tone stated once is the only
8
+ * version of that which cannot drift.
9
+ *
10
+ * A tone is a word the page's kit already names as a property (`var(--warn)`), so the page
11
+ * wears one directly and needs no map. The terminal speaks its own vocabulary — `ok`, `warn`,
12
+ * `bad`, `meta`, `faint` — and that bridge lives in `extensions/board/tone.ts`, because only
13
+ * the transcript speaks it.
14
+ *
15
+ * The rule behind the words, so a future kind is not a guess: **warn is a person who must act
16
+ * next** (approve, review, decide), **danger is a refusal or a loss** (blocked, gone), and
17
+ * anything merely true of the card — waiting, offered, executing, done, noted — is muted.
18
+ *
19
+ * Value-only: no I/O, no clock, no card.
20
+ */
21
+
22
+ import type { LaneRole } from "./lanes.ts";
23
+ import type { ReasonKind } from "./reason.ts";
24
+
25
+ /**
26
+ * A colour a board surface may wear.
27
+ *
28
+ * `dim` joins the lane roles because a Team queue row nobody has to act on yet is quieter
29
+ * than a muted one: drawn, not emphasised.
30
+ */
31
+ export type BoardTone = LaneRole | "dim";
32
+
33
+ /** Every tone, in the order a rule is written for one. */
34
+ export const BOARD_TONES = ["muted", "dim", "warn", "accent", "danger", "success"] as const;
35
+
36
+ /**
37
+ * The tone a card's reason line wears, by the kind `reason.ts` reports.
38
+ *
39
+ * `waiting` is here even though `reasonFor` never returns it: the page's card face invents it
40
+ * for a card that names what it waits on (`view/card.ts`), so the table has to answer for it
41
+ * or that line would draw the default.
42
+ */
43
+ export const REASON_TONE: Record<ReasonKind, BoardTone> = {
44
+ waiting: "muted",
45
+ blocked: "danger",
46
+ offered: "muted",
47
+ contract: "warn",
48
+ approval: "warn",
49
+ executing: "muted",
50
+ review: "warn",
51
+ done: "muted",
52
+ note: "muted",
53
+ };
54
+
55
+ /**
56
+ * The Team queue's decision kinds, in the order a lead reads them.
57
+ *
58
+ * One list for both facts a row needs: the position is the rank (a `gone` owner outranks a
59
+ * card merely sitting still), and {@link DECISION_TONE} is the colour. They were two lists — a
60
+ * `KIND_RANK` object in the bundle and a hand-written set of `[data-kind]` rules in the
61
+ * stylesheet — which is how `gone` came to be ranked second and drawn quietest.
62
+ */
63
+ export const DECISION_KINDS = ["blocked", "gone", "approval", "waiting", "idle", "unplanned", "unassigned"] as const;
64
+
65
+ /** One reason a card is in the lead's queue. */
66
+ export type DecisionKind = (typeof DECISION_KINDS)[number];
67
+
68
+ /** The tone a decision row's edge, and a danger row's reason line, wear. */
69
+ export const DECISION_TONE: Record<DecisionKind, BoardTone> = {
70
+ blocked: "danger",
71
+ gone: "danger",
72
+ approval: "warn",
73
+ waiting: "dim",
74
+ idle: "warn",
75
+ unplanned: "dim",
76
+ unassigned: "dim",
77
+ };
78
+
79
+ /**
80
+ * {@link DECISION_KINDS} as the browser bundle's own source.
81
+ *
82
+ * The page ranks its queue by this list, and the bundle is a string, so the order travels as
83
+ * data the way `reasonFor` and `matchesQuery` do: one list in `lib/`, spliced in, never a
84
+ * second copy in the browser. `test` parses it back to the tuple.
85
+ */
86
+ export const DECISION_RANK_JS = JSON.stringify(DECISION_KINDS);
87
+
88
+ /** {@link DECISION_TONE} as the bundle's own source, spliced in like the rank. */
89
+ export const DECISION_TONE_JS = JSON.stringify(DECISION_TONE);
90
+
91
+ /** {@link REASON_TONE} as the bundle's own source, spliced in like the rank. */
92
+ export const REASON_TONE_JS = JSON.stringify(REASON_TONE);
package/lib/board/view.ts CHANGED
@@ -15,15 +15,10 @@
15
15
  */
16
16
 
17
17
  import { type RosterSession, type RosterState, rosterStateOf } from "../session-roster.ts";
18
- import {
19
- type Card,
20
- CARD_STATUSES,
21
- type CardStatus,
22
- laneSince,
23
- priorityOf,
24
- type Priority,
25
- signatureOf,
26
- } from "./card.ts";
18
+ import { type Card, laneSince, priorityOf, type Priority, signatureOf } from "./card.ts";
19
+ import type { Skip } from "./store.ts";
20
+ import { LANE_RULES, LANES, type Lane, type LaneRole } from "./lanes.ts";
21
+ import { DEFAULT_WIP, limitOf, type WipLimits } from "./limits.ts";
27
22
  import { blockersOf, indexOf, lanesText, openDependentsOf } from "./links.ts";
28
23
  import type { Actor } from "./rules.ts";
29
24
  import { buildRound, type RoundSummary } from "./round.ts";
@@ -39,7 +34,7 @@ export interface ViewTeammate {
39
34
  mail: number;
40
35
  /** Whether it is listening for mail; absent means its record does not say so. */
41
36
  listening?: boolean;
42
- /** Whether this is the session the page writes as. */
37
+ /** Whether this is the seat the page is read as. */
43
38
  you: boolean;
44
39
  }
45
40
 
@@ -55,14 +50,16 @@ export interface ViewCard extends Card {
55
50
  /** The priority it acts on: a card that never set one is ordinary. */
56
51
  priority: Priority;
57
52
  /**
58
- * The unfinished cards it waits for, as `#2 (doing)`; absent when nothing blocks it.
53
+ * The unfinished cards it waits for, as `#2 (execute) Patch the gateway`; absent when
54
+ * nothing blocks it.
59
55
  *
60
56
  * `undefined` is the whole fact — a waiting card is one with a `waits` — because a
61
57
  * boolean beside it would be one more thing that can disagree with the string.
62
58
  */
63
59
  waits?: string;
64
60
  /**
65
- * The unfinished cards waiting on it, as `#9 (todo)`; absent when none do.
61
+ * The unfinished cards waiting on it, as `#9 (backlog) Ship the picker`; absent when none
62
+ * do.
66
63
  *
67
64
  * The other side of `waits`: what leaving this card undone holds up. Derived here
68
65
  * rather than in the page's script for the same reason, and filtered to the unfinished
@@ -80,32 +77,31 @@ export interface ViewCard extends Card {
80
77
  since?: number;
81
78
  }
82
79
 
83
- /** One lane and the cards in it, in the order the page draws them. */
80
+ /**
81
+ * One lane, as the page draws it: the name, the words, the colour, the limit, and the cards.
82
+ *
83
+ * The page holds no lane table of its own — it renders this — so a lane's label, its empty
84
+ * word, and its head's `n/limit` cannot read one way here and another in the transcript.
85
+ */
84
86
  export interface ViewLane {
85
- status: CardStatus;
87
+ key: Lane;
88
+ /** The word a head, a row, and a sentence print. */
89
+ label: string;
90
+ /** What the tray says when it holds nothing. */
91
+ empty: string;
92
+ /** The colour role the lane's dot and its card faces take. */
93
+ role: LaneRole;
94
+ /** The WIP limit this lane is held to, for the heads that show `n/limit`; absent when it is not pulled. */
95
+ limit?: number;
96
+ count: number;
86
97
  cards: ViewCard[];
87
98
  }
88
99
 
89
- /** The per-lane tallies the toolbar shows. */
90
- export interface ViewCounts {
91
- total: number;
92
- todo: number;
93
- doing: number;
94
- blocked: number;
95
- done: number;
96
- }
97
-
98
100
  /** Everything the page is allowed to know. */
99
101
  export interface BoardView {
100
- /**
101
- * The session a page write is made as, and the one the page is read as: its seat.
102
- *
103
- * The page is not a session that happens to be serving — it is a seat that outlives
104
- * whichever session serves it — so the browser is told one identity and no other.
105
- */
106
- me: { id?: string; label: string };
107
102
  lanes: ViewLane[];
108
- counts: ViewCounts;
103
+ /** Every card on the board: the sum of the lanes, said once so a head need not add them up. */
104
+ total: number;
109
105
  round: RoundSummary<ViewCard>;
110
106
  /**
111
107
  * The sessions on this repository's bus, or absent when there is no bus here.
@@ -119,10 +115,11 @@ export interface BoardView {
119
115
  /**
120
116
  * Card files the reader refused, so the footer can say the board is not empty.
121
117
  *
122
- * Zero is the ordinary answer and draws nothing; a count above it is the one fact
123
- * that separates "no work here" from "this build cannot read your work".
118
+ * An empty list is the ordinary answer and draws nothing; anything in it is the one fact
119
+ * that separates "no work here" from "this build cannot read your work" — and the file
120
+ * names and reasons are what let a reader open the file, so the page carries them whole.
124
121
  */
125
- skipped: number;
122
+ skipped: readonly Skip[];
126
123
  /** Changes when a card's revision, a teammate's state, or its mail does. */
127
124
  sig: string;
128
125
  /** The root's last segment, e.g. `my-pi-agent`: what the tab and the footer lead with. */
@@ -135,13 +132,19 @@ export interface BoardView {
135
132
 
136
133
  /** What the server read before building one snapshot. */
137
134
  export interface ViewInput {
138
- /** The page's own actor, which is both who it writes as and who is looking. */
135
+ /**
136
+ * The page's own actor: the seat the snapshot is read as.
137
+ *
138
+ * The page is not a session that happens to be serving — it is a seat that outlives
139
+ * whichever session serves it — so this is the one identity the browser's roster is marked
140
+ * against, and the page shows no other.
141
+ */
139
142
  me: Actor;
140
143
  cards: readonly Card[];
141
144
  now: number;
142
145
  bytes: number;
143
146
  /** Card files the reader refused; absent reads as none, which is the ordinary board. */
144
- skipped?: number;
147
+ skipped?: readonly Skip[];
145
148
  where: string;
146
149
  /** The repository's name, from `BoardStore.name()`. */
147
150
  repo: string;
@@ -149,6 +152,8 @@ export interface ViewInput {
149
152
  root: string;
150
153
  /** The bus's records, when the page could read them at all. */
151
154
  roster?: readonly RosterSession[];
155
+ /** The board's WIP limits, so a head can say `n/limit`; absent reads as the defaults. */
156
+ limits?: WipLimits;
152
157
  }
153
158
 
154
159
  /** Build the page's snapshot. */
@@ -160,25 +165,39 @@ export function buildView(input: ViewInput): BoardView {
160
165
  // card the lane does, so a client reading `round.unassigned` is not handed a card
161
166
  // missing the facts every other card has.
162
167
  const cards = input.cards.map((card) => viewCardOf(card, index));
163
- const lanes = CARD_STATUSES.map((status) => ({
164
- status,
165
- cards: cards.filter((card) => card.status === status),
166
- }));
168
+ const limits = input.limits ?? DEFAULT_WIP;
169
+ const lanes = LANES.map((key) => {
170
+ const rule = LANE_RULES[key];
171
+ const inLane = cards.filter((card) => card.lane === key);
172
+ const limit = limitOf(key, limits);
173
+ return {
174
+ key,
175
+ label: rule.label,
176
+ empty: rule.empty,
177
+ role: rule.role,
178
+ ...(limit === undefined ? {} : { limit }),
179
+ count: inLane.length,
180
+ cards: inLane,
181
+ };
182
+ });
167
183
  const reader = input.me;
168
184
  const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
169
185
  return {
170
- me: input.me.id === undefined ? { label: input.me.label } : { id: input.me.id, label: input.me.label },
171
186
  lanes,
172
- counts: countOf(lanes),
187
+ total: cards.length,
173
188
  round: buildRound(cards, input.now),
174
189
  ...(teammates === undefined ? {} : { teammates }),
175
190
  bytes: input.bytes,
176
- skipped: input.skipped ?? 0,
191
+ skipped: input.skipped ?? [],
177
192
  // The roster enters the signature at its coarse facts only: a heartbeat every ten
178
193
  // seconds is not news, while a join, a quiet and a letter are. A file that became
179
- // readable, or unreadable, is news too: the footer counts it, and a frame that is
180
- // not pushed leaves the page saying the board holds less than it does.
181
- sig: signatureOf(input.cards) + rosterSignature(teammates) + `|skipped:${input.skipped ?? 0}`,
194
+ // readable, or unreadable, is news too, and so is a *different* file becoming
195
+ // unreadable: the footer names them, so the signature carries the names and reasons
196
+ // rather than a count that a swap would leave unchanged.
197
+ sig:
198
+ signatureOf(input.cards) +
199
+ rosterSignature(teammates) +
200
+ `|skipped:${(input.skipped ?? []).map((entry) => `${entry.file}:${entry.why}`).join(",")}`,
182
201
  repo: input.repo,
183
202
  root: input.root,
184
203
  where: input.where,
@@ -192,8 +211,11 @@ export function buildView(input: ViewInput): BoardView {
192
211
  * derived field written onto one would outlive the read that made it.
193
212
  */
194
213
  function viewCardOf(card: Card, index: ReadonlyMap<string, Card>): ViewCard {
195
- const waits = lanesText(blockersOf(card, index));
196
- const dependents = lanesText(openDependentsOf(card.id, index));
214
+ // The page draws these two as its own lines, and a reader has to know *which* card: the
215
+ // lane alone is a state, and the title is the card. The transcript asks for no titles —
216
+ // a refusal is a row, and the sentence is the point there.
217
+ const waits = lanesText(blockersOf(card, index), { titles: true });
218
+ const dependents = lanesText(openDependentsOf(card.id, index), { titles: true });
197
219
  const since = laneSince(card);
198
220
  return {
199
221
  ...card,
@@ -228,13 +250,3 @@ function rosterSignature(teammates: readonly ViewTeammate[] | undefined): string
228
250
  teammates.map((teammate) => `${teammate.label}:${teammate.state}:${teammate.mail}:${teammate.listening}`).join(",")
229
251
  );
230
252
  }
231
-
232
- /** The tallies, summed from the lanes so the two can never disagree. */
233
- function countOf(lanes: readonly ViewLane[]): ViewCounts {
234
- const counts: ViewCounts = { total: 0, todo: 0, doing: 0, blocked: 0, done: 0 };
235
- for (const lane of lanes) {
236
- counts[lane.status] = lane.cards.length;
237
- counts.total += lane.cards.length;
238
- }
239
- return counts;
240
- }
@@ -1,23 +1,32 @@
1
1
  /**
2
- * A command watcher's process lifecycle: spawn, tree-kill, escalate, and drain.
2
+ * A spawned command's lifecycle: spawn, tree-kill, escalate, and drain.
3
3
  *
4
- * A command source is a spawned process whose merged stdout and stderr feed the
5
- * matcher. This module owns the process side only — the spawn, the SIGTERM →
6
- * SIGKILL grace, and the shutdown drain — over the injected `SpawnFn` and
7
- * `KillTreeFn` seams, so the runtime keeps the watcher's status, its repaint,
8
- * and its clock.
4
+ * A `job` or a `watch` command source is a process whose merged stdout and
5
+ * stderr feed its owner. This module owns the process side only — the spawn, the
6
+ * SIGTERM → SIGKILL grace, and the shutdown drain — over the injected `SpawnFn`
7
+ * and `KillTreeFn` seams, so each owner keeps its own status, repaint, and clock.
8
+ * The escalation has one body and one default (`KILL_GRACE_MS`); each owner passes
9
+ * its configured grace, so `job` escalates at its `killGraceMs` and `watch` at its
10
+ * own, and neither re-implements the SIGTERM → SIGKILL step.
9
11
  *
10
12
  * Value-only: the child handle and its escalation timer live on the caller's
11
13
  * slot, never here, because Pi loads each extension with its own module cache.
12
14
  */
13
15
 
14
- import { defaultKillTree, defaultSpawn, type KillTreeFn, type SpawnFn, type SpawnedProcess } from "../../lib/spawn.ts";
16
+ import {
17
+ defaultKillTree,
18
+ defaultSpawn,
19
+ type KillTreeFn,
20
+ type SpawnFn,
21
+ type SpawnedProcess,
22
+ type SpawnOptions,
23
+ } from "./spawn.ts";
15
24
 
16
25
  /** Grace period before a stopped command is escalated to SIGKILL. */
17
26
  export const KILL_GRACE_MS = 2000;
18
27
 
19
28
  /**
20
- * The process fields a watcher record carries. The supervisor mutates them in
29
+ * The process fields a caller's record carries. The supervisor mutates them in
21
30
  * place, so the runtime's record and the module's handle cannot disagree about
22
31
  * whether a child is still live.
23
32
  */
@@ -27,7 +36,7 @@ export interface ChildSlot {
27
36
  killTimer?: ReturnType<typeof setTimeout>;
28
37
  }
29
38
 
30
- /** How a child settled, for the sink that owns the watcher's status transition. */
39
+ /** How a child settled, for the sink that owns the caller's status transition. */
31
40
  export type ChildOutcome =
32
41
  | { kind: "closed"; code: number | null; signal: string | null }
33
42
  | { kind: "failed"; message: string };
@@ -37,7 +46,7 @@ export interface ChildSink {
37
46
  onData(chunk: Buffer): void;
38
47
  /**
39
48
  * The child closed or failed to spawn. The sink owns the status, because only
40
- * it knows whether the watcher was still running when that happened.
49
+ * it knows whether the job or watcher was still running when that happened.
41
50
  */
42
51
  onSettled(outcome: ChildOutcome): void;
43
52
  }
@@ -50,12 +59,19 @@ export interface ChildSupervisorOptions {
50
59
  }
51
60
 
52
61
  export interface ChildSupervisor {
53
- /** Spawn `command`, or return the spawn error and leave the slot's child unset. */
54
- start(slot: ChildSlot, command: string, cwd: string, sink: ChildSink): string | undefined;
55
- /** SIGTERM a command, escalating to SIGKILL when it has not closed in time. */
56
- terminate(slot: ChildSlot): void;
57
- /** Kill immediately, without arming an escalation. */
58
- kill(slot: ChildSlot, signal: "SIGTERM" | "SIGKILL"): void;
62
+ /**
63
+ * Spawn `command` with the caller's options, or return the spawn error and
64
+ * leave the slot's child unset. The child is readable as `slot.child`.
65
+ */
66
+ start(slot: ChildSlot, command: string, options: SpawnOptions, sink: ChildSink): string | undefined;
67
+ /**
68
+ * Send `signal` (SIGTERM by default) and escalate to SIGKILL when the command
69
+ * has not closed within the grace. A SIGKILL was already final, so it is not
70
+ * escalated.
71
+ */
72
+ terminate(slot: ChildSlot, signal?: NodeJS.Signals): void;
73
+ /** Signal now, without arming an escalation. */
74
+ kill(slot: ChildSlot, signal: NodeJS.Signals): void;
59
75
  /** Cancel a pending escalation. */
60
76
  clearTimer(slot: ChildSlot): void;
61
77
  /** Resolve when every slot's child has closed, or after `ms`, whichever comes first. */
@@ -74,7 +90,7 @@ export function createChildSupervisor(options: ChildSupervisorOptions = {}): Chi
74
90
  }
75
91
  };
76
92
 
77
- const kill = (slot: ChildSlot, signal: "SIGTERM" | "SIGKILL"): void => {
93
+ const kill = (slot: ChildSlot, signal: NodeJS.Signals): void => {
78
94
  const pid = slot.pid;
79
95
  try {
80
96
  if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
@@ -84,9 +100,12 @@ export function createChildSupervisor(options: ChildSupervisorOptions = {}): Chi
84
100
  }
85
101
  };
86
102
 
87
- const terminate = (slot: ChildSlot): void => {
103
+ const terminate = (slot: ChildSlot, signal: NodeJS.Signals = "SIGTERM"): void => {
88
104
  clearTimer(slot);
89
- kill(slot, "SIGTERM");
105
+ kill(slot, signal);
106
+ // A SIGKILL is already final: arming an escalation behind it would only fire against
107
+ // a slot that is about to be cleared, and the doc above says it does not.
108
+ if (signal === "SIGKILL") return;
90
109
  if (slot.pid === undefined && !slot.child) return;
91
110
  slot.killTimer = setTimeout(() => {
92
111
  slot.killTimer = undefined;
@@ -95,10 +114,10 @@ export function createChildSupervisor(options: ChildSupervisorOptions = {}): Chi
95
114
  slot.killTimer.unref?.();
96
115
  };
97
116
 
98
- const start = (slot: ChildSlot, command: string, cwd: string, sink: ChildSink): string | undefined => {
117
+ const start = (slot: ChildSlot, command: string, options: SpawnOptions, sink: ChildSink): string | undefined => {
99
118
  let child: SpawnedProcess;
100
119
  try {
101
- child = spawnFn(command, { cwd });
120
+ child = spawnFn(command, options);
102
121
  } catch (error) {
103
122
  return error instanceof Error ? error.message : String(error);
104
123
  }