@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
@@ -15,24 +15,33 @@ export const BoardParams = Type.Object({
15
15
  action: Type.Union(
16
16
  BOARD_ACTIONS.map((action) => Type.Literal(action)),
17
17
  {
18
+ // The verbs and their meanings live in the tool description, built from
19
+ // `BOARD_ACTION_DOCS`; this says only what a caller needs to choose one.
18
20
  description:
19
- "`list` the board (optionally one card, one lane, a query, or the lead's rollup), `add` a card, " +
20
- "`claim` one for this session, `release` it, `reclaim` it from whoever holds it, `move` it to " +
21
- "another lane, `note` it, `plan` it (record the plan file being executed), `edit` what it says " +
22
- "(title, paths, priority, what it waits for), or `drop` it.",
21
+ "`list` reads the board, or one card when `card` names it; every other verb changes it. The tool description says what each one does.",
23
22
  },
24
23
  ),
25
24
  card: Type.Optional(
26
- Type.String({ description: 'The card id for one card, e.g. "4" or "#4". Used by every action but `list`/`add`.' }),
25
+ Type.String({
26
+ description:
27
+ 'The card id for one card, e.g. "4" or "#4". `list` reads that card; every other action but `add` acts on it.',
28
+ }),
27
29
  ),
28
30
  status: Type.Optional(
29
31
  Type.Union(
30
32
  CARD_STATUSES.map((status) => Type.Literal(status)),
31
33
  {
32
- description: "`list`: show only this lane. `move`: the lane to move to (not `todo` — use `release`).",
34
+ description:
35
+ "`list`: show only this lane, and not skip the `done` cards. `move`: the lane to move to (not `todo` — use `release`).",
33
36
  },
34
37
  ),
35
38
  ),
39
+ blockedBy: Type.Optional(
40
+ Type.String({
41
+ description:
42
+ "`move` to `blocked`: what the card waits on — the decision a person has to make, one line, at most 200 characters.",
43
+ }),
44
+ ),
36
45
  title: Type.Optional(Type.String({ description: "`add` and `edit`: the feature's outcome, one line." })),
37
46
  paths: Type.Optional(
38
47
  Type.Array(Type.String(), {
@@ -64,7 +73,7 @@ export const BoardParams = Type.Object({
64
73
  query: Type.Optional(
65
74
  Type.String({
66
75
  description:
67
- "`list`: show only the cards whose title, path hint, or holder matches this text, case-insensitively.",
76
+ "`list`: show only the cards whose title, holder, path hint, plan, or a note's text matches this text, case-insensitively.",
68
77
  }),
69
78
  ),
70
79
  round: Type.Optional(
@@ -72,4 +81,10 @@ export const BoardParams = Type.Object({
72
81
  description: "`list`: also return the lead's rollup — owners, unassigned, unplanned, blocked, idle.",
73
82
  }),
74
83
  ),
84
+ archived: Type.Optional(
85
+ Type.Boolean({
86
+ description:
87
+ "`list`: read the archive instead of the board — the finished cards `archive` set aside, which no other read opens. `query` and `mine` narrow it; `status` and `round` do not apply to a pile that is out of the board.",
88
+ }),
89
+ ),
75
90
  });
@@ -1,16 +1,15 @@
1
1
  /**
2
- * The seat: the name the page serves as, and the actor its writes are made as.
2
+ * The seat: the name the page serves as, and the actor it is shown as.
3
3
  *
4
4
  * A page outlives the session that opened it — a `/resume`, a rename, or a fresh
5
- * session taking the page over would otherwise change who a browser claim belongs to,
6
- * and a card claimed yesterday could no longer be released today. So the page is a
7
- * *seat* rather than a session: one fixed name, one fixed id derived from it, and the
8
- * same cards belong to it however many sessions have served it.
5
+ * session taking the page over would otherwise change who the page says it is. So the
6
+ * page is a *seat* rather than a session: one fixed name, one fixed id derived from it,
7
+ * and the same identity however many sessions have served it.
9
8
  *
10
- * The id is derived rather than minted so two sessions that serve the same seat are the
11
- * same actor — which is what a handover needs, and what makes the terminal a *different*
12
- * actor from the page: the board's own tools still write as the session, and `reclaim`
13
- * is how a lead takes back what the page holds.
9
+ * The page writes nothing, so the id decides no ownership: it is what the page's own
10
+ * roster and the `you` chip are read against, and it keeps a page's reads the same
11
+ * across a handover. The board's tools write as the session — which is why the label is
12
+ * shared with the session serving the page and the ids are not.
14
13
  */
15
14
 
16
15
  import type { Actor } from "../../lib/board/rules.ts";
@@ -22,7 +21,7 @@ export const DEFAULT_SEAT = "team-lead";
22
21
  /** The prefix of a page's actor id, so one is never mistaken for a session id. */
23
22
  export const SEAT_ID_PREFIX = "board:";
24
23
 
25
- /** The actor a page writes as: the seat, and an id derived from it. */
24
+ /** The actor a page reads as: the seat, and an id derived from it. */
26
25
  export function pageActor(label: string = DEFAULT_SEAT): Actor {
27
26
  const seat = normalizeLabel(label, DEFAULT_SEAT);
28
27
  return { id: `${SEAT_ID_PREFIX}${seat.toLowerCase()}`, label: seat };
@@ -35,6 +35,7 @@ export async function viewOf(context: SnapshotContext): Promise<BoardView> {
35
35
  cards: await context.store.list(),
36
36
  now: context.now(),
37
37
  bytes: await context.store.bytes(),
38
+ skipped: (await context.store.skipped()).length,
38
39
  where: context.store.where(),
39
40
  ...(roster === undefined ? {} : { roster }),
40
41
  });
@@ -20,17 +20,42 @@ import { type CardOp, holds } from "../../lib/board/rules.ts";
20
20
  import { buildRound } from "../../lib/board/round.ts";
21
21
  import type { BoardStore } from "../../lib/board/store.ts";
22
22
  import { messageOf } from "../../lib/errors.ts";
23
+ import { revParse } from "../../lib/git/read.ts";
24
+ import { strayField } from "../../lib/stray-fields.ts";
25
+ import { createExecRunner } from "../../lib/git/runner.ts";
23
26
  import { failedResult, textResult } from "../../lib/tool-result.ts";
24
27
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
25
28
  import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
26
29
  import { formatRound } from "../../lib/board/round-text.ts";
27
- import { formatCard, formatCards, formatDropped } from "./format.ts";
30
+ import {
31
+ archiveNote,
32
+ formatArchive,
33
+ formatArchived,
34
+ formatBoard,
35
+ formatCard,
36
+ formatDropped,
37
+ formatSkipped,
38
+ } from "./format.ts";
28
39
  import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
29
40
  import { BoardResult, collapsedLine, resultInput } from "./result.ts";
30
41
  import { createInput } from "./input.ts";
31
42
  import type { BoardSession } from "../../lib/board/session.ts";
32
43
  import { BoardParams } from "./schema.ts";
33
- import { BOARD_ACTIONS, BOARD_TOOL, type BoardAction, type BoardDetails, normalizeBoardAction } from "./types.ts";
44
+ import {
45
+ ACTION_FIELDS,
46
+ BOARD_ACTION_DOCS,
47
+ BOARD_ACTIONS,
48
+ BOARD_MUTATING_ACTIONS,
49
+ BOARD_TOOL,
50
+ type BoardAction,
51
+ type BoardDetails,
52
+ normalizeBoardAction,
53
+ } from "./types.ts";
54
+
55
+ /** The verbs as one sentence, in `BOARD_ACTIONS` order; the table owns the words. */
56
+ function verbHelp(): string {
57
+ return `${BOARD_ACTIONS.map((action) => `\`${action}\` ${BOARD_ACTION_DOCS[action]}`).join("; ")}.`;
58
+ }
34
59
 
35
60
  /** The parameters as the schema validates them. */
36
61
  type BoardArgs = Static<typeof BoardParams>;
@@ -71,8 +96,18 @@ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, c
71
96
  });
72
97
  }
73
98
 
74
- /** The actions that need this session to be a teammate: everything but a read. */
75
- const MUTATING_ACTIONS = new Set<BoardAction>(BOARD_ACTIONS.filter((action) => action !== "list"));
99
+ /**
100
+ * The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
101
+ *
102
+ * A finished card records what closed it, and the commit is the one fact a repository can
103
+ * answer that the card cannot. A directory that is not a repository, or one with no
104
+ * commits, answers nothing — the move still happens, because finishing a card must not
105
+ * depend on git.
106
+ */
107
+ async function headCommit(pi: ExtensionAPI, cwd: string): Promise<{ commit: string } | undefined> {
108
+ const id = await revParse(createExecRunner(pi), cwd, "HEAD");
109
+ return id === undefined ? undefined : { commit: id.slice(0, 7) };
110
+ }
76
111
 
77
112
  export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { team?: TeamJoin } = {}): void {
78
113
  const team = deps.team ?? createTeamJoin(pi);
@@ -82,16 +117,9 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
82
117
  description:
83
118
  "The shared feature list for this repository: one card per feature, which any session reads and writes. " +
84
119
  "A card carries the feature's outcome, the session that holds it, path hints, the plan it is executing, " +
85
- "its priority, what it waits for, and its notes. `list` shows the board (`status` filters one lane, " +
86
- "`mine` shows only what this session holds, `query` matches a title, a path hint, or a holder, `card` shows one card, `round` adds the lead's rollup " +
87
- "of owners, unassigned, unplanned, blocked, and idle - the rollup always covers the whole board). `add` " +
88
- "creates a `todo` card; `claim` takes one for this session; `release` gives it back; `reclaim` takes it " +
89
- "from whoever holds it; `move` changes lane; `note` appends to its history; `plan` records the plan file " +
90
- "being executed; `edit` fixes what the card says (title, paths, priority, and the cards it waits for); " +
91
- "`drop` deletes a `todo`, a `done`, or one you hold. A card's steps do not belong on the board - they " +
92
- "belong in the plan and in `todo`.",
93
- promptSnippet:
94
- "The shared feature list for this repository: list, add, claim, release, reclaim, move, note, plan, edit, drop.",
120
+ `its priority, what it waits for, and its notes. ${verbHelp()} A card's steps do not belong on the ` +
121
+ "board - they belong in the plan and in `todo`.",
122
+ promptSnippet: `The shared feature list for this repository: ${BOARD_ACTIONS.join(", ")}.`,
95
123
  promptGuidelines: [
96
124
  "Claim a card before working on it; release or finish it when you stop, and reclaim a card whose owner is gone.",
97
125
  "Record the plan file with `board` action `plan` once the plan is approved, then execute it.",
@@ -117,11 +145,15 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
117
145
  }
118
146
  const me = runtime.who(ctx, ctx.cwd);
119
147
  try {
148
+ // Before the join and the store: a refused call has done nothing, so it cannot
149
+ // have enlisted this session as a teammate on its way to the refusal.
150
+ const stray = strayField(action, args, ACTION_FIELDS);
151
+ if (stray !== undefined) return refusal(action, me, stray);
120
152
  // Working a card makes this session a teammate, once: the board is the user's
121
153
  // surface for the team, so nobody has to know the bus exists. A failed join is
122
154
  // not a failed card write — the board works with no bus at all — and it is not
123
155
  // marked, so the next write asks again.
124
- if (MUTATING_ACTIONS.has(action) && !runtime.joined()) {
156
+ if (BOARD_MUTATING_ACTIONS.has(action) && !runtime.joined()) {
125
157
  if ((await team.join(ctx)).ok) runtime.markJoined();
126
158
  }
127
159
  const store = await runtime.store(ctx.cwd);
@@ -147,7 +179,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
147
179
  'move needs a target lane: "doing", "blocked", or "done". Use release to return a card to todo.',
148
180
  );
149
181
  }
150
- return await mutate(store, action, me, now, args, { kind: "move", status });
182
+ // Only the lane that has something to say reads for it: a move to `done`
183
+ // records the commit that closed the card, and a board outside a
184
+ // repository simply has none to record.
185
+ const landed = status === "done" ? await headCommit(pi, ctx.cwd) : undefined;
186
+ return await mutate(store, action, me, now, args, {
187
+ kind: "move",
188
+ status,
189
+ ...(args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy }),
190
+ ...(landed === undefined ? {} : { landed }),
191
+ });
151
192
  }
152
193
  case "note":
153
194
  return await mutate(store, action, me, now, args, { kind: "note", text: args.text });
@@ -172,6 +213,8 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
172
213
  }
173
214
  case "drop":
174
215
  return await dropCard(store, me, args, now);
216
+ case "archive":
217
+ return await archiveCards(store, me, args, now);
175
218
  }
