@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -20,18 +20,29 @@
20
20
  * a board is tens of files, so the read is cheaper than the second source of truth a
21
21
  * cached index would be.
22
22
  *
23
- * Nothing prunes. A `done` card is history, the page shows the store's size, and
24
- * `drop` is the only deletion.
23
+ * Nothing prunes by itself. `cards/` is the board and `archive/` is where a finished card
24
+ * is set aside by `archive` — its file moved rather than rewritten, so the record is
25
+ * unchanged and the board stops paying for it — and `ended/` keeps the record of a `drop`
26
+ * alone. The page shows the store's size, and `drop` is the only deletion.
25
27
  */
26
28
 
27
29
  import { link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
28
- import { join } from "node:path";
30
+ import { basename, join } from "node:path";
29
31
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
32
  import { resolveEffectiveCwd } from "../env.ts";
31
33
  import { hasErrorCode, messageOf } from "../errors.ts";
32
34
  import { shortenPath } from "../format.ts";
33
35
  import { repoKeyFor } from "../repo-key.ts";
34
- import { type Card, CARD_STATUSES, cardRef, isCard, type Priority, priorityRank } from "./card.ts";
36
+ import {
37
+ type Card,
38
+ CARD_STATUSES,
39
+ CARD_VERSION,
40
+ cardRef,
41
+ type EndedCard,
42
+ isCard,
43
+ type Priority,
44
+ priorityRank,
45
+ } from "./card.ts";
35
46
  import { indexOf } from "./links.ts";
36
47
  import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
37
48
 
@@ -41,6 +52,17 @@ export const BOARD_DIR_NAME = "board";
41
52
  /** The subdirectory one board's cards live in. */
42
53
  export const CARDS_DIR_NAME = "cards";
43
54
 
55
+ /** The subdirectory a dropped card leaves its record in. */
56
+ export const ENDED_DIR_NAME = "ended";
57
+
58
+ /**
59
+ * The subdirectory a finished card is set aside in.
60
+ *
61
+ * A second pile, not a second state: the file is moved here unchanged, so its notes, plan,
62
+ * and landed commit stay readable, and the board's own reads stop opening it.
63
+ */
64
+ export const ARCHIVE_DIR_NAME = "archive";
65
+
44
66
  /** Card files are the user's work; only their owner reads them. */
45
67
  const FILE_MODE = 0o600;
46
68
 
@@ -64,27 +86,72 @@ export interface CreateInput {
64
86
 
65
87
  /** What a mutation did, or why it was refused. */
66
88
  export type MutateResult =
67
- | { ok: true; card: Card; changed: boolean; dropped: boolean }
89
+ | { ok: true; card: Card; changed: boolean; dropped: boolean; archived: boolean }
68
90
  | { ok: false; error: string; card?: Card };
69
91
 
92
+ /**
93
+ * A card file the reader refused, and why.
94
+ *
95
+ * The lanes skip these, so without this the board reads as if they were never there:
96
+ * "nobody has planned anything" instead of "this build cannot read your work".
97
+ */
98
+ export interface Skip {
99
+ /** The file name, e.g. `0042.json`. */
100
+ file: string;
101
+ /** The id the file name spells, when it spells one, so a caller can match a refusal. */
102
+ id?: string;
103
+ /** What is wrong, phrased for a reader: `bad JSON`, `version 2`. */
104
+ why: string;
105
+ }
106
+
70
107
  /** One repository's board, as the runtime, the router, and the tool use it. */
71
108
  export interface BoardStore {
72
109
  /** The board's own directory, for `where` and for a refusal that names it. */
73
110
  readonly dir: string;
74
111
  /** Create the directories and sweep a temp file a dead process left. */
75
112
  ensure(): Promise<void>;
76
- /** Every card, in lane order: todo, doing, blocked, done. */
113
+ /** Every card on the board, in lane order: todo, doing, blocked, done. */
77
114
  list(): Promise<Card[]>;
78
- /** One card by its unpadded id, or undefined when it is not there. */
115
+ /**
116
+ * The board in one read: the cards, and the files it refused.
117
+ *
118
+ * A page's snapshot needs both, and asking twice reads and parses every card twice to
119
+ * answer one frame; `list` and `skipped` are the two halves of this for a caller that
120
+ * wants only one.
121
+ */
122
+ scan(): Promise<{ cards: Card[]; skipped: Skip[] }>;
123
+ /**
124
+ * The cards set aside in the archive, in the same order, and the files there this build
125
+ * refused. Read only when a caller asks: no other read opens the pile.
126
+ */
127
+ archived(): Promise<{ cards: Card[]; skipped: Skip[] }>;
128
+ /** How many card files the archive holds, without opening one: the count a listing prints. */
129
+ archivedCount(): Promise<number>;
130
+ /** One card by its unpadded id, from whichever pile its file lies in. */
79
131
  read(id: string): Promise<Card | undefined>;
80
132
  /** Add a card, allocating the next id. */
81
133
  create(input: CreateInput, actor: Actor, now: number): Promise<Card>;
82
- /** Apply one operation, re-checking it if the card changed while it was read. */
134
+ /**
135
+ * Apply one operation, re-checking it if the card changed while it was read. A card is
136
+ * found in whichever pile it lies in, and written back to that one.
137
+ */
83
138
  mutate(id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult>;
84
- /** Bytes the store occupies on disk. */
139
+ /** Bytes the board's cards occupy on disk; the archive is not counted. */
85
140
  bytes(): Promise<number>;
141
+ /** Card files this build refused to read, and why. */
142
+ skipped(): Promise<Skip[]>;
86
143
  /** A short, user-facing name for the directory. */
87
144
  where(): string;
145
+ /**
146
+ * The repository root this board belongs to, and the directory itself outside one.
147
+ *
148
+ * A board is keyed by a hash, so this is the one part of its identity a reader can
149
+ * say out loud: the page names the repository from it, and offers the whole path on
150
+ * hover, which is what tells two clones of one repository apart.
151
+ */
152
+ root(): string;
153
+ /** The root's last segment — `my-pi-agent` — which is all a tab has room for. */
154
+ name(): string;
88
155
  }
89
156
 
90
157
  /** Where one repository's board lives. */
@@ -103,37 +170,75 @@ export async function openBoard(
103
170
  // environment carries the active root, so the two are reconciled before the
104
171
  // repository lookup — the same path `plan` and `session-bus` take.
105
172
  const repo = await repoKeyFor(pi, resolveEffectiveCwd(cwd));
106
- return createStore(boardDirFor(agentDir, repo.key));
173
+ return createStore(boardDirFor(agentDir, repo.key), { root: repo.root });
107
174
  }
108
175
 
109
176
  /** Build a store over one directory. Exported for tests and for a second backend. */
110
- export function createStore(dir: string): BoardStore {
177
+ export function createStore(dir: string, options: { root?: string } = {}): BoardStore {
178
+ // A store built over a directory by hand has no repository to name, so it names its
179
+ // own directory: the page then says something true instead of something blank.
180
+ const root = options.root ?? dir;
111
181
  const cardsDir = join(dir, CARDS_DIR_NAME);
182
+ const endedDir = join(dir, ENDED_DIR_NAME);
183
+ const archiveDir = join(dir, ARCHIVE_DIR_NAME);
112
184
 
113
185
  const ensure = async (): Promise<void> => {
114
186
  await mkdir(cardsDir, { recursive: true, mode: DIR_MODE });
115
187
  await sweepTemps(cardsDir);
188
+ // An archived card that was edited leaves a temp file in its own pile, and a process
189
+ // that died mid-write there is the same mess as one on the board.
190
+ await sweepTemps(archiveDir);
116
191
  };
117
192
 
118
- const list = async (): Promise<Card[]> => {
119
- const entries = await readdirSafe(cardsDir);
193
+ /** Every card file of one pile, read once: what it shows and what it refused. */
194
+ const scanPile = async (pileDir: string): Promise<{ cards: Card[]; skipped: Skip[] }> => {
195
+ const entries = await readdirSafe(pileDir);
120
196
  const cards: Card[] = [];
197
+ const skipped: Skip[] = [];
121
198
  for (const entry of entries) {
122
- const card = await readCardFile(cardsDir, entry);
123
- if (card) cards.push(card);
199
+ const read = await readCardFile(pileDir, entry);
200
+ if ("card" in read) cards.push(read.card);
201
+ // Anything that is not a card-named file is not the board's to explain.
202
+ else if (entry.endsWith(".json")) skipped.push(read.skip);
124
203
  }
125
- return sortForLane(cards);
204
+ // Sorted by file name so two reads of one board say the same thing in the same order.
205
+ return { cards: sortForLane(cards), skipped: skipped.sort((a, b) => a.file.localeCompare(b.file)) };
126
206
  };
127
207
 
128
- const read = async (id: string): Promise<Card | undefined> => {
129
- return readCardFile(cardsDir, fileNameFor(id));
208
+ const scan = (): Promise<{ cards: Card[]; skipped: Skip[] }> => scanPile(cardsDir);
209
+
210
+ const list = async (): Promise<Card[]> => (await scan()).cards;
211
+
212
+ const archived = async (): Promise<{ cards: Card[]; skipped: Skip[] }> => scanPile(archiveDir);
213
+
214
+ // A directory listing and a name pattern, with no file opened: a plain `list` prints this
215
+ // count so the archive it left out cannot read as history that was deleted.
216
+ const archivedCount = async (): Promise<number> =>
217
+ (await readdirSafe(archiveDir)).filter((entry) => CARD_FILE.test(entry)).length;
218
+
219
+ const skipped = async (): Promise<Skip[]> => (await scan()).skipped;
220
+
221
+ /**
222
+ * One card and the pile its file is in, the board first.
223
+ *
224
+ * A card is where it lies: the archive is not a state a record carries, so every read by
225
+ * id has to look in both places, and every write goes back to the one it was read from.
226
+ */
227
+ const locate = async (id: string): Promise<{ card: Card; pile: string } | undefined> => {
228
+ for (const pile of [cardsDir, archiveDir]) {
229
+ const result = await readCardFile(pile, fileNameFor(id));
230
+ if ("card" in result) return { card: result.card, pile };
231
+ }
232
+ return undefined;
130
233
  };
131
234
 
235
+ const read = async (id: string): Promise<Card | undefined> => (await locate(id))?.card;
236
+
132
237
  const create = async (input: CreateInput, actor: Actor, now: number): Promise<Card> => {
133
238
  await ensure();
134
239
  let lastError: unknown;
135
240
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
136
- const id = await nextCardId(cardsDir);
241
+ const id = await nextCardId(cardsDir, endedDir, archiveDir);
137
242
  const card = createCard(id, input, actor, now);
138
243
  const temp = tempPath(cardsDir);
139
244
  await writeFile(temp, serialize(card), { mode: FILE_MODE });
@@ -155,22 +260,44 @@ export function createStore(dir: string): BoardStore {
155
260
  const mutate = async (id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult> => {
156
261
  let changedUnderneath = false;
157
262
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
158
- const card = await read(id);
159
- if (!card) {
263
+ const found = await locate(id);
264
+ if (!found) {
265
+ if (changedUnderneath) return { ok: false, error: `${cardRef(id)} was dropped.` };
266
+ // A file that is there and refused is not a card that does not exist: the
267
+ // reader is told which file to look at instead of being sent to add one.
268
+ const skip = (await scan()).skipped.find((entry) => entry.id === id);
160
269
  return {
161
270
  ok: false,
162
- error: changedUnderneath ? `${cardRef(id)} was dropped.` : `${cardRef(id)} does not exist.`,
271
+ error:
272
+ skip === undefined
273
+ ? `${cardRef(id)} does not exist.`
274
+ : `${cardRef(id)} is there and this build cannot read it (${skip.file}: ${skip.why}).`,
163
275
  };
164
276
  }
277
+ const { card, pile } = found;
165
278
  // The preconditions are checked against the card as it is *now*, and against the
166
279
  // board as it is now, so an operation re-applied to a card that changed is refused
167
280
  // rather than forced.
168
281
  const result = applyOp(card, op, actor, now, indexOf(await list()));
169
282
  if (!result.ok) return { ok: false, error: result.error, card };
170
- if (!result.changed) return { ok: true, card: result.card, changed: false, dropped: false };
283
+ if (!result.changed) {
284
+ return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
285
+ }
171
286
  if (op.kind === "drop") {
172
- await rm(join(cardsDir, fileNameFor(id)), { force: true });
173
- return { ok: true, card: result.card, changed: true, dropped: true };
287
+ // The record lands first: a crash between the two leaves a card that still
288
+ // exists with a note explaining it, never a card that is gone unexplained.
289
+ await writeEnded(endedDir, result.card, actor, now);
290
+ await rm(join(pile, fileNameFor(id)), { force: true });
291
+ return { ok: true, card: result.card, changed: true, dropped: true, archived: false };
292
+ }
293
+ if (op.kind === "archive") {
294
+ // A card the board does not hold is already aside, and two sessions asking at
295
+ // once are one move rather than a second one.
296
+ if (pile === archiveDir) {
297
+ return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
298
+ }
299
+ await moveCard(cardsDir, archiveDir, id);
300
+ return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
174
301
  }
175
302
  // Last look before the rename: a writer that landed in between is the
176
303
  // re-read the next pass applies against.
@@ -179,8 +306,8 @@ export function createStore(dir: string): BoardStore {
179
306
  changedUnderneath = true;
180
307
  continue;
181
308
  }
182
- await writeCardFile(cardsDir, id, result.card);
183
- return { ok: true, card: result.card, changed: true, dropped: false };
309
+ await writeCardFile(pile, id, result.card);
310
+ return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
184
311
  }
185
312
  return {
186
313
  ok: false,
@@ -200,7 +327,22 @@ export function createStore(dir: string): BoardStore {
200
327
  return total;
201
328
  };
202
329
 
203
- return { dir, ensure, list, read, create, mutate, bytes, where: () => shortenPath(dir) };
330
+ return {
331
+ dir,
332
+ ensure,
333
+ list,
334
+ scan,
335
+ archived,
336
+ archivedCount,
337
+ read,
338
+ create,
339
+ mutate,
340
+ bytes,
341
+ skipped,
342
+ where: () => shortenPath(dir),
343
+ root: () => root,
344
+ name: () => basename(root),
345
+ };
204
346
  }
205
347
 
206
348
  /** `<id>.json`, zero-padded to four digits so a directory lists in card order. */
@@ -211,58 +353,129 @@ function fileNameFor(id: string): string {
211
353
  /** The card files the reader accepts. */
212
354
  const CARD_FILE = /^(\d{4,})\.json$/;
213
355
 
214
- /** One card file, or undefined when it is not a card this build trusts. */
215
- async function readCardFile(cardsDir: string, entry: string): Promise<Card | undefined> {
356
+ /** One card file: the card, or the reason this build refused it. */
357
+ async function readCardFile(pileDir: string, entry: string): Promise<{ card: Card } | { skip: Skip }> {
216
358
  const match = CARD_FILE.exec(entry);
217
- if (!match) return undefined;
218
- const text = await readFile(join(cardsDir, entry), "utf-8").catch(() => undefined);
219
- if (text === undefined) return undefined;
359
+ if (!match) return { skip: { file: entry, why: "not a card id" } };
360
+ const id = String(Number(match[1]));
361
+ const text = await readFile(join(pileDir, entry), "utf-8").catch(() => undefined);
362
+ if (text === undefined) return { skip: { file: entry, id, why: "cannot be read" } };
220
363
  let parsed: unknown;
221
364
  try {
222
365
  parsed = JSON.parse(text);
223
366
  } catch {
224
- return undefined;
367
+ return { skip: { file: entry, id, why: "bad JSON" } };
368
+ }
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.
371
+ const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
372
+ if (typeof version === "number" && version !== CARD_VERSION) {
373
+ return { skip: { file: entry, id, why: `version ${version}` } };
225
374
  }
226
- if (!isCard(parsed)) return undefined;
375
+ if (!isCard(parsed)) return { skip: { file: entry, id, why: "not a card record" } };
227
376
  // The file name is the id's canonical spelling: a padded id inside the record
228
377
  // (or a record moved under another name) is a disagreement, not a card.
229
- if (parsed.id !== String(Number(match[1]))) return undefined;
230
- return parsed;
378
+ if (parsed.id !== String(Number(match[1]))) {
379
+ return { skip: { file: entry, id, why: `names card ${parsed.id}` } };
380
+ }
381
+ return { card: parsed };
231
382
  }
232
383
 
233
- /** `1`, `2`, `3` … as the highest existing id plus one. */
234
- async function nextCardId(cardsDir: string): Promise<string> {
235
- const entries = await readdirSafe(cardsDir);
384
+ /** The highest id a directory holds, or 0 when it holds none. */
385
+ async function highestId(dir: string): Promise<number> {
386
+ const entries = await readdirSafe(dir);
236
387
  let highest = 0;
237
388
  for (const entry of entries) {
238
389
  const match = CARD_FILE.exec(entry);
239
390
  if (match) highest = Math.max(highest, Number(match[1]));
240
391
  }
241
- return String(highest + 1);
392
+ return highest;
393
+ }
394
+
395
+ /**
396
+ * `1`, `2`, `3` … as the highest id the board has ever used plus one.
397
+ *
398
+ * Ended and archived cards count: a dropped card's record keeps its id spent, and an
399
+ * archived card's file is still named by it, so a note or a handoff that names `#29`
400
+ * cannot come to mean a different feature tomorrow.
401
+ */
402
+ async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string): Promise<string> {
403
+ return String(Math.max(await highestId(cardsDir), await highestId(endedDir), await highestId(archiveDir)) + 1);
404
+ }
405
+
406
+ /**
407
+ * Write the record of a card that has ended.
408
+ *
409
+ * Exclusive like a create, so the id ends once: a retry that finds the record already
410
+ * there is the same end, not a second one.
411
+ */
412
+ async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
413
+ await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
414
+ const ended: EndedCard = {
415
+ v: 1,
416
+ id: card.id,
417
+ title: card.title,
418
+ status: card.status,
419
+ by: actor.label,
420
+ ...(card.owner === undefined ? {} : { owner: card.owner }),
421
+ at: now,
422
+ why: "dropped",
423
+ };
424
+ const temp = tempPath(endedDir);
425
+ await writeFile(temp, serialize(ended), { mode: FILE_MODE });
426
+ try {
427
+ await link(temp, join(endedDir, fileNameFor(card.id)));
428
+ } catch (error) {
429
+ if (!hasErrorCode(error, "EEXIST")) throw error;
430
+ } finally {
431
+ await rm(temp, { force: true });
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Move one card's file into the archive.
437
+ *
438
+ * A rename, not a write: the record does not change, and the pile it lies in is the whole
439
+ * of what an archive means.
440
+ */
441
+ async function moveCard(from: string, to: string, id: string): Promise<void> {
442
+ await mkdir(to, { recursive: true, mode: DIR_MODE });
443
+ await renameInto(join(from, fileNameFor(id)), join(to, fileNameFor(id)));
444
+ }
445
+
446
+ /**
447
+ * Rename one file over or onto another, with the fallback this platform needs.
448
+ *
449
+ * Windows refuses a rename onto an open file, so removing the target first is the only
450
+ * way through there; every caller has already read what it is replacing, which is what
451
+ * bounds what the removal can lose.
452
+ */
453
+ async function renameInto(source: string, target: string): Promise<void> {
454
+ try {
455
+ await rename(source, target);
456
+ } catch (error) {
457
+ if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) throw error;
458
+ await rm(target, { force: true });
459
+ await rename(source, target);
460
+ }
242
461
  }
243
462
 
244
463
  /** Write one card: a temp file in the same directory, then a rename over the old one. */
245
- async function writeCardFile(cardsDir: string, id: string, card: Card): Promise<void> {
246
- const temp = tempPath(cardsDir);
464
+ async function writeCardFile(dir: string, id: string, card: Card): Promise<void> {
465
+ const temp = tempPath(dir);
247
466
  await writeFile(temp, serialize(card), { mode: FILE_MODE });
248
- const target = join(cardsDir, fileNameFor(id));
249
467
  try {
250
- await rename(temp, target);
468
+ await renameInto(temp, join(dir, fileNameFor(id)));
251
469
  } catch (error) {
252
- // Windows refuses a rename onto an open file; removing first is the fallback
253
- // the platform needs, and the read above still bounds what can be lost.
254
- if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) {
255
- await rm(temp, { force: true });
256
- throw error;
257
- }
258
- await rm(target, { force: true });
259
- await rename(temp, target);
470
+ await rm(temp, { force: true });
471
+ throw error;
260
472
  }
261
473
  }
262
474
 
263
475
  /** One card's bytes, ending in a newline so the file reads well in an editor. */
264
- function serialize(card: Card): string {
265
- return `${JSON.stringify(card, null, "\t")}\n`;
476
+ /** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
477
+ function serialize(record: Card | EndedCard): string {
478
+ return `${JSON.stringify(record, null, "\t")}\n`;
266
479
  }
267
480
 
268
481
  /** A temp path in the cards directory: never matched by the reader's own pattern. */
package/lib/board/view.ts CHANGED
@@ -8,21 +8,31 @@
8
8
  *
9
9
  * `where` is the store's own path, shortened, so a reader can tell which directory
10
10
  * a shared board is filed under — and so nothing here needs a "remote" variant that
11
- * no backend produces.
11
+ * no backend produces. `repo` and `root` are the same board said aloud: the repository
12
+ * root's own name, which a tab can carry, and its whole path for the hover title.
12
13
  *
13
14
  * Pure: the caller supplies the cards, the clock, and the sizes it read.
14
15
  */
15
16
 
16
- import { type RosterSession, rosterStateOf } from "../session-roster.ts";
17
- import { type Card, CARD_STATUSES, type CardStatus, signatureOf } from "./card.ts";
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";
27
+ import { blockersOf, indexOf, lanesText, openDependentsOf } from "./links.ts";
18
28
  import type { Actor } from "./rules.ts";
19
29
  import { buildRound, type RoundSummary } from "./round.ts";
20
30
 
21
31
  /** One session on the repository's bus, as the Team tab lists it. */
22
32
  export interface ViewTeammate {
23
33
  label: string;
24
- /** `live` while its heartbeat holds, `quiet` once the record has aged out. */
25
- state: "live" | "quiet";
34
+ /** `live` while its heartbeat holds, `quiet` once the record has aged out, `gone` once its process is not running. */
35
+ state: RosterState;
26
36
  /** When it last proved it was there. */
27
37
  seen: number;
28
38
  /** Messages waiting in its inbox. */
@@ -33,10 +43,47 @@ export interface ViewTeammate {
33
43
  you: boolean;
34
44
  }
35
45
 
46
+ /**
47
+ * One card as the page meets it: the stored card, plus the two answers it would
48
+ * otherwise compute for itself.
49
+ *
50
+ * Both are derived from the board, not stored on the card, so a client that did the
51
+ * same work would be a second copy of a rule — and the copies drift, which is how the
52
+ * page came to disagree with the tool about what a card waits for.
53
+ */
54
+ export interface ViewCard extends Card {
55
+ /** The priority it acts on: a card that never set one is ordinary. */
56
+ priority: Priority;
57
+ /**
58
+ * The unfinished cards it waits for, as `#2 (doing)`; absent when nothing blocks it.
59
+ *
60
+ * `undefined` is the whole fact — a waiting card is one with a `waits` — because a
61
+ * boolean beside it would be one more thing that can disagree with the string.
62
+ */
63
+ waits?: string;
64
+ /**
65
+ * The unfinished cards waiting on it, as `#9 (todo)`; absent when none do.
66
+ *
67
+ * The other side of `waits`: what leaving this card undone holds up. Derived here
68
+ * rather than in the page's script for the same reason, and filtered to the unfinished
69
+ * for the same reason a finished blocker does not count.
70
+ */
71
+ dependents?: string;
72
+ /**
73
+ * When the card entered the lane it is in now, or absent when its history does not say.
74
+ *
75
+ * A timestamp rather than a phrase: the page already formats ages, and a wording
76
+ * decided here would be a second copy of that rule. Absent is the honest answer for a
77
+ * card written before the log existed, so the page draws no age rather than guessing
78
+ * one from `updatedAt`, which moves on every write.
79
+ */
80
+ since?: number;
81
+ }
82
+
36
83
  /** One lane and the cards in it, in the order the page draws them. */
37
84
  export interface ViewLane {
38
85
  status: CardStatus;
39
- cards: Card[];
86
+ cards: ViewCard[];
40
87
  }
41
88
 
42
89
  /** The per-lane tallies the toolbar shows. */
@@ -50,13 +97,16 @@ export interface ViewCounts {
50
97
 
51
98
  /** Everything the page is allowed to know. */
52
99
  export interface BoardView {
53
- /** The session a page write is made as — the page's seat, never the browser. */
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
+ */
54
106
  me: { id?: string; label: string };
55
- /** The session the page is served from, when the page knows it: who is reading. */
56
- session?: { id?: string; label: string };
57
107
  lanes: ViewLane[];
58
108
  counts: ViewCounts;
59
- round: RoundSummary;
109
+ round: RoundSummary<ViewCard>;
60
110
  /**
61
111
  * The sessions on this repository's bus, or absent when there is no bus here.
62
112
  *
@@ -66,54 +116,94 @@ export interface BoardView {
66
116
  teammates?: ViewTeammate[];
67
117
  /** Bytes the store occupies, so a board that is never pruned says so. */
68
118
  bytes: number;
119
+ /**
120
+ * Card files the reader refused, so the footer can say the board is not empty.
121
+ *
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".
124
+ */
125
+ skipped: number;
69
126
  /** Changes when a card's revision, a teammate's state, or its mail does. */
70
127
  sig: string;
128
+ /** The root's last segment, e.g. `my-pi-agent`: what the tab and the footer lead with. */
129
+ repo: string;
130
+ /** The repository root, absolute, so the footer can put the whole path on hover. */
131
+ root: string;
71
132
  /** The store's directory, shortened. */
72
133
  where: string;
73
134
  }
74
135
 
75
136
  /** What the server read before building one snapshot. */
76
137
  export interface ViewInput {
138
+ /** The page's own actor, which is both who it writes as and who is looking. */
77
139
  me: Actor;
78
- /**
79
- * The session the page is served from, when it has one.
80
- *
81
- * The page writes as its seat — a fixed name — so the reader's own row on the Team
82
- * tab is marked from the session, not from `me`: the seat is what a card records,
83
- * and the session is who is looking.
84
- */
85
- session?: Actor;
86
140
  cards: readonly Card[];
87
141
  now: number;
88
142
  bytes: number;
143
+ /** Card files the reader refused; absent reads as none, which is the ordinary board. */
144
+ skipped?: number;
89
145
  where: string;
146
+ /** The repository's name, from `BoardStore.name()`. */
147
+ repo: string;
148
+ /** The repository root, from `BoardStore.root()`. */
149
+ root: string;
90
150
  /** The bus's records, when the page could read them at all. */
91
151
  roster?: readonly RosterSession[];
92
152
  }
93
153
 
94
154
  /** Build the page's snapshot. */
95
155
  export function buildView(input: ViewInput): BoardView {
156
+ // One index for the whole snapshot: a card's lane and its blockers are read from the
157
+ // same board, so a dependency and its dependent cannot be told two different stories.
158
+ const index = indexOf(input.cards);
159
+ // One mapped list feeds both halves of the snapshot: the rollup's groups carry the same
160
+ // card the lane does, so a client reading `round.unassigned` is not handed a card
161
+ // missing the facts every other card has.
162
+ const cards = input.cards.map((card) => viewCardOf(card, index));
96
163
  const lanes = CARD_STATUSES.map((status) => ({
97
164
  status,
98
- cards: input.cards.filter((card) => card.status === status),
165
+ cards: cards.filter((card) => card.status === status),
99
166
  }));
100
- const reader = input.session ?? input.me;
167
+ const reader = input.me;
101
168
  const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
102
169
  return {
103
170
  me: input.me.id === undefined ? { label: input.me.label } : { id: input.me.id, label: input.me.label },
104
- ...(input.session === undefined ? {} : { session: input.session }),
105
171
  lanes,
106
172
  counts: countOf(lanes),
107
- round: buildRound(input.cards, input.now),
173
+ round: buildRound(cards, input.now),
108
174
  ...(teammates === undefined ? {} : { teammates }),
109
175
  bytes: input.bytes,
176
+ skipped: input.skipped ?? 0,
110
177
  // The roster enters the signature at its coarse facts only: a heartbeat every ten
111
- // seconds is not news, while a join, a quiet and a letter are.
112
- sig: signatureOf(input.cards) + rosterSignature(teammates),
178
+ // 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}`,
182
+ repo: input.repo,
183
+ root: input.root,
113
184
  where: input.where,
114
185
  };
115
186
  }
116
187
 
188
+ /**
189
+ * One card for the page: what it carries, plus what it acts on.
190
+ *
191
+ * The card is copied rather than annotated — the caller's cards are the store's, and a
192
+ * derived field written onto one would outlive the read that made it.
193
+ */
194
+ function viewCardOf(card: Card, index: ReadonlyMap<string, Card>): ViewCard {
195
+ const waits = lanesText(blockersOf(card, index));
196
+ const dependents = lanesText(openDependentsOf(card.id, index));
197
+ const since = laneSince(card);
198
+ return {
199
+ ...card,
200
+ priority: priorityOf(card),
201
+ ...(waits === "" ? {} : { waits }),
202
+ ...(dependents === "" ? {} : { dependents }),
203
+ ...(since === undefined ? {} : { since }),
204
+ };
205
+ }
206
+
117
207
  /** The roster as the page shows it, ordered so a repaint never reorders it. */
118
208
  function teammatesOf(roster: readonly RosterSession[], meLabel: string, now: number): ViewTeammate[] {
119
209
  return roster