@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -31,7 +31,9 @@ import {
31
31
  cardRef,
32
32
  DEFAULT_PRIORITY,
33
33
  dependsOf,
34
+ MAX_LOG,
34
35
  MAX_NOTES,
36
+ normalizeBlockedBy,
35
37
  normalizeNote,
36
38
  normalizePathHints,
37
39
  normalizePlanPath,
@@ -57,7 +59,13 @@ export type CardOp =
57
59
  */
58
60
  | { kind: "assign"; to: Actor; note?: unknown }
59
61
  | { kind: "release" }
60
- | { kind: "move"; status: Exclude<CardStatus, "todo"> }
62
+ /**
63
+ * Change lane. `blockedBy` says what a block waits on, and `landed` says which commit
64
+ * closed a card: both are read only by the lane they belong to, and both are optional,
65
+ * because a card may be blocked with nothing recorded and a `done` card may predate the
66
+ * field or live outside a repository.
67
+ */
68
+ | { kind: "move"; status: Exclude<CardStatus, "todo">; blockedBy?: unknown; landed?: { commit: string } }
61
69
  // `text` and `path` are whatever the model sent: `card.ts` validates them, and the
62
70
  // refusal it produces is the one the caller shows.
63
71
  | { kind: "note"; text: unknown }
@@ -66,6 +74,16 @@ export type CardOp =
66
74
  // sends the three it can show, and omitting a link must not clear it.
67
75
  | { kind: "edit"; title: unknown; paths: unknown; priority: unknown; dependsOn: unknown }
68
76
  | { kind: "reclaim" }
77
+ /**
78
+ * Record that the card was offered to the team: `team offer` broadcasts it on the bus
79
+ * and then writes this, because an event nobody answers otherwise leaves no trace.
80
+ */
81
+ | { kind: "offer" }
82
+ /**
83
+ * Set a finished card aside: the store moves its file out of the board, and the record
84
+ * itself is unchanged, because the pile its file lies in is the whole of the state.
85
+ */
86
+ | { kind: "archive" }
69
87
  | { kind: "drop" };
70
88
 
71
89
  /** What an operation did: the new card, or the reason it was refused. */
@@ -89,6 +107,10 @@ export function createCard(
89
107
  priority: input.priority ?? DEFAULT_PRIORITY,
90
108
  notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
91
109
  notesDropped: 0,
110
+ // A card is born in `todo`, and that is a transition like any other: a reader asking
111
+ // "how long has this been waiting" gets an answer rather than an absence.
112
+ log: [{ status: "todo", at: now, by: actor.label }],
113
+ logDropped: 0,
92
114
  rev: 1,
93
115
  createdAt: now,
94
116
  updatedAt: now,
@@ -125,7 +147,7 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
125
147
  case "release":
126
148
  return release(card, actor, now);
127
149
  case "move":
128
- return move(card, op.status, actor, now, board);
150
+ return move(card, op, actor, now, board);
129
151
  case "note":
130
152
  return note(card, op.text, actor, now);
131
153
  case "plan":
@@ -134,6 +156,10 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
134
156
  return edit(card, op, actor, now, board);
135
157
  case "reclaim":
136
158
  return reclaim(card, actor, now);
159
+ case "offer":
160
+ return offer(card, actor, now, board);
161
+ case "archive":
162
+ return archive(card);
137
163
  case "drop":
138
164
  return drop(card, actor, board);
139
165
  }
@@ -145,7 +171,11 @@ function claim(card: Card, actor: Actor, now: number, board: BoardIndex): OpResu
145
171
  if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
146
172
  const waiting = waitingOn(card, board);
147
173
  if (waiting !== undefined) return { ok: false, error: waiting };
148
- return { ok: true, card: write(card, actor, now, { status: "doing", owner: ownerOf(actor) }), changed: true };
174
+ return {
175
+ ok: true,
176
+ card: write(card, actor, now, { status: "doing", owner: ownerOf(actor), offeredAt: undefined }),
177
+ changed: true,
178
+ };
149
179
  }