176
219
  } catch (error) {
177
220
  return refusal(action, me, messageOf(error));
@@ -204,7 +247,7 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
204
247
  });
205
248
  }
206
249
 
207
- /** `list`: the board, one card, or the lead's rollup. */
250
+ /** `list`: the board, one card, the lead's rollup, or the archive. */
208
251
  async function read(
209
252
  store: BoardStore,
210
253
  action: BoardAction,
@@ -212,13 +255,32 @@ async function read(
212
255
  args: BoardArgs,
213
256
  now: number,
214
257
  ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
258
+ if (args.archived === true) return readArchive(store, action, me, args, now);
215
259
  const cards = await store.list();
260
+ // Read once: a card file the reader refused is the difference between "not there"
261
+ // and "there, and this build cannot read it".
262
+ const skipped = await store.skipped();
263
+ // A directory listing, not a read: a listing says what it left aside without opening one.
264
+ const archived = await store.archivedCount();
216
265
  if (args.card !== undefined) {
217
266
  const id = normalizeCardId(args.card);
218
267
  if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
219
268
  const card = cards.find((entry) => entry.id === id);
220
- if (!card) return refusal(action, me, `${cardRef(id)} does not exist.`);
221
- return textResult(formatCard(card, indexOf(cards)), { action, me, card });
269
+ if (!card) {
270
+ // Gone and set aside are different answers, and one read by id tells them apart.
271
+ if ((await store.read(id)) !== undefined) {
272
+ return refusal(action, me, `${cardRef(id)} is archived; pass archived: true to read it.`);
273
+ }
274
+ const unreadable = skipped.find((entry) => entry.id === id);
275
+ return refusal(
276
+ action,
277
+ me,
278
+ unreadable === undefined
279
+ ? `${cardRef(id)} does not exist.`
280
+ : `${cardRef(id)} is there and this build cannot read it (${unreadable.file}: ${unreadable.why}).`,
281
+ );
282
+ }
283
+ return textResult(formatCard(card, indexOf(cards), now), { action, me, card });
222
284
  }
223
285
  const listed = cards.filter(
224
286
  (card) =>
@@ -228,9 +290,65 @@ async function read(
228
290
  // session could not release.
229
291
  (args.mine !== true || holds(card, me)),
230
292
  );
231
- if (args.round !== true) return textResult(formatCards(listed), { action, me, cards: listed });
293
+ // A finished card is history rather than work, and nothing prunes it — this
294
+ // repository's own board is mostly finished cards — so a listing that names no lane
295
+ // leaves them out and says how many. A named lane, `card`, and `round` are unchanged;
296
+ // the rollup counts the whole board, which is what makes it a rollup.
297
+ const hiddenDone = args.status === undefined ? listed.filter((card) => card.status === "done").length : 0;
298
+ const shown = hiddenDone === 0 ? listed : listed.filter((card) => card.status !== "done");
299
+ if (args.round !== true) {
300
+ return textResult(formatBoard(shown, { hiddenDone, archived, skipped }), { action, me, cards: shown });
301
+ }
232
302
  const round = buildRound(cards, now);
233
- return textResult(formatRound(cards, round), { action, me, cards: listed, round });
303
+ // One line under the rollup, because a reader who cannot see a card file should know why,
304
+ // and one for the archive, because a rollup that silently left it out would be a board
305
+ // smaller than the one on disk.
306
+ const notes = [formatSkipped(skipped), archiveNote(archived)].filter((line) => line !== "");
307
+ const text = [formatRound(cards, round), ...notes].join("\n");
308
+ return textResult(text, { action, me, cards: listed, round });
309
+ }
310
+
311
+ /**
312
+ * `list archived: true`: the cards set aside, which no board read opens.
313
+ *
314
+ * A lane and a rollup are answers about the board, so asking for the archive with either
315
+ * is refused rather than quietly ignored: the pile is one thing, and every card in it is
316
+ * finished. A query and `mine` narrow it, because both are questions about a card.
317
+ */
318
+ async function readArchive(
319
+ store: BoardStore,
320
+ action: BoardAction,
321
+ me: BoardDetails["me"],
322
+ args: BoardArgs,
323
+ now: number,
324
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
325
+ const wrong = (["status", "round"] as const).filter((field) => args[field] !== undefined);
326
+ if (wrong.length > 0) {
327
+ return refusal(
328
+ action,
329
+ me,
330
+ `archived does not read ${wrong.join(", ")}: the archive is one pile of finished cards, out of the board.`,
331
+ );
332
+ }
333
+ const pile = await store.archived();
334
+ if (args.card !== undefined) {
335
+ const id = normalizeCardId(args.card);
336
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
337
+ const card = pile.cards.find((entry) => entry.id === id);
338
+ if (!card) {
339
+ const onBoard = (await store.list()).some((entry) => entry.id === id);
340
+ return refusal(
341
+ action,
342
+ me,
343
+ onBoard ? `${cardRef(id)} is on the board, not in the archive.` : `${cardRef(id)} is not in the archive.`,
344
+ );
345
+ }
346
+ return textResult(formatCard(card, indexOf(pile.cards), now), { action, me, card });
347
+ }
348
+ const shown = pile.cards.filter(
349
+ (card) => (args.query === undefined || matchesQuery(card, args.query)) && (args.mine !== true || holds(card, me)),
350
+ );
351
+ return textResult(formatArchive(shown, pile.skipped), { action, me, cards: shown });
234
352
  }
235
353
 
236
354
  /** `add`: the only action that creates a card. */
@@ -263,6 +381,53 @@ async function mutate(
263
381
  return textResult(formatCard(result.card), { action, me, card: result.card });
264
382
  }
265
383
 
384
+ /**
385
+ * `archive`: a finished card leaves the board without being destroyed.
386
+ *
387
+ * One card, or — with no card named — every finished card, because clearing a board is
388
+ * the errand and a lead should not have to run sixty calls to do it. Each move is its own
389
+ * compare-and-set, so a sweep is best-effort: it reports what it actually set aside.
390
+ */
391
+ async function archiveCards(
392
+ store: BoardStore,
393
+ me: BoardDetails["me"],
394
+ args: BoardArgs,
395
+ now: number,
396
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
397
+ if (args.card !== undefined) {
398
+ const id = normalizeCardId(args.card);
399
+ if (id === undefined) return refusal("archive", me, `"${args.card}" is not a card id.`);
400
+ const result = await store.mutate(id, { kind: "archive" }, me, now);
401
+ if (!result.ok) return refusal("archive", me, result.error, result.card);
402
+ // Asked twice is one state, not an error: the card is where the caller wanted it.
403
+ if (!result.changed) return textResult(`${cardRef(id)} is already archived.`, { action: "archive", me });
404
+ return textResult(formatArchived([id]), { action: "archive", me, card: result.card, archived: [id] });
405
+ }
406
+ const finished = (await store.list()).filter((card) => card.status === "done");
407
+ const moved: string[] = [];
408
+ const failed: Array<{ card: string; error: string }> = [];
409
+ for (const card of finished) {
410
+ const result = await store.mutate(card.id, { kind: "archive" }, me, now);
411
+ if (result.ok && result.changed) moved.push(card.id);
412
+ // A sweep is best-effort, and a card that changed underneath it is the one thing it
413
+ // cannot report by silence: "every finished card" must not read as fewer, unexplained.
414
+ else if (!result.ok) failed.push({ card: card.id, error: result.error });
415
+ }
416
+ if (moved.length === 0 && failed.length === 0) {
417
+ return textResult("No finished cards to archive.", { action: "archive", me });
418
+ }
419
+ const text = [
420
+ ...(moved.length === 0 ? [] : [formatArchived(moved)]),
421
+ ...failed.map((entry) => `${cardRef(entry.card)} was not archived (${entry.error})`),
422
+ ].join(" · ");
423
+ return textResult(text, {
424
+ action: "archive",
425
+ me,
426
+ ...(moved.length === 0 ? {} : { archived: moved }),
427
+ ...(failed.length === 0 ? {} : { failed }),
428
+ });
429
+ }
430
+
266
431
  /** `drop`: the card and its notes leave together, so the report is the id. */
267
432
  async function dropCard(
268
433
  store: BoardStore,
@@ -2,14 +2,14 @@
2
2
  * The board page's router: one request in, one response out, and every policy the
3
3
  * served surface obeys decided here.
4
4
  *
5
- * The page can read and it can write, so it is checked the way `plan`'s review page
6
- * is: a token in the URL, the `Host` allowlist, an origin check on every
7
- * state-changing method, and a JSON body. The guards themselves are `lib/http`'s —
8
- * this module only decides the order and the sentences.
5
+ * The page reads. It cannot change a card, so what is left to decide is the `Host`
6
+ * allowlist, the token in the URL, and which reads exist — a method that would write is
7
+ * refused by the method list before a handler is reached. The guards themselves are
8
+ * `lib/http`'s — this module only decides the order and the sentences.
9
9
  *
10
- * Nothing here knows what a board is. The snapshot arrives as `view()`, a write as
11
- * `write()`, and the document as `page()`, all supplied by `page.ts`, so this file
12
- * can be tested with a fake store and no server.
10
+ * Nothing here knows what a board is. The snapshot arrives as `view()` and the document
11
+ * as `page()`, both supplied by `page.ts`, so this file can be tested with a fake store
12
+ * and no server.
13
13
  */
14
14
 
15
15
  import {
@@ -18,72 +18,48 @@ import {
18
18
  jsonError,
19
19
  jsonResponse,
20
20
  pageHeaders,
21
- PAGE_METHODS,
22
21
  parseRequestUrl,
23
- postOnly,
24
- requireJsonBody,
25
22
  scriptHeaders,
26
23
  type ServeResponse,
27
24
  } from "../../../lib/http/index.ts";
28
25
  import type { BoardView } from "../../../lib/board/view.ts";
29
- import type { Card } from "../../../lib/board/card.ts";
30
26
  import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
31
27
  import type { PageView } from "../view/page.ts";
32
28
 
33
- /** The methods this surface serves: reads, writes, and the one deletion. */
34
- const METHODS: readonly string[] = [...PAGE_METHODS, "DELETE"];
35
-
36
29
  /**
37
- * What a credential may do on this page.
30
+ * The methods this surface serves: the two reads and the stream.
38
31
  *
39
- * A page can be opened by the session that serves it and by a teammate watching it, and
40
- * the two carry different tokens: `write` may change a card, `view` may only read. The
41
- * split is a value here and not a boolean threaded through the page, so a route asks one
42
- * function instead of remembering which token it was given.
32
+ * `PAGE_METHODS` also carries `POST`, because a page that writes needs it. This one does
33
+ * not, so a write is refused by the envelope — before the token is even read — and the
34
+ * `Allow` header says the truth about what a browser can do here.
43
35
  */
44
- export type Authority = "write" | "view";
45
-
46
- /** The verbs a card route accepts, in the order the page uses them. */
47
- const CARD_VERBS = ["claim", "release", "reclaim", "assign", "move", "note", "plan", "edit"] as const;
48
-
49
- /** One card verb. */
50
- export type CardVerb = (typeof CARD_VERBS)[number];
51
-
52
- /** One write the page asks for, already routed but not yet applied. */
53
- export type PageWrite =
54
- | { kind: "add"; body: Record<string, unknown> }
55
- | { kind: "drop"; id: string }
56
- | { kind: CardVerb; id: string; body: Record<string, unknown> };
57
-
58
- /** What a write did, or why it was refused with the status the page shows. */
59
- export type PageResult = { ok: true; card: Card } | { ok: false; status: number; error: string; card?: Card };
36
+ const METHODS: readonly string[] = ["GET", "HEAD"];
60
37
 
61
38
  /** Everything the router needs from the running page. */
62
39
  export interface BoardRouterContext {
63
- /** What the request's token may do here, or undefined when it is not a token of this page. */
64
- authority: (given: string | null) => Authority | undefined;
40
+ /** Whether a token is this page's. */
41
+ read: (given: string | null) => boolean;
65
42
  /** The names this server answers to. */
66
43
  hosts: () => readonly string[];
67
- /** The document for one tab, as the authority that asked for it may see it. */
68
- page: (view: PageView, authority: Authority) => Promise<string>;
44
+ /** The document for one tab. */
45
+ page: (view: PageView) => Promise<string>;
69
46
  /** The client script. */
70
47
  script: string;
71
48
  /** The snapshot the page renders, and the body of `/api/board`. */
72
49
  view: () => Promise<BoardView>;
73
- /** Apply one write as the serving session. */
74
- write: (request: PageWrite) => Promise<PageResult>;
75
50
  }
76
51
 
52
+ /** The cheap liveness route: a probe reads this, and never the snapshot. */
53
+ export const PING_PATH = "/api/ping";
54
+
77
55
  /**
78
- * Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values;
79
- * `body` is the request body, which is only non-empty when the server caps it.
56
+ * Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values.
80
57
  */
81
58
  export async function handleBoardRequest(
82
59
  ctx: BoardRouterContext,
83
60
  method: string,
84
61
  rawUrl: string,
85
62
  headers: Record<string, string | string[] | undefined> = {},
86
- body = "",
87
63
  ): Promise<ServeResponse> {
88
64
  const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts(), allowed: METHODS });
89
65
  if (envelope) return envelope;
@@ -92,81 +68,24 @@ export async function handleBoardRequest(
92
68
  if (!parsed) return jsonError(400, "Malformed request URL.");
93
69
  const { pathname, search } = parsed;
94
70
 
95
- if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
96
71
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
97
72
  if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: ctx.script };
98
73
 
99
- const authority = ctx.authority(search.get("token"));
100
- if (authority === undefined) return jsonError(403, "Invalid or missing token.");
74
+ if (!ctx.read(search.get("token"))) return jsonError(403, "Invalid or missing token.");
101
75
 
76
+ // The liveness answer, and nothing else: a session deciding whether this page is alive
77
+ // must not pay for the snapshot, which is a store read and a roster read on the same
78
+ // event loop the probe is racing.
79
+ if (pathname === PING_PATH) return jsonResponse(200, { ok: true });
102
80
  if (pathname === "/api/board") return jsonResponse(200, await ctx.view());
103
81
  if (pathname === "/" || pathname === "/index.html") {
104
- const view = viewOf(search.get("view"));
105
- return { status: 200, headers: pageHeaders(), body: await ctx.page(view, authority) };
82
+ return { status: 200, headers: pageHeaders(), body: await ctx.page(viewOf(search.get("view"))) };
106
83
  }
107
84
  if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
108
-
109
- // Everything below changes a card, so a credential that may only read stops here.
110
- const readOnly = writeGuard(authority);
111
- if (readOnly) return readOnly;
112
-
113
- if (pathname === "/api/cards") {
114
- const methodRefusal = postOnly(method, "add a card");
115
- if (methodRefusal) return methodRefusal;
116
- const jsonRefusal = requireJsonBody(headers["content-type"]);
117
- if (jsonRefusal) return jsonRefusal;
118
- const parsedBody = parseBody(body);
119
- if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
120
- return writeAnswer(await ctx.write({ kind: "add", body: parsedBody }));
121
- }
122
-
123
- const route = /^\/api\/cards\/([^/]+)(?:\/([^/]+))?$/.exec(pathname);
124
- if (!route) return jsonError(404, "That page does not exist.");
125
- const id = decodeURIComponent(route[1] ?? "");
126
- const verb = route[2];
127
-
128
- if (verb === undefined) {
129
- if (method.toUpperCase() !== "DELETE") return jsonError(405, "Use DELETE to drop a card.");
130
- return writeAnswer(await ctx.write({ kind: "drop", id }));
131
- }
132
- if (!CARD_VERBS.includes(verb as CardVerb)) return jsonError(404, "That card action does not exist.");
133
- const methodRefusal = postOnly(method, verb);
134
- if (methodRefusal) return methodRefusal;
135
- const jsonRefusal = requireJsonBody(headers["content-type"]);
136
- if (jsonRefusal) return jsonRefusal;
137
- const parsedBody = parseBody(body);
138
- if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
139
- return writeAnswer(await ctx.write({ kind: verb as CardVerb, id, body: parsedBody }));
140
- }
141
-
142
- /** The 403 for a credential that may read this page but not change it. */
143
- function writeGuard(authority: Authority): ServeResponse | undefined {
144
- if (authority === "write") return undefined;
145
- return jsonError(403, "This page is read-only for a viewer.");
85
+ return jsonError(404, "That page does not exist.");
146
86
  }
147
87
 
148
88
  /** The tab a `?view=` names; anything else is the board. */
149
89
  function viewOf(raw: string | null): PageView {
150
90
  return raw === "team" ? "team" : "board";
151
91
  }
152
-
153
- /** A request body as an object, or undefined when it is not one. */
154
- function parseBody(body: string): Record<string, unknown> | undefined {
155
- let parsed: unknown;
156
- try {
157
- parsed = JSON.parse(body);
158
- } catch {
159
- return undefined;
160
- }
161
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
162
- return parsed as Record<string, unknown>;
163
- }
164
-
165
- /** A write's answer: the card, or the refusal and the card it left alone. */
166
- function writeAnswer(result: PageResult): ServeResponse {
167
- if (result.ok) return jsonResponse(200, { card: result.card });
168
- return jsonResponse(
169
- result.status,
170
- result.card === undefined ? { error: result.error } : { error: result.error, card: result.card },
171
- );
172
- }