@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
@@ -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 };
@@ -18,8 +18,6 @@ export interface SnapshotContext {
18
18
  store: BoardStore;
19
19
  /** The actor every request is made and reported as: the page's seat. */
20
20
  actor: () => Actor;
21
- /** The session serving the page, which is who is reading, not who it writes as. */
22
- session?: () => Actor;
23
21
  now: () => number;
24
22
  /** Where this repository's bus files its records. */
25
23
  busDir: () => Promise<string>;
@@ -28,13 +26,17 @@ export interface SnapshotContext {
28
26
  /** The snapshot the page renders, and the body of `/api/board`. */
29
27
  export async function viewOf(context: SnapshotContext): Promise<BoardView> {
30
28
  const roster = await readRoster(await context.busDir());
31
- const session = context.session?.();
29
+ // One read of the pile answers both: a frame that asked `list` and `skipped` separately
30
+ // would parse every card twice, once a second, for as long as a tab is open.
31
+ const { cards, skipped } = await context.store.scan();
32
32
  return buildView({
33
33
  me: context.actor(),
34
- ...(session === undefined ? {} : { session }),
35
- cards: await context.store.list(),
34
+ cards,
36
35
  now: context.now(),
37
36
  bytes: await context.store.bytes(),
37
+ skipped: skipped.length,
38
+ repo: context.store.name(),
39
+ root: context.store.root(),
38
40
  where: context.store.where(),
39
41
  ...(roster === undefined ? {} : { roster }),
40
42
  });
@@ -14,23 +14,48 @@
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import type { Static } from "typebox";
17
- import { cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
17
+ import { type Card, cardRef, matchesQuery, normalizeCardId } from "../../lib/board/card.ts";
18
18
  import { indexOf } from "../../lib/board/links.ts";
19
19
  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
- import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
28
+ import { callLine, detailsOrLine, isTailResult, TailResult } 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
- import { BoardResult, collapsedLine, resultInput } from "./result.ts";
40
+ import { joinOnClaim } from "./join.ts";
41
+ import { collapsedLine, resultInput } from "./result.ts";
30
42
  import { createInput } from "./input.ts";
31
43
  import type { BoardSession } from "../../lib/board/session.ts";
32
44
  import { BoardParams } from "./schema.ts";
33
- import { BOARD_ACTIONS, BOARD_TOOL, type BoardAction, type BoardDetails, normalizeBoardAction } from "./types.ts";
45
+ import {
46
+ ACTION_FIELDS,
47
+ BOARD_ACTION_DOCS,
48
+ BOARD_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,29 @@ 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
+ * Whether a result this tool built is a refusal.
101
+ *
102
+ * `refusal` marks one with `isError`, and the success shape (`ToolResult`) has no such
103
+ * field, so this is the one probe that tells the two apart — read here rather than
104
+ * widening the shared envelope with a flag that would then be optional everywhere.
105
+ */
106
+ function refused(result: unknown): boolean {
107
+ return typeof result === "object" && result !== null && (result as { isError?: unknown }).isError === true;
108
+ }
109
+
110
+ /**
111
+ * The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
112
+ *
113
+ * A finished card records what closed it, and the commit is the one fact a repository can
114
+ * answer that the card cannot. A directory that is not a repository, or one with no
115
+ * commits, answers nothing — the move still happens, because finishing a card must not
116
+ * depend on git.
117
+ */
118
+ async function headCommit(pi: ExtensionAPI, cwd: string): Promise<{ commit: string } | undefined> {
119
+ const id = await revParse(createExecRunner(pi), cwd, "HEAD");
120
+ return id === undefined ? undefined : { commit: id.slice(0, 7) };
121
+ }
76
122
 
77
123
  export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { team?: TeamJoin } = {}): void {
78
124
  const team = deps.team ?? createTeamJoin(pi);
@@ -82,16 +128,9 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
82
128
  description:
83
129
  "The shared feature list for this repository: one card per feature, which any session reads and writes. " +
84
130
  "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.",
131
+ `its priority, what it waits for, and its notes. ${verbHelp()} A card's steps do not belong on the ` +
132
+ "board - they belong in the plan and in `todo`.",
133
+ promptSnippet: `The shared feature list for this repository: ${BOARD_ACTIONS.join(", ")}.`,
95
134
  promptGuidelines: [
96
135
  "Claim a card before working on it; release or finish it when you stop, and reclaim a card whose owner is gone.",
97
136
  "Record the plan file with `board` action `plan` once the plan is approved, then execute it.",
@@ -117,13 +156,10 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
117
156
  }
118
157
  const me = runtime.who(ctx, ctx.cwd);
119
158
  try {
120
- // Working a card makes this session a teammate, once: the board is the user's
121
- // surface for the team, so nobody has to know the bus exists. A failed join is
122
- // not a failed card write — the board works with no bus at all — and it is not
123
- // marked, so the next write asks again.
124
- if (MUTATING_ACTIONS.has(action) && !runtime.joined()) {
125
- if ((await team.join(ctx)).ok) runtime.markJoined();
126
- }
159
+ // Before the join and the store: a refused call has done nothing, so it cannot
160
+ // have enlisted this session as a teammate on its way to the refusal.
161
+ const stray = strayField(action, args, ACTION_FIELDS);
162
+ if (stray !== undefined) return refusal(action, me, stray);
127
163
  const store = await runtime.store(ctx.cwd);
128
164
  const now = runtime.now();
129
165
  switch (action) {
@@ -131,8 +167,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
131
167
  return await read(store, action, me, args, now);
132
168
  case "add":
133
169
  return await add(store, me, args, now);
134
- case "claim":
135
- return await mutate(store, action, me, now, args, { kind: "claim" });
170
+ case "claim": {
171
+ const claimed = await mutate(store, action, me, now, args, { kind: "claim" });
172
+ // Taking a card is what makes this session a teammate. It is the one action here
173
+ // worth being addressed about, and the board is the user's surface for the team, so
174
+ // nobody has to know the bus exists. A refused claim wrote nothing and enlists
175
+ // nobody; a refused join is not a failed claim, so the card stands and the next
176
+ // claim asks again.
177
+ if (!refused(claimed)) await joinOnClaim(runtime.team, team, ctx, me.label);
178
+ return claimed;
179
+ }
136
180
  case "release":
137
181
  return await mutate(store, action, me, now, args, { kind: "release" });
138
182
  case "reclaim":
@@ -147,7 +191,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
147
191
  'move needs a target lane: "doing", "blocked", or "done". Use release to return a card to todo.',
148
192
  );
149
193
  }
150
- return await mutate(store, action, me, now, args, { kind: "move", status });
194
+ // Only the lane that has something to say reads for it: a move to `done`
195
+ // records the commit that closed the card, and a board outside a
196
+ // repository simply has none to record.
197
+ const landed = status === "done" ? await headCommit(pi, ctx.cwd) : undefined;
198
+ return await mutate(store, action, me, now, args, {
199
+ kind: "move",
200
+ status,
201
+ ...(args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy }),
202
+ ...(landed === undefined ? {} : { landed }),
203
+ });
151
204
  }
152
205
  case "note":
153
206
  return await mutate(store, action, me, now, args, { kind: "note", text: args.text });
@@ -172,6 +225,8 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
172
225
  }
173
226
  case "drop":
174
227
  return await dropCard(store, me, args, now);
228
+ case "archive":
229
+ return await archiveCards(store, me, args, now);
175
230
  }
176
231
  } catch (error) {
177
232
  return refusal(action, me, messageOf(error));
@@ -196,15 +251,15 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
196
251
  text.setText(collapsedLine(theme, outcome.details));
197
252
  return text;
198
253
  }
