@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
@@ -36,20 +36,28 @@ export function indexOf(cards: readonly Card[]): BoardIndex {
36
36
  export function blockersOf(card: Card, board: BoardIndex): Card[] {
37
37
  return dependsOf(card)
38
38
  .map((id) => board.get(id))
39
- .filter((other): other is Card => other !== undefined && other.status !== "done");
39
+ .filter((other): other is Card => other !== undefined && other.lane !== "done");
40
40
  }
41
41
 
42
42
  /**
43
- * A list of cards as a reader meets it: `#2 (doing), #3 (blocked)`.
43
+ * A list of cards as a reader meets it: `#2 (execute), #3 (blocked)`.
44
44
  *
45
45
  * The lane is what turns ids into a decision — a finished card is about to start, a
46
- * `doing` one is to leave alone — so it is never left out. One wording serves the
46
+ * `execute` one is to leave alone — so it is never left out. One formatter serves the
47
47
  * refusal sentence, the page's reason line, and the mirror question of what a card is
48
48
  * holding up: every one of them is a list of cards read to decide something, so a second
49
49
  * formatter would be a second way for the same list to read.
50
+ *
51
+ * `titles` adds the card's own title after its lane, for the one audience with the room
52
+ * and the reason for it: a card's face on the page is where a reader decides what to do
53
+ * next, and an id alone would send them into the dialog to find out which card it is. A
54
+ * refusal stays ids and lanes — it is read in a transcript row, and a title there would
55
+ * push the sentence it belongs to off the line.
50
56
  */
51
- export function lanesText(cards: readonly Card[]): string {
52
- return cards.map((card) => `${cardRef(card.id)} (${card.status})`).join(", ");
57
+ export function lanesText(cards: readonly Card[], options: { titles?: boolean } = {}): string {
58
+ return cards
59
+ .map((card) => `${cardRef(card.id)} (${card.lane})${options.titles === true ? ` ${card.title}` : ""}`)
60
+ .join(", ");
53
61
  }
54
62
 
55
63
  /** The cards that wait on `id`, oldest id first, so a report reads in board order. */
@@ -66,7 +74,7 @@ export function dependentsOf(id: string, board: BoardIndex): Card[] {
66
74
  * refusal is about a link that would be stranded rather than about work that is held up.
67
75
  */
68
76
  export function openDependentsOf(id: string, board: BoardIndex): Card[] {
69
- return dependentsOf(id, board).filter((card) => card.status !== "done");
77
+ return dependentsOf(id, board).filter((card) => card.lane !== "done");
70
78
  }
71
79
 
72
80
  /** The sentence a claim, a move, or an assignment is refused with while `card` waits, or undefined. */
@@ -106,21 +114,34 @@ export function wouldCycle(id: string, dependsOn: readonly string[], board: Boar
106
114
  *
107
115
  * `[]` is a legal answer and means "waits on nothing", so an edit can remove a link
108
116
  * — the caller decides whether an absent list means "leave it alone" or "none".
117
+ *
118
+ * A link the card already carries is kept even when the card it names has left the board:
119
+ * `archive` moves a finished card's file rather than deleting its record, the link is inert
120
+ * (a finished dependency stops blocking), and refusing it would make `edit` unable to
121
+ * reproduce what the card says. A link the card does not already carry still has to name a
122
+ * card on the board, so an id nobody can see cannot be added.
109
123
  */
110
- export function normalizeDepends(raw: unknown, id: string, board: BoardIndex): { dependsOn: string[] } | FieldError {
124
+ export function normalizeDepends(
125
+ raw: unknown,
126
+ card: Pick<Card, "id" | "dependsOn">,
127
+ board: BoardIndex,
128
+ ): { dependsOn: string[] } | FieldError {
111
129
  if (!Array.isArray(raw)) return { error: "dependsOn must be a list of card ids." };
130
+ const held = dependsOf(card);
112
131
  const dependsOn: string[] = [];
113
132
  for (const entry of raw) {
114
133
  const parsed = normalizeCardId(entry);
115
134
  // The file name is the canonical spelling of an id, so `#04` and `4` are one card.
116
135
  const dependency = parsed === undefined ? undefined : String(Number(parsed));
117
136
  if (dependency === undefined) return { error: `${JSON.stringify(entry)} is not a card id.` };
118
- if (dependency === id) return { error: `${cardRef(id)} cannot wait on itself.` };
119
- if (!board.has(dependency)) return { error: `${cardRef(dependency)} does not exist.` };
137
+ if (dependency === card.id) return { error: `${cardRef(card.id)} cannot wait on itself.` };
138
+ if (!board.has(dependency) && !held.includes(dependency)) {
139
+ return { error: `${cardRef(dependency)} does not exist.` };
140
+ }
120
141
  if (!dependsOn.includes(dependency)) dependsOn.push(dependency);
121
142
  }
122
143
  if (dependsOn.length > MAX_DEPENDS) return { error: `at most ${MAX_DEPENDS} dependencies are allowed.` };
123
- const loop = wouldCycle(id, dependsOn, board);
144
+ const loop = wouldCycle(card.id, dependsOn, board);
124
145
  if (loop !== undefined) return { error: loop };
125
146
  return { dependsOn };
126
147
  }
@@ -66,7 +66,7 @@ export interface PagePointer {
66
66
  port: number;
67
67
  /** The page's one token: every read, and nothing else to authorise. */
68
68
  token: string;
69
- /** The seat the page writes as. */
69
+ /** The seat the page is read as. */
70
70
  seat: { id?: string; label: string };
71
71
  /** When the page started, so a reader can tell a fresh note from a stale one. */
72
72
  startedAt: number;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Reading a card file: the one gate a read passes, and the map from an older shape.
3
+ *
4
+ * The record itself is `card.ts`; this is what a file on disk becomes. Every version this
5
+ * build can read passes through {@link readCard}, which maps the names a past build wrote
6
+ * and stamps the version this one writes — a v1 file says `status`, `plan`, and a
7
+ * `blockedBy`/`blocker` pair where the record says `lane`, `planPath`, and `blocked`. The
8
+ * map is read-time only: no file is rewritten, so a board full of older cards reads as a
9
+ * board rather than as a pile of refusals, and each card takes the new shape on its next
10
+ * write.
11
+ *
12
+ * Pure: no I/O, no clock, no state.
13
+ */
14
+
15
+ import { BLOCKERS, type BlockerKind, type Card, type CardBlock, CARD_VERSION, isCard } from "./card.ts";
16
+ import type { Lane } from "./lanes.ts";
17
+
18
+ /**
19
+ * The lane words the four-lane board used, as this build's names.
20
+ *
21
+ * `todo` was the pool nobody held and `doing` was the work in hand; `blocked` and `done`
22
+ * kept their names, so they need no row here. A word with no row is not one this build can
23
+ * place, so the entry is left as it is and {@link isCard} refuses the record — a lane
24
+ * guessed from an unknown word would put a card somewhere its own history disagrees with.
25
+ */
26
+ const LEGACY_LANES: Readonly<Record<string, Lane>> = { todo: "backlog", doing: "execute" };
27
+
28
+ /**
29
+ * The card as a file on disk holds it, or `undefined` when this build cannot trust it.
30
+ *
31
+ * A v1 file is the record before the lane and the block were named: `status` for `lane`,
32
+ * `plan` for `planPath`, a `blockedBy`/`blocker` pair for `blocked`, and — for a file older
33
+ * still — log entries whose lane is spelled `status` too. This maps them on read and stamps
34
+ * `v: 2`; the map is read-time only, so no file is rewritten and a card takes the new shape
35
+ * on its next write. Everything else is {@link isCard}'s decision; a version above this
36
+ * build's is the store's refusal to name, not this function's.
37
+ */
38
+ export function readCard(value: unknown): Card | undefined {
39
+ const record = migrated(value);
40
+ return isCard(record) ? record : undefined;
41
+ }
42
+
43
+ /** A v1 record as this build's shape: the renames, and the version stamp. */
44
+ function migrated(value: unknown): unknown {
45
+ if (typeof value !== "object" || value === null) return value;
46
+ const record = value as Record<string, unknown>;
47
+ if (record.v !== 1) return value;
48
+ const { status, plan, blockedBy, blocker, log, ...rest } = record;
49
+ const blocked = blockOf(blockedBy, blocker);
50
+ return {
51
+ ...rest,
52
+ v: CARD_VERSION,
53
+ ...(status === undefined || record.lane !== undefined ? {} : { lane: status }),
54
+ ...(plan === undefined ? {} : { planPath: record.planPath ?? plan }),
55
+ ...(blocked === undefined || record.blocked !== undefined ? {} : { blocked }),
56
+ ...(log === undefined ? {} : { log: logEntries(log) }),
57
+ };
58
+ }
59
+
60
+ /** The block a v1 pair spells, keeping whichever half the file actually has. */
61
+ function blockOf(rawNote: unknown, rawKind: unknown): CardBlock | undefined {
62
+ const kind =
63
+ typeof rawKind === "string" && BLOCKERS.includes(rawKind as BlockerKind) ? (rawKind as BlockerKind) : undefined;
64
+ const note = typeof rawNote === "string" && rawNote !== "" ? rawNote : undefined;
65
+ if (kind === undefined && note === undefined) return undefined;
66
+ return { ...(kind === undefined ? {} : { kind }), ...(note === undefined ? {} : { note }) };
67
+ }
68
+
69
+ /** A v1 log whose entries spelled the lane `status`, read as this build's entries. */
70
+ function logEntries(raw: unknown): unknown {
71
+ if (!Array.isArray(raw)) return raw;
72
+ return raw.map((entry) => {
73
+ if (typeof entry !== "object" || entry === null) return entry;
74
+ const record = entry as Record<string, unknown>;
75
+ if (record.status === undefined || record.lane !== undefined) return entry;
76
+ const { status, ...rest } = record;
77
+ // The four-lane board's `todo` and `doing` are this build's `backlog` and `execute`;
78
+ // a status that is already a lane name passes through, and anything else is left for
79
+ // `isCard` to refuse rather than mapped onto a lane nobody wrote.
80
+ const lane = typeof status === "string" ? (LEGACY_LANES[status] ?? status) : status;
81
+ return { ...rest, lane };
82
+ });
83
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Why a card sits where it does: one rule, for the card face, the lead's queue, and the
3
+ * transcript.
4
+ *
5
+ * A card's lane says what kind of state it is in; the reason line says the one fact that
6
+ * decides what happens next — an order it waits on, a block somebody recorded, a contract
7
+ * nobody approved yet, a spot-check nobody has done. That sentence used to be written
8
+ * three times (the page's card face, its Team queue, and `board`'s card block), and the
9
+ * flow this board now runs multiplies the states it has to name, so it is one body.
10
+ *
11
+ * It is spliced into the browser bundle as {@link REASON_FOR_JS}, which is why it is
12
+ * self-contained: it reads the card and a formatter for relative times, and nothing else.
13
+ * The two callers differ only in whose clock an age is read against — the page's `ago`
14
+ * against the reader's now, the transcript's against the snapshot's — so the clock is a
15
+ * parameter rather than an import.
16
+ *
17
+ * Pure: no I/O, no clock of its own, no state.
18
+ */
19
+
20
+ import type { Card } from "./card.ts";
21
+
22
+ /** What a reason is about, which is what a row's edge and a dot are coloured by. */
23
+ export const REASON_KINDS = [
24
+ "waiting",
25
+ "blocked",
26
+ "offered",
27
+ "contract",
28
+ "approval",
29
+ "executing",
30
+ "review",
31
+ "done",
32
+ "note",
33
+ ] as const;
34
+
35
+ /** One kind of reason. */
36
+ export type ReasonKind = (typeof REASON_KINDS)[number];
37
+
38
+ /** One reason line: what it is about, and the sentence a reader gets. */
39
+ export interface Reason {
40
+ kind: ReasonKind;
41
+ text: string;
42
+ }
43
+
44
+ /**
45
+ * The one line under a card, or `undefined` when the card has nothing to say.
46
+ *
47
+ * The order inside a lane is the weight of the fact: an unfinished dependency outranks
48
+ * everything (a claim would be refused), then the lane's own decision, then the last note.
49
+ */
50
+ export function reasonFor(card: Card, ago: (at: number) => string): Reason | undefined {
51
+ const last = card.notes.length > 0 ? card.notes[card.notes.length - 1] : undefined;
52
+ if (card.lane === "blocked") {
53
+ // The recorded block is the reason: its sentence if one was written, else the kind,
54
+ // which is what says where the card goes back to. Only a card with no block at all —
55
+ // one hand-edited, or released and parked again before this record existed — falls
56
+ // back to the last note, and then to saying it has nothing.
57
+ if (card.blocked !== undefined) {
58
+ const kind = card.blocked.kind;
59
+ return {
60
+ kind: "blocked",
61
+ text: card.blocked.note ?? "blocked on a " + (kind === undefined ? "decision" : kind),
62
+ };
63
+ }
64
+ return { kind: "blocked", text: last === undefined ? "blocked, with nothing recorded" : last.text };
65
+ }
66
+ if (card.lane === "backlog") {
67
+ if (card.offeredAt !== undefined) {
68
+ return { kind: "offered", text: "offered " + ago(card.offeredAt) + " ago — nobody took it" };
69
+ }
70
+ return last === undefined ? undefined : { kind: "note", text: last.text };
71
+ }
72
+ if (card.lane === "plan") {
73
+ // The contract's three lines, read here so the page and the transcript name the same
74
+ // missing field: this body is spliced, so it cannot call the module that owns them.
75
+ const missing = [];
76
+ const fields = ["goal", "acceptance", "scopeOut"];
77
+ for (let index = 0; index < fields.length; index++) {
78
+ const value = card.contract === undefined ? undefined : card.contract[fields[index] as "goal"];
79
+ if (typeof value !== "string" || value === "") missing.push(fields[index]);
80
+ }
81
+ if (missing.length > 0) {
82
+ return { kind: "contract", text: "no contract yet — missing " + missing.join(", ") };
83
+ }
84
+ const approved = card.contract === undefined ? undefined : card.contract.approved;
85
+ if (approved === undefined) return { kind: "approval", text: "contract ready — needs approval" };
86
+ // A card planned before the plan was required: its contract names no artifact, which
87
+ // `execute` now refuses, so the line says what is missing rather than who signed.
88
+ if (card.planPath === undefined) return { kind: "contract", text: "contract approved — no plan recorded" };
89
+ return { kind: "approval", text: "contract approved by " + approved.by };
90
+ }
91
+ if (card.lane === "execute") {
92
+ if (card.review !== undefined) return { kind: "review", text: "spot-checked by " + card.review.by };
93
+ if (card.planPath === undefined) return { kind: "contract", text: "no plan recorded" };
94
+ return { kind: "executing", text: "executing — no spot-check yet" };
95
+ }
96
+ const landed = card.landed === undefined ? "" : " · landed " + card.landed.commit;
97
+ const who = card.owner === undefined ? "" : " by " + card.owner.label;
98
+ return { kind: "done", text: "✓ done" + who + landed };
99
+ }
100
+
101
+ /** The same body, as the browser receives it: one rule for the page and the transcript. */
102
+ export const REASON_FOR_JS = String(reasonFor);
@@ -6,32 +6,54 @@
6
6
  * because a lead in a terminal should not have to open a browser to see what needs a
7
7
  * decision. One text, so the two cannot describe one board differently.
8
8
  *
9
- * It reads the cards and the round and nothing else — no clock, no roster, no store — so
10
- * `plural` and the group lines are pure and a caller supplies what it already read.
9
+ * It reads the cards, the round, and — when a caller has one — the roster, and never a clock
10
+ * or a store: the group lines are pure and a caller supplies what it already read. The roster
11
+ * is what lets an owner who is only history be stated once instead of once per label, and it
12
+ * is optional because a board works with `session-bus` unloaded.
11
13
  */
12
14
 
13
15
  import { type Card, cardRef } from "./card.ts";
14
16
  import type { RoundSummary } from "./round.ts";
15
17
 
18
+ /** What the fold needs of a roster row: the page's `ViewTeammate` and the bus's record both answer it. */
19
+ export interface RosterLabel {
20
+ label: string;
21
+ }
22
+
16
23
  /**
17
24
  * The rollup: a headline of what a lead decides on, then one line per group.
18
25
  *
19
26
  * Groups name their cards by id and state, because the ids are what the next action
20
27
  * takes, and an empty group is left out rather than drawn empty.
28
+ *
29
+ * `roster` decides which owners are only history: the ones a bus has no record of and that
30
+ * hold nothing are stated in one line, because a label that ever claimed a card stays an
31
+ * owner for good. Without a roster nothing is called absent, which is the honest answer for
32
+ * a machine where the bus has recorded nobody.
21
33
  */
22
- export function formatRound(cards: readonly Card[], round: RoundSummary): string {
34
+ export function formatRound(cards: readonly Card[], round: RoundSummary, roster?: readonly RosterLabel[]): string {
23
35
  const owners = round.owners.length;
24
36
  const headline = [
25
37
  plural(owners, "owner"),
26
38
  plural(cards.length, "feature"),
27
39
  `${round.unassigned.length} unassigned`,
28
40
  `${round.unplanned.length} unplanned`,
41
+ `${round.unapproved.length} awaiting approval`,
29
42
  `${round.blocked.length} blocked`,
30
43
  `${round.idle.length} idle`,
31
44
  `${round.done.length} done`,
32
45
  ].join(" · ");
33
46
  const lines = [headline];
47
+ // The history is counted as it goes, so its one line can follow the owner lines and the
48
+ // queue can start where a lead actually decides something.
49
+ let history = 0;
50
+ let finished = 0;
34
51
  for (const group of round.owners) {
52
+ if (isHistoryOwner(group.label, group.cards.length, roster)) {
53
+ history += 1;
54
+ finished += group.done.length;
55
+ continue;
56
+ }
35
57
  // An owner who finished everything is still listed, with what it finished: the
36
58
  // count says which kind of work is left, and an empty list is a legitimate answer.
37
59
  const counts =
@@ -39,8 +61,10 @@ export function formatRound(cards: readonly Card[], round: RoundSummary): string
39
61
  const listed = group.cards.map((card) => brief(card)).join(", ");
40
62
  lines.push(listed === "" ? `${group.label} (${counts})` : `${group.label} (${counts}): ${listed}`);
41
63
  }
64
+ if (history > 0) lines.push(historyLine(history, finished));
42
65
  lines.push(...group("unassigned", round.unassigned));
43
66
  lines.push(...group("unplanned", round.unplanned));
67
+ lines.push(...group("unapproved", round.unapproved));
44
68
  lines.push(...group("blocked", round.blocked, true));
45
69
  lines.push(...group("idle", round.idle));
46
70
  lines.push(...group("done", round.done));
@@ -61,10 +85,48 @@ export function plural(count: number, noun: string): string {
61
85
  /** The same body, as the browser receives it: one wording for `1 card`, not two. */
62
86
  export const PLURAL_JS = String(plural);
63
87
 
64
- /** `#4 doing planned`, the brief a rollup group needs. */
88
+ /**
89
+ * Whether an owner is only history: the bus has no record of it, and it holds nothing.
90
+ *
91
+ * The Team tab and this text answer one question — which owners a board still has to show —
92
+ * so they ask it here. A label that ever claimed a card stays an owner for good, which is why
93
+ * the set only grows and why it is stated once.
94
+ *
95
+ * `roster` absent is "no bus here", not "no record": a machine that has recorded nobody
96
+ * cannot call an owner absent, and the page says `—` for the same reason.
97
+ */
98
+ export function isHistoryOwner(label: string, held: number, roster: readonly RosterLabel[] | undefined): boolean {
99
+ if (roster === undefined || held > 0) return false;
100
+ const key = label.toLowerCase();
101
+ for (const session of roster) {
102
+ if (session.label.toLowerCase() === key) return false;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ /** The same body, as the browser receives it. */
108
+ export const HISTORY_OWNER_JS = String(isHistoryOwner);
109
+
110
+ /**
111
+ * The one line the fold prints: `18 owners with no record · 71 done`.
112
+ *
113
+ * The page's history row and this line are one sentence, so the count of owners and the work
114
+ * they finished read the same on both surfaces.
115
+ */
116
+ export function historyLine(owners: number, done: number): string {
117
+ return plural(owners, "owner") + " with no record · " + done + " done";
118
+ }
119
+
120
+ /** The same body, as the browser receives it: it calls `plural`, which the fragment holds. */
121
+ export const HISTORY_LINE_JS = String(historyLine);
122
+
123
+ /** `#4 execute planned`, the brief a rollup group needs. */
65
124
  function brief(card: Card): string {
66
- const planned = card.status === "doing" ? (card.plan === undefined ? " unplanned" : " planned") : "";
67
- return `${cardRef(card.id)} ${card.status}${planned}`;
125
+ // Only the two lanes a card is worked in say whether it was planned first: the pool has
126
+ // no plan yet, and a finished card is history.
127
+ const planned =
128
+ card.lane === "plan" || card.lane === "execute" ? (card.planPath === undefined ? " unplanned" : " planned") : "";
129
+ return `${cardRef(card.id)} ${card.lane}${planned}`;
68
130
  }
69
131
 
70
132
  /** One rollup group, or nothing when it is empty. */
@@ -8,16 +8,17 @@
8
8
  * know — nobody has touched it in half an hour — not that its owner's process is
9
9
  * gone.
10
10
  *
11
- * The groups overlap on purpose. A `doing` card with no plan and no touch for half
12
- * an hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
13
- * half the reason a lead should look at it.
11
+ * The groups overlap on purpose. A `plan` card with no contract and no touch for half
12
+ * an hour is both `unplanned` and, once it is executing, `idle`; hiding it in one of the
13
+ * two would lose half the reason a lead should look at it.
14
14
  *
15
15
  * Pure: no I/O and no clock of its own.
16
16
  */
17
17
 
18
18
  import type { Card } from "./card.ts";
19
+ import { contractApproved, missingContractFields } from "./contract.ts";
19
20
 
20
- /** How long a `doing` card may go untouched before the rollup calls it idle. */
21
+ /** How long an `execute` card may go untouched before the rollup calls it idle. */
21
22
  export const IDLE_MS = 30 * 60 * 1_000;
22
23
 
23
24
  /**
@@ -35,17 +36,22 @@ export interface RoundGroup<T extends Card = Card> {
35
36
 
36
37
  /** The lead's rollup of one board. */
37
38
  export interface RoundSummary<T extends Card = Card> {
38
- /** Every owner with at least one unfinished card, ordered by label. */
39
+ /** Every owner the board names, ordered by label, with its live cards and the ones it finished. */
39
40
  owners: RoundGroup<T>[];
40
- /** `todo` cards: nobody has taken them yet. */
41
+ /** `backlog` cards: nobody has taken them yet. */
41
42
  unassigned: T[];
42
- /** `doing` cards with no plan recorded: work started before it was planned. */
43
+ /**
44
+ * `plan` cards whose contract is still missing a line, or whose plan was never recorded:
45
+ * work started before it was agreed, and either one refuses `execute`.
46
+ */
43
47
  unplanned: T[];
48
+ /** `plan` cards whose contract is complete and nobody has approved: the lead's queue. */
49
+ unapproved: T[];
44
50
  /** `blocked` cards, and the note that says why. */
45
51
  blocked: T[];
46
- /** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
52
+ /** `execute` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
47
53
  idle: T[];
48
- /** Finished cards, oldest first, kept as history. */
54
+ /** Finished cards, in the board's own order — priority, then age — kept as history. */
49
55
  done: T[];
50
56
  }
51
57
 
@@ -64,21 +70,30 @@ export function buildRound<T extends Card = Card>(cards: readonly T[], now: numb
64
70
  const groups = new Map<string, RoundGroup<T>>();
65
71
  const unassigned: T[] = [];
66
72
  const unplanned: T[] = [];
73
+ const unapproved: T[] = [];
67
74
  const blocked: T[] = [];
68
75
  const idle: T[] = [];
69
76
  const done: T[] = [];
70
77
 
71
78
  for (const card of cards) {
72
- if (card.status === "todo") unassigned.push(card);
73
- if (card.status === "done") done.push(card);
74
- if (card.status === "blocked") blocked.push(card);
75
- if (card.status === "doing" && card.plan === undefined) unplanned.push(card);
76
- if (card.status === "doing" && now - card.updatedAt >= IDLE_MS) idle.push(card);
79
+ // The pool is what nobody holds, and the two lanes a lead watches are the two a card
80
+ // is worked in: a plan with a contract still missing is work started before it was
81
+ // agreed, and an execute nothing has touched is work that may have stopped.
82
+ if (card.lane === "backlog") unassigned.push(card);
83
+ if (card.lane === "done") done.push(card);
84
+ if (card.lane === "blocked") blocked.push(card);
85
+ if (card.lane === "plan") {
86
+ // A complete contract still needs a file to execute against: a card planned before
87
+ // the artifact rule has neither, and it belongs in the queue that leads somewhere.
88
+ if (missingContractFields(card.contract).length > 0 || card.planPath === undefined) unplanned.push(card);
89
+ else if (!contractApproved(card.contract)) unapproved.push(card);
90
+ }
91
+ if (card.lane === "execute" && now - card.updatedAt >= IDLE_MS) idle.push(card);
77
92
 
78
93
  if (card.owner === undefined) continue;
79
94
  const key = card.owner.label.toLowerCase();
80
95
  const group = groups.get(key) ?? { label: card.owner.label, cards: [], done: [] };
81
- if (card.status === "done") group.done.push(card);
96
+ if (card.lane === "done") group.done.push(card);
82
97
  else group.cards.push(card);
83
98
  groups.set(key, group);
84
99
  }
@@ -87,6 +102,7 @@ export function buildRound<T extends Card = Card>(cards: readonly T[], now: numb
87
102
  owners: [...groups.values()].sort((a, b) => a.label.localeCompare(b.label)),
88
103
  unassigned,
89
104
  unplanned,
105
+ unapproved,
90
106
  blocked,
91
107
  idle,
92
108
  done,