150
180
 
151
181
  function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: number, board: BoardIndex): OpResult {
@@ -154,7 +184,7 @@ function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: numb
154
184
  if (holds(card, to) && card.status === "doing") return { ok: true, card, changed: false };
155
185
  const waiting = waitingOn(card, board);
156
186
  if (waiting !== undefined) return { ok: false, error: waiting };
157
- const fields: Partial<Card> = { status: "doing", owner: ownerOf(to) };
187
+ const fields: Partial<Card> = { status: "doing", owner: ownerOf(to), offeredAt: undefined };
158
188
  if (rawNote !== undefined) {
159
189
  const parsed = normalizeNote(rawNote);
160
190
  if ("error" in parsed) return { ok: false, error: parsed.error };
@@ -172,23 +202,66 @@ function release(card: Card, actor: Actor, now: number): OpResult {
172
202
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be released.` };
173
203
  if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} is not claimed.` };
174
204
  if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Release is the owner's decision.` };
175
- return { ok: true, card: write(card, actor, now, { status: "todo", owner: undefined }), changed: true };
205
+ return {
206
+ ok: true,
207
+ card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
208
+ changed: true,
209
+ };
176
210
  }
177
211
 
178
- function move(card: Card, status: Exclude<CardStatus, "todo">, actor: Actor, now: number, board: BoardIndex): OpResult {
212
+ function move(
213
+ card: Card,
214
+ op: Extract<CardOp, { kind: "move" }>,
215
+ actor: Actor,
216
+ now: number,
217
+ board: BoardIndex,
218
+ ): OpResult {
219
+ const status = op.status;
179
220
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be moved.` };
180
221
  if (card.owner === undefined) {
181
222
  return { ok: false, error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${status}.` };
182
223
  }
183
224
  if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner can move it.` };
184
- if (card.status === status) return { ok: true, card, changed: false };
225
+ // A block's reason is checked before the no-op: a caller that repeats a move with a new
226
+ // sentence meant to change it, and a refusal is clearer than a silent nothing.
227
+ let blockedBy: string | undefined;
228
+ if (status === "blocked" && op.blockedBy !== undefined) {
229
+ const parsed = normalizeBlockedBy(op.blockedBy);
230
+ if ("error" in parsed) return { ok: false, error: parsed.error };
231
+ blockedBy = parsed.blockedBy;
232
+ }
233
+ if (card.status === status && blockedBy === undefined) return { ok: true, card, changed: false };
185
234
  // Parking is what a link is for, so only the two lanes that put a card back in flight
186
235
  // are gated: a card that waits may be blocked, it may not be taken or finished.
187
236
  if (status !== "blocked") {
188
237
  const waiting = waitingOn(card, board);
189
238
  if (waiting !== undefined) return { ok: false, error: waiting };
190
239
  }
191
- return { ok: true, card: write(card, actor, now, { status }), changed: true };
240
+ return { ok: true, card: write(card, actor, now, laneFields(status, blockedBy, op, now)), changed: true };
241
+ }
242
+
243
+ /**
244
+ * The fields one lane change writes.
245
+ *
246
+ * `blockedBy` belongs to `blocked` alone: a card that leaves for `doing` or `done` has
247
+ * had its decision or stopped needing one, and a stale sentence would be read as a
248
+ * current one. `landed` belongs to `done` alone, and only when the caller could name a
249
+ * commit — a board outside git still finishes cards, it just cannot say with what.
250
+ */
251
+ function laneFields(
252
+ status: Exclude<CardStatus, "todo">,
253
+ blockedBy: string | undefined,
254
+ op: Extract<CardOp, { kind: "move" }>,
255
+ now: number,
256
+ ): Partial<Card> {
257
+ const fields: Partial<Card> = { status };
258
+ if (status === "blocked") {
259
+ if (blockedBy !== undefined) fields.blockedBy = blockedBy;
260
+ } else {
261
+ fields.blockedBy = undefined;
262
+ }
263
+ if (status === "done" && op.landed !== undefined) fields.landed = { commit: op.landed.commit, at: now };
264
+ return fields;
192
265
  }
193
266
 
194
267
  function note(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
@@ -207,6 +280,16 @@ function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<C
207
280
  };
208
281
  }
209
282
 
283
+ /** A card's log after one more transition, with the oldest dropped and counted. */
284
+ function appendTransition(card: Card, status: CardStatus, actor: Actor, now: number): Pick<Card, "log" | "logDropped"> {
285
+ const log = [...(card.log ?? []), { status, at: now, by: actor.label }];
286
+ const overflow = Math.max(0, log.length - MAX_LOG);
287
+ return {
288
+ log: overflow === 0 ? log : log.slice(overflow),
289
+ logDropped: (card.logDropped ?? 0) + overflow,
290
+ };
291
+ }
292
+
210
293
  function plan(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
211
294
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; its plan cannot change.` };
212
295
  if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner records a plan.` };
@@ -262,10 +345,45 @@ function edit(
262
345
  return { ok: true, card: write(card, actor, now, fields), changed: true };
263
346
  }
264
347
 
348
+ /**
349
+ * Record that the card was offered to the team, and nobody has taken it.
350
+ *
351
+ * The same preconditions a claim would meet, because the offer asks a peer to claim: a
352
+ * held card, a finished one, or one waiting on unfinished work is refused before anything
353
+ * is broadcast. Re-offering refreshes the age rather than refusing as a no-op — a lead
354
+ * who offers again is asking again, and the age is the record of when they last did.
355
+ */
356
+ function offer(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
357
+ if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be offered.` };
358
+ if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
359
+ const waiting = waitingOn(card, board);
360
+ if (waiting !== undefined) return { ok: false, error: waiting };
361
+ return { ok: true, card: write(card, actor, now, { offeredAt: now }), changed: true };
362
+ }
363
+
265
364
  function reclaim(card: Card, actor: Actor, now: number): OpResult {
266
365
  if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
267
366
  if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} has no owner to reclaim.` };
268
- return { ok: true, card: write(card, actor, now, { status: "todo", owner: undefined }), changed: true };
367
+ return {
368
+ ok: true,
369
+ card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
370
+ changed: true,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Set a finished card aside, out of the board and into the archive beside it.
376
+ *
377
+ * Only a finished card leaves this way: `done` is terminal, and a card still in flight
378
+ * has an owner, a plan, and a decision in it. No field moves and no transition is
379
+ * appended — the record is unchanged, and what changed is which pile its file lies in,
380
+ * which is the store's write. `changed` is still true, because the board lost a card.
381
+ */
382
+ function archive(card: Card): OpResult {
383
+ if (card.status !== "done") {
384
+ return { ok: false, error: `${cardRef(card.id)} is in ${card.status}; only a finished card can be archived.` };
385
+ }
386
+ return { ok: true, card, changed: true };
269
387
  }
270
388
 
271
389
  function drop(card: Card, actor: Actor, board: BoardIndex): OpResult {
@@ -283,13 +401,28 @@ function sameList(left: readonly string[], right: readonly string[]): boolean {
283
401
  return left.length === right.length && left.every((entry, index) => entry === right[index]);
284
402
  }
285
403
 
286
- /** A card with `fields` applied and its write marks moved. */
404
+ /**
405
+ * A card with `fields` applied and its write marks moved.
406
+ *
407
+ * This is the one place a lane change is observed, because it is the one place every
408
+ * transition ends: a note, a plan, or an edit writes fields and leaves the lane alone,
409
+ * while a claim, an assign, a release, a reclaim, and a move all name the new one. So
410
+ * the log is appended from here rather than from each operation, and an operation added
411
+ * later cannot forget it.
412
+ */
287
413
  function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
288
- const next: Card = { ...card, ...fields, rev: card.rev + 1, updatedAt: now, by: actor.label };
414
+ const status = fields.status;
415
+ const transition =
416
+ status !== undefined && status !== card.status ? appendTransition(card, status, actor, now) : undefined;
417
+ const next: Card = { ...card, ...fields, ...transition, rev: card.rev + 1, updatedAt: now, by: actor.label };
289
418
  // `owner: undefined` must clear the field rather than store a key holding it, so a
290
419
  // released card reads back exactly like one that was never claimed. Only an
291
420
  // explicit `owner` in `fields` may delete it: every other write keeps the holder.
292
421
  if ("owner" in fields && fields.owner === undefined) delete next.owner;
422
+ // The same for the block: a card that left `blocked` has no reason, and a key holding
423
+ // undefined would be one more shape for a reader to know about.
424
+ if ("blockedBy" in fields && fields.blockedBy === undefined) delete next.blockedBy;
425
+ if ("offeredAt" in fields && fields.offeredAt === undefined) delete next.offeredAt;
293
426
  return next;
294
427
  }
295
428
 
@@ -20,8 +20,10 @@
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";
@@ -31,7 +33,16 @@ 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,25 +86,52 @@ 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 cards set aside in the archive, in the same order, and the files there this build
117
+ * refused. Read only when a caller asks: no other read opens the pile.
118
+ */
119
+ archived(): Promise<{ cards: Card[]; skipped: Skip[] }>;
120
+ /** How many card files the archive holds, without opening one: the count a listing prints. */
121
+ archivedCount(): Promise<number>;
122
+ /** One card by its unpadded id, from whichever pile its file lies in. */
79
123
  read(id: string): Promise<Card | undefined>;
80
124
  /** Add a card, allocating the next id. */
81
125
  create(input: CreateInput, actor: Actor, now: number): Promise<Card>;
82
- /** Apply one operation, re-checking it if the card changed while it was read. */
126
+ /**
127
+ * Apply one operation, re-checking it if the card changed while it was read. A card is
128
+ * found in whichever pile it lies in, and written back to that one.
129
+ */
83
130
  mutate(id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult>;
84
- /** Bytes the store occupies on disk. */
131
+ /** Bytes the board's cards occupy on disk; the archive is not counted. */
85
132
  bytes(): Promise<number>;
133
+ /** Card files this build refused to read, and why. */
134
+ skipped(): Promise<Skip[]>;
86
135
  /** A short, user-facing name for the directory. */
87
136
  where(): string;
88
137
  }
@@ -109,31 +158,64 @@ export async function openBoard(
109
158
  /** Build a store over one directory. Exported for tests and for a second backend. */
110
159
  export function createStore(dir: string): BoardStore {
111
160
  const cardsDir = join(dir, CARDS_DIR_NAME);
161
+ const endedDir = join(dir, ENDED_DIR_NAME);
162
+ const archiveDir = join(dir, ARCHIVE_DIR_NAME);
112
163
 
113
164
  const ensure = async (): Promise<void> => {
114
165
  await mkdir(cardsDir, { recursive: true, mode: DIR_MODE });
115
166
  await sweepTemps(cardsDir);
167
+ // An archived card that was edited leaves a temp file in its own pile, and a process
168
+ // that died mid-write there is the same mess as one on the board.
169
+ await sweepTemps(archiveDir);
116
170
  };
117
171
 
118
- const list = async (): Promise<Card[]> => {
119
- const entries = await readdirSafe(cardsDir);
172
+ /** Every card file of one pile, read once: what it shows and what it refused. */
173
+ const scan = async (pileDir: string): Promise<{ cards: Card[]; skipped: Skip[] }> => {
174
+ const entries = await readdirSafe(pileDir);
120
175
  const cards: Card[] = [];
176
+ const skipped: Skip[] = [];
121
177
  for (const entry of entries) {
122
- const card = await readCardFile(cardsDir, entry);
123
- if (card) cards.push(card);
178
+ const read = await readCardFile(pileDir, entry);
179
+ if ("card" in read) cards.push(read.card);
180
+ // Anything that is not a card-named file is not the board's to explain.
181
+ else if (entry.endsWith(".json")) skipped.push(read.skip);
124
182
  }
125
- return sortForLane(cards);
183
+ // Sorted by file name so two reads of one board say the same thing in the same order.
184
+ return { cards: sortForLane(cards), skipped: skipped.sort((a, b) => a.file.localeCompare(b.file)) };
126
185
  };
127
186
 
128
- const read = async (id: string): Promise<Card | undefined> => {
129
- return readCardFile(cardsDir, fileNameFor(id));
187
+ const list = async (): Promise<Card[]> => (await scan(cardsDir)).cards;
188
+
189
+ const archived = async (): Promise<{ cards: Card[]; skipped: Skip[] }> => scan(archiveDir);
190
+
191
+ // A directory listing and a name pattern, with no file opened: a plain `list` prints this
192
+ // count so the archive it left out cannot read as history that was deleted.
193
+ const archivedCount = async (): Promise<number> =>
194
+ (await readdirSafe(archiveDir)).filter((entry) => CARD_FILE.test(entry)).length;
195
+
196
+ const skipped = async (): Promise<Skip[]> => (await scan(cardsDir)).skipped;
197
+
198
+ /**
199
+ * One card and the pile its file is in, the board first.
200
+ *
201
+ * A card is where it lies: the archive is not a state a record carries, so every read by
202
+ * id has to look in both places, and every write goes back to the one it was read from.
203
+ */
204
+ const locate = async (id: string): Promise<{ card: Card; pile: string } | undefined> => {
205
+ for (const pile of [cardsDir, archiveDir]) {
206
+ const result = await readCardFile(pile, fileNameFor(id));
207
+ if ("card" in result) return { card: result.card, pile };
208
+ }
209
+ return undefined;
130
210
  };
131
211
 
212
+ const read = async (id: string): Promise<Card | undefined> => (await locate(id))?.card;
213
+
132
214
  const create = async (input: CreateInput, actor: Actor, now: number): Promise<Card> => {
133
215
  await ensure();
134
216
  let lastError: unknown;
135
217
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
136
- const id = await nextCardId(cardsDir);
218
+ const id = await nextCardId(cardsDir, endedDir, archiveDir);
137
219
  const card = createCard(id, input, actor, now);
138
220
  const temp = tempPath(cardsDir);
139
221
  await writeFile(temp, serialize(card), { mode: FILE_MODE });
@@ -155,22 +237,44 @@ export function createStore(dir: string): BoardStore {
155
237
  const mutate = async (id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult> => {
156
238
  let changedUnderneath = false;
157
239
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
158
- const card = await read(id);
159
- if (!card) {
240
+ const found = await locate(id);
241
+ if (!found) {
242
+ if (changedUnderneath) return { ok: false, error: `${cardRef(id)} was dropped.` };
243
+ // A file that is there and refused is not a card that does not exist: the
244
+ // reader is told which file to look at instead of being sent to add one.
245
+ const skip = (await scan(cardsDir)).skipped.find((entry) => entry.id === id);
160
246
  return {
161
247
  ok: false,
162
- error: changedUnderneath ? `${cardRef(id)} was dropped.` : `${cardRef(id)} does not exist.`,
248
+ error:
249
+ skip === undefined
250
+ ? `${cardRef(id)} does not exist.`
251
+ : `${cardRef(id)} is there and this build cannot read it (${skip.file}: ${skip.why}).`,
163
252
  };
164
253
  }
254
+ const { card, pile } = found;
165
255
  // The preconditions are checked against the card as it is *now*, and against the
166
256
  // board as it is now, so an operation re-applied to a card that changed is refused
167
257
  // rather than forced.
168
258
  const result = applyOp(card, op, actor, now, indexOf(await list()));
169
259
  if (!result.ok) return { ok: false, error: result.error, card };
170
- if (!result.changed) return { ok: true, card: result.card, changed: false, dropped: false };
260
+ if (!result.changed) {
261
+ return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
262
+ }
171
263
  if (op.kind === "drop") {
172
- await rm(join(cardsDir, fileNameFor(id)), { force: true });
173
- return { ok: true, card: result.card, changed: true, dropped: true };
264
+ // The record lands first: a crash between the two leaves a card that still
265
+ // exists with a note explaining it, never a card that is gone unexplained.
266
+ await writeEnded(endedDir, result.card, actor, now);
267
+ await rm(join(pile, fileNameFor(id)), { force: true });
268
+ return { ok: true, card: result.card, changed: true, dropped: true, archived: false };
269
+ }
270
+ if (op.kind === "archive") {
271
+ // A card the board does not hold is already aside, and two sessions asking at
272
+ // once are one move rather than a second one.
273
+ if (pile === archiveDir) {
274
+ return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
275
+ }
276
+ await moveCard(cardsDir, archiveDir, id);
277
+ return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
174
278
  }
175
279
  // Last look before the rename: a writer that landed in between is the
176
280
  // re-read the next pass applies against.
@@ -179,8 +283,8 @@ export function createStore(dir: string): BoardStore {
179
283
  changedUnderneath = true;
180
284
  continue;
181
285
  }
182
- await writeCardFile(cardsDir, id, result.card);
183
- return { ok: true, card: result.card, changed: true, dropped: false };
286
+ await writeCardFile(pile, id, result.card);
287
+ return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
184
288
  }
185
289
  return {
186
290
  ok: false,
@@ -200,7 +304,19 @@ export function createStore(dir: string): BoardStore {
200
304
  return total;
201
305
  };
202
306
 
203
- return { dir, ensure, list, read, create, mutate, bytes, where: () => shortenPath(dir) };
307
+ return {
308
+ dir,
309
+ ensure,
310
+ list,
311
+ archived,
312
+ archivedCount,
313
+ read,
314
+ create,
315
+ mutate,
316
+ bytes,
317
+ skipped,
318
+ where: () => shortenPath(dir),
319
+ };
204
320
  }
205
321
 
206
322
  /** `<id>.json`, zero-padded to four digits so a directory lists in card order. */
@@ -211,58 +327,129 @@ function fileNameFor(id: string): string {
211
327
  /** The card files the reader accepts. */
212
328
  const CARD_FILE = /^(\d{4,})\.json$/;
213
329
 
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> {
330
+ /** One card file: the card, or the reason this build refused it. */
331
+ async function readCardFile(pileDir: string, entry: string): Promise<{ card: Card } | { skip: Skip }> {
216
332
  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;
333
+ if (!match) return { skip: { file: entry, why: "not a card id" } };
334
+ const id = String(Number(match[1]));
335
+ const text = await readFile(join(pileDir, entry), "utf-8").catch(() => undefined);
336
+ if (text === undefined) return { skip: { file: entry, id, why: "cannot be read" } };
220
337
  let parsed: unknown;
221
338
  try {
222
339
  parsed = JSON.parse(text);
223
340
  } catch {
224
- return undefined;
341
+ return { skip: { file: entry, id, why: "bad JSON" } };
342
+ }
343
+ // A version above this build's is the one refusal a reader can act on: the file was
344
+ // written by a newer build, so the board is not empty, it is not readable here.
345
+ const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
346
+ if (typeof version === "number" && version !== CARD_VERSION) {
347
+ return { skip: { file: entry, id, why: `version ${version}` } };
225
348
  }
226
- if (!isCard(parsed)) return undefined;
349
+ if (!isCard(parsed)) return { skip: { file: entry, id, why: "not a card record" } };
227
350
  // The file name is the id's canonical spelling: a padded id inside the record
228
351
  // (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;
352
+ if (parsed.id !== String(Number(match[1]))) {
353
+ return { skip: { file: entry, id, why: `names card ${parsed.id}` } };
354
+ }
355
+ return { card: parsed };
231
356
  }
232
357
 
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);
358
+ /** The highest id a directory holds, or 0 when it holds none. */
359
+ async function highestId(dir: string): Promise<number> {
360
+ const entries = await readdirSafe(dir);
236
361
  let highest = 0;
237
362
  for (const entry of entries) {
238
363
  const match = CARD_FILE.exec(entry);
239
364
  if (match) highest = Math.max(highest, Number(match[1]));
240
365
  }
241
- return String(highest + 1);
366
+ return highest;
367
+ }
368
+
369
+ /**
370
+ * `1`, `2`, `3` … as the highest id the board has ever used plus one.
371
+ *
372
+ * Ended and archived cards count: a dropped card's record keeps its id spent, and an
373
+ * archived card's file is still named by it, so a note or a handoff that names `#29`
374
+ * cannot come to mean a different feature tomorrow.
375
+ */
376
+ async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string): Promise<string> {
377
+ return String(Math.max(await highestId(cardsDir), await highestId(endedDir), await highestId(archiveDir)) + 1);
378
+ }
379
+
380
+ /**
381
+ * Write the record of a card that has ended.
382
+ *
383
+ * Exclusive like a create, so the id ends once: a retry that finds the record already
384
+ * there is the same end, not a second one.
385
+ */
386
+ async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
387
+ await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
388
+ const ended: EndedCard = {
389
+ v: 1,
390
+ id: card.id,
391
+ title: card.title,
392
+ status: card.status,
393
+ by: actor.label,
394
+ ...(card.owner === undefined ? {} : { owner: card.owner }),
395
+ at: now,
396
+ why: "dropped",
397
+ };
398
+ const temp = tempPath(endedDir);
399
+ await writeFile(temp, serialize(ended), { mode: FILE_MODE });
400
+ try {
401
+ await link(temp, join(endedDir, fileNameFor(card.id)));
402
+ } catch (error) {
403
+ if (!hasErrorCode(error, "EEXIST")) throw error;
404
+ } finally {
405
+ await rm(temp, { force: true });
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Move one card's file into the archive.
411
+ *
412
+ * A rename, not a write: the record does not change, and the pile it lies in is the whole
413
+ * of what an archive means.
414
+ */
415
+ async function moveCard(from: string, to: string, id: string): Promise<void> {
416
+ await mkdir(to, { recursive: true, mode: DIR_MODE });
417
+ await renameInto(join(from, fileNameFor(id)), join(to, fileNameFor(id)));
418
+ }
419
+
420
+ /**
421
+ * Rename one file over or onto another, with the fallback this platform needs.
422
+ *
423
+ * Windows refuses a rename onto an open file, so removing the target first is the only
424
+ * way through there; every caller has already read what it is replacing, which is what
425
+ * bounds what the removal can lose.
426
+ */
427
+ async function renameInto(source: string, target: string): Promise<void> {
428
+ try {
429
+ await rename(source, target);
430
+ } catch (error) {
431
+ if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) throw error;
432
+ await rm(target, { force: true });
433
+ await rename(source, target);
434
+ }
242
435
  }
243
436
 
244
437
  /** 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);
438
+ async function writeCardFile(dir: string, id: string, card: Card): Promise<void> {
439
+ const temp = tempPath(dir);
247
440
  await writeFile(temp, serialize(card), { mode: FILE_MODE });
248
- const target = join(cardsDir, fileNameFor(id));
249
441
  try {
250
- await rename(temp, target);
442
+ await renameInto(temp, join(dir, fileNameFor(id)));
251
443
  } 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);
444
+ await rm(temp, { force: true });
445
+ throw error;
260
446
  }
261
447
  }
262
448
 
263
449
  /** 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`;
450
+ /** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
451
+ function serialize(record: Card | EndedCard): string {
452
+ return `${JSON.stringify(record, null, "\t")}\n`;
266
453
  }
267
454
 
268
455
  /** A temp path in the cards directory: never matched by the reader's own pattern. */