199
- const view =
200
- context?.lastComponent instanceof BoardResult ? context.lastComponent : new BoardResult(input, theme);
254
+ const previous = context?.lastComponent;
255
+ const view = isTailResult<Card>(previous) ? previous : new TailResult<Card>(input, theme);
201
256
  view.setInput(input, theme);
202
257
  return view;
203
258
  },
204
259
  });
205
260
  }
206
261
 
207
- /** `list`: the board, one card, or the lead's rollup. */
262
+ /** `list`: the board, one card, the lead's rollup, or the archive. */
208
263
  async function read(
209
264
  store: BoardStore,
210
265
  action: BoardAction,
@@ -212,13 +267,32 @@ async function read(
212
267
  args: BoardArgs,
213
268
  now: number,
214
269
  ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
270
+ if (args.archived === true) return readArchive(store, action, me, args, now);
215
271
  const cards = await store.list();
272
+ // Read once: a card file the reader refused is the difference between "not there"
273
+ // and "there, and this build cannot read it".
274
+ const skipped = await store.skipped();
275
+ // A directory listing, not a read: a listing says what it left aside without opening one.
276
+ const archived = await store.archivedCount();
216
277
  if (args.card !== undefined) {
217
278
  const id = normalizeCardId(args.card);
218
279
  if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
219
280
  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 });
281
+ if (!card) {
282
+ // Gone and set aside are different answers, and one read by id tells them apart.
283
+ if ((await store.read(id)) !== undefined) {
284
+ return refusal(action, me, `${cardRef(id)} is archived; pass archived: true to read it.`);
285
+ }
286
+ const unreadable = skipped.find((entry) => entry.id === id);
287
+ return refusal(
288
+ action,
289
+ me,
290
+ unreadable === undefined
291
+ ? `${cardRef(id)} does not exist.`
292
+ : `${cardRef(id)} is there and this build cannot read it (${unreadable.file}: ${unreadable.why}).`,
293
+ );
294
+ }
295
+ return textResult(formatCard(card, indexOf(cards), now), { action, me, card });
222
296
  }
223
297
  const listed = cards.filter(
224
298
  (card) =>
@@ -228,9 +302,65 @@ async function read(
228
302
  // session could not release.
229
303
  (args.mine !== true || holds(card, me)),
230
304
  );
231
- if (args.round !== true) return textResult(formatCards(listed), { action, me, cards: listed });
305
+ // A finished card is history rather than work, and nothing prunes it — this
306
+ // repository's own board is mostly finished cards — so a listing that names no lane
307
+ // leaves them out and says how many. A named lane, `card`, and `round` are unchanged;
308
+ // the rollup counts the whole board, which is what makes it a rollup.
309
+ const hiddenDone = args.status === undefined ? listed.filter((card) => card.status === "done").length : 0;
310
+ const shown = hiddenDone === 0 ? listed : listed.filter((card) => card.status !== "done");
311
+ if (args.round !== true) {
312
+ return textResult(formatBoard(shown, { hiddenDone, archived, skipped }), { action, me, cards: shown });
313
+ }
232
314
  const round = buildRound(cards, now);
233
- return textResult(formatRound(cards, round), { action, me, cards: listed, round });
315
+ // One line under the rollup, because a reader who cannot see a card file should know why,
316
+ // and one for the archive, because a rollup that silently left it out would be a board
317
+ // smaller than the one on disk.
318
+ const notes = [formatSkipped(skipped), archiveNote(archived)].filter((line) => line !== "");
319
+ const text = [formatRound(cards, round), ...notes].join("\n");
320
+ return textResult(text, { action, me, cards: listed, round });
321
+ }
322
+
323
+ /**
324
+ * `list archived: true`: the cards set aside, which no board read opens.
325
+ *
326
+ * A lane and a rollup are answers about the board, so asking for the archive with either
327
+ * is refused rather than quietly ignored: the pile is one thing, and every card in it is
328
+ * finished. A query and `mine` narrow it, because both are questions about a card.
329
+ */
330
+ async function readArchive(
331
+ store: BoardStore,
332
+ action: BoardAction,
333
+ me: BoardDetails["me"],
334
+ args: BoardArgs,
335
+ now: number,
336
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
337
+ const wrong = (["status", "round"] as const).filter((field) => args[field] !== undefined);
338
+ if (wrong.length > 0) {
339
+ return refusal(
340
+ action,
341
+ me,
342
+ `archived does not read ${wrong.join(", ")}: the archive is one pile of finished cards, out of the board.`,
343
+ );
344
+ }
345
+ const pile = await store.archived();
346
+ if (args.card !== undefined) {
347
+ const id = normalizeCardId(args.card);
348
+ if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
349
+ const card = pile.cards.find((entry) => entry.id === id);
350
+ if (!card) {
351
+ const onBoard = (await store.list()).some((entry) => entry.id === id);
352
+ return refusal(
353
+ action,
354
+ me,
355
+ onBoard ? `${cardRef(id)} is on the board, not in the archive.` : `${cardRef(id)} is not in the archive.`,
356
+ );
357
+ }
358
+ return textResult(formatCard(card, indexOf(pile.cards), now), { action, me, card });
359
+ }
360
+ const shown = pile.cards.filter(
361
+ (card) => (args.query === undefined || matchesQuery(card, args.query)) && (args.mine !== true || holds(card, me)),
362
+ );
363
+ return textResult(formatArchive(shown, pile.skipped), { action, me, cards: shown });
234
364
  }
235
365
 
236
366
  /** `add`: the only action that creates a card. */
@@ -263,6 +393,53 @@ async function mutate(
263
393
  return textResult(formatCard(result.card), { action, me, card: result.card });
264
394
  }
265
395
 
396
+ /**
397
+ * `archive`: a finished card leaves the board without being destroyed.
398
+ *
399
+ * One card, or — with no card named — every finished card, because clearing a board is
400
+ * the errand and a lead should not have to run sixty calls to do it. Each move is its own
401
+ * compare-and-set, so a sweep is best-effort: it reports what it actually set aside.
402
+ */
403
+ async function archiveCards(
404
+ store: BoardStore,
405
+ me: BoardDetails["me"],
406
+ args: BoardArgs,
407
+ now: number,
408
+ ): Promise<ReturnType<typeof textResult<BoardDetails>>> {
409
+ if (args.card !== undefined) {
410
+ const id = normalizeCardId(args.card);
411
+ if (id === undefined) return refusal("archive", me, `"${args.card}" is not a card id.`);
412
+ const result = await store.mutate(id, { kind: "archive" }, me, now);
413
+ if (!result.ok) return refusal("archive", me, result.error, result.card);
414
+ // Asked twice is one state, not an error: the card is where the caller wanted it.
415
+ if (!result.changed) return textResult(`${cardRef(id)} is already archived.`, { action: "archive", me });
416
+ return textResult(formatArchived([id]), { action: "archive", me, card: result.card, archived: [id] });
417
+ }
418
+ const finished = (await store.list()).filter((card) => card.status === "done");
419
+ const moved: string[] = [];
420
+ const failed: Array<{ card: string; error: string }> = [];
421
+ for (const card of finished) {
422
+ const result = await store.mutate(card.id, { kind: "archive" }, me, now);
423
+ if (result.ok && result.changed) moved.push(card.id);
424
+ // A sweep is best-effort, and a card that changed underneath it is the one thing it
425
+ // cannot report by silence: "every finished card" must not read as fewer, unexplained.
426
+ else if (!result.ok) failed.push({ card: card.id, error: result.error });
427
+ }
428
+ if (moved.length === 0 && failed.length === 0) {
429
+ return textResult("No finished cards to archive.", { action: "archive", me });
430
+ }
431
+ const text = [
432
+ ...(moved.length === 0 ? [] : [formatArchived(moved)]),
433
+ ...failed.map((entry) => `${cardRef(entry.card)} was not archived (${entry.error})`),
434
+ ].join(" · ");
435
+ return textResult(text, {
436
+ action: "archive",
437
+ me,
438
+ ...(moved.length === 0 ? {} : { archived: moved }),
439
+ ...(failed.length === 0 ? {} : { failed }),
440
+ });
441
+ }
442
+
266
443
  /** `drop`: the card and its notes leave together, so the report is the id. */
267
444
  async function dropCard(
268
445
  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,46 @@ 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";
26
+ import { PING_PATH, SNAPSHOT_PATH } from "../../../lib/board/page-pointer.ts";
30
27
  import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
31
28
  import type { PageView } from "../view/page.ts";
32
29
 
33
- /** The methods this surface serves: reads, writes, and the one deletion. */
34
- const METHODS: readonly string[] = [...PAGE_METHODS, "DELETE"];
35
-
36
30
  /**
37
- * What a credential may do on this page.
31
+ * The methods this surface serves: the two reads and the stream.
38
32
  *
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.
33
+ * `PAGE_METHODS` also carries `POST`, because a page that writes needs it. This one does
34
+ * not, so a write is refused by the envelope — before the token is even read — and the
35
+ * `Allow` header says the truth about what a browser can do here.
43
36
  */
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 };
37
+ const METHODS: readonly string[] = ["GET", "HEAD"];
60
38
 
61
39
  /** Everything the router needs from the running page. */
62
40
  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;
41
+ /** Whether a token is this page's. */
42
+ read: (given: string | null) => boolean;
65
43
  /** The names this server answers to. */
66
44
  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>;
45
+ /** The document for one tab. */
46
+ page: (view: PageView) => Promise<string>;
69
47
  /** The client script. */
70
48
  script: string;
71
- /** The snapshot the page renders, and the body of `/api/board`. */
49
+ /** The snapshot the page renders, and the body of the snapshot route. */
72
50
  view: () => Promise<BoardView>;
73
- /** Apply one write as the serving session. */
74
- write: (request: PageWrite) => Promise<PageResult>;
75
51
  }
76
52
 
77
53
  /**
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.
54
+ * Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values.
80
55
  */
81
56
  export async function handleBoardRequest(
82
57
  ctx: BoardRouterContext,
83
58
  method: string,
84
59
  rawUrl: string,
85
60
  headers: Record<string, string | string[] | undefined> = {},
86
- body = "",
87
61
  ): Promise<ServeResponse> {
88
62
  const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts(), allowed: METHODS });
89
63
  if (envelope) return envelope;
@@ -92,81 +66,24 @@ export async function handleBoardRequest(
92
66
  if (!parsed) return jsonError(400, "Malformed request URL.");
93
67
  const { pathname, search } = parsed;
94
68
 
95
- if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
96
69
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
97
70
  if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: ctx.script };
98
71
 
99
- const authority = ctx.authority(search.get("token"));
100
- if (authority === undefined) return jsonError(403, "Invalid or missing token.");
72
+ if (!ctx.read(search.get("token"))) return jsonError(403, "Invalid or missing token.");
101
73
 
102
- if (pathname === "/api/board") return jsonResponse(200, await ctx.view());
74
+ // The liveness answer, and nothing else: a session deciding whether this page is alive
75
+ // must not pay for the snapshot, which is a store read and a roster read on the same
76
+ // event loop the probe is racing.
77
+ if (pathname === PING_PATH) return jsonResponse(200, { ok: true });
78
+ if (pathname === SNAPSHOT_PATH) return jsonResponse(200, await ctx.view());
103
79
  if (pathname === "/" || pathname === "/index.html") {
104
- const view = viewOf(search.get("view"));
105
- return { status: 200, headers: pageHeaders(), body: await ctx.page(view, authority) };
80
+ return { status: 200, headers: pageHeaders(), body: await ctx.page(viewOf(search.get("view"))) };
106
81
  }
107
82
  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.");
83
+ return jsonError(404, "That page does not exist.");
146
84
  }
147
85
 
148
86
  /** The tab a `?view=` names; anything else is the board. */
149
87
  function viewOf(raw: string | null): PageView {
150
88
  return raw === "team" ? "team" : "board";
151
89
  }
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
- }