@clearlysh/beehaven 0.8.16

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,1301 @@
1
+ /**
2
+ * `beehaven ticket` AND `beehaven board` — the work verbs.
3
+ *
4
+ * ⚠⚠ WHY THESE EXIST WHEN `call` ALREADY WORKED. Measured on `agent_activity`: `ticket-create` 400,
5
+ * `ticket-update` 330, `board-list` 240 — **1,062 calls across tickets and boards, the second-largest
6
+ * domain in the workspace after the canvas** — against ZERO CLI verbs. Every ticket filed during the
7
+ * 2026-09-04 session was `beehaven call ticket-create '{…}'`, not by preference but because there was
8
+ * nothing else, and because the verb that sounds like it (`task`) calls `note-create` and makes a
9
+ * NOTE. One word, two objects, and the wrong one is what you got.
10
+ *
11
+ * ⚠ THIS IS NOT SYMMETRY. There is deliberately no `project` verb: a project IS a folder, `mkdir`
12
+ * already creates one and `ls` / `mv` / `cp -r` / `rm -r` cover the rest, and project traffic is ~85
13
+ * calls against 1,062. A `project` verb would be a second door to `mkdir` at a ninth of the usage —
14
+ * the failure this repo names over and over. Project METADATA stays `call project-update`.
15
+ *
16
+ * ⚠ AND NO `board show`. `cat ~/Sprint 1.board.json` already prints the board WITH its tickets; a
17
+ * second reader would be two doors to one read. What is here is what the filesystem cannot express.
18
+ *
19
+ * ⚠⚠ THE VERBS EXIST TO MAKE THE TRAPS UNTYPEABLE. Each one below is a documented, silent,
20
+ * already-shipped defect in this exact API, and a flag is the only place to kill it for good:
21
+ *
22
+ * · PRIORITY IS 0-3 AND **3 IS MOST URGENT** — the OPPOSITE of the P0/P1 convention people write
23
+ * in tags. A card meant to be top priority, filed as `priority: 0`, becomes the LEAST urgent
24
+ * thing on the board and nothing says so. So this verb takes WORDS ONLY and never a number.
25
+ * · `issueType`, NEVER `type` — the wire uses `type` as the ACTION discriminator, so a payload
26
+ * field of that name is overwritten before the handler sees it. Measured once: every card sent
27
+ * as `type:'epic'` stored `task`, and the whole hierarchy feature was inert while looking like
28
+ * it worked.
29
+ * · `body`, NEVER `description` — an unknown key is stripped by validation, so the card is
30
+ * created with a title and nothing else.
31
+ * · `boardId` IS REQUIRED — `ticket-create` refuses without one, because a ticket with no board
32
+ * is a card you made, were told about, and cannot find.
33
+ */
34
+ /**
35
+ * ⚠⚠ `agentRpc`, NOT `rpcOnce` — a bare `rpcOnce` runs as the ACCOUNT OWNER, not as the signed-in
36
+ * agent. It skips `agent-invoke`, and with it the per-agent gate and the activity log. Measured:
37
+ * two comments on one card seconds apart came back authored `agent:jinri-claude` (via
38
+ * `beehaven call`) and as a raw uid (via this verb) before this was fixed.
39
+ */
40
+ import { agentRpc as rpcOnce } from './workspace.js';
41
+ const c = {
42
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
43
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
44
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
45
+ };
46
+ function getTimeout(values) {
47
+ const t = Number(values?.timeout);
48
+ return Number.isFinite(t) && t > 0 ? t : 30_000;
49
+ }
50
+ /** ⚠ `rpcOnce` returns `{ error }` as a VALUE and never throws — see `rpcFailure` in workspace.ts. */
51
+ function failure(res) {
52
+ if (!res || typeof res !== 'object')
53
+ return null;
54
+ if (typeof res.error === 'string' && res.error)
55
+ return res.error;
56
+ if (res.ok === false)
57
+ return String(res.message ?? res.code ?? 'the workspace refused the call');
58
+ return null;
59
+ }
60
+ function die(msg, hint) {
61
+ console.error(` Error: ${msg}`);
62
+ if (hint)
63
+ console.error(c.dim(` ${hint}`));
64
+ process.exit(1);
65
+ }
66
+ /**
67
+ * ⚠⚠ WORDS IN, NUMBER OUT — AND THE SCALE IS INVERTED FROM WHAT PEOPLE WRITE.
68
+ *
69
+ * `0 Low · 1 Medium · 2 High · 3 Urgent`. Anyone who has used a tracker writes P0 for "drop
70
+ * everything", so a number reaching this API unmediated is a coin flip on the most consequential
71
+ * field a card has (it drives the SLA: 3=1h, 2=4h, 1=24h, 0=72h). The verb therefore refuses a
72
+ * bare number rather than passing one through — a refusal is recoverable, a silently de-prioritised
73
+ * card is not.
74
+ */
75
+ const PRIORITY = { low: 0, medium: 1, normal: 1, high: 2, urgent: 3, critical: 3 };
76
+ /**
77
+ * ⚠⚠ THE READ SIDE AND THE WRITE SIDE MUST SHARE ONE TABLE, OR THE VERB CONTRADICTS ITSELF.
78
+ *
79
+ * `ticket show` printed `priority 3` — handing back the raw number that `resolvePriority`
80
+ * REFUSES on the way in, with the inversion explained at length. So the verb whose stated reason
81
+ * for existing is "make the 0-3 trap untypeable" was the one surface re-exposing it: a reader who
82
+ * files `high` and later sees `priority 2` has every reason to read that as P2, i.e. lowish —
83
+ * the precise misreading the guard was built to prevent.
84
+ *
85
+ * ⚠ DERIVED FROM `PRIORITY`, NOT A SECOND LITERAL LIST. A hand-written `['low','medium',…]` beside
86
+ * the map is two tables for one scale, and the day someone renumbers one the other keeps printing
87
+ * the old word with nothing to notice.
88
+ *
89
+ * ⚠ `normal` AND `critical` ARE ALIASES ON THE WAY IN AND MUST NOT WIN ON THE WAY OUT. They share
90
+ * numbers with `medium` and `urgent`, so the label is taken from the FIRST word that maps to each
91
+ * value — the canonical four the help text advertises.
92
+ */
93
+ export const PRIORITY_LABELS = (() => {
94
+ const out = [];
95
+ for (const [word, n] of Object.entries(PRIORITY))
96
+ if (out[n] === undefined)
97
+ out[n] = word;
98
+ return out;
99
+ })();
100
+ /**
101
+ * The word for a stored priority.
102
+ *
103
+ * ⚠ AN OFF-SCALE VALUE IS SHOWN, NOT GUESSED. Clamping 7 to `urgent` would invent a fact; printing
104
+ * `7 (off-scale — 0 low … 3 urgent)` says exactly what is stored and what the scale is, which is
105
+ * the one thing a reader of a surprising number needs.
106
+ */
107
+ export function priorityLabel(v) {
108
+ const n = typeof v === 'number' ? v : Number(String(v ?? '').trim());
109
+ const word = Number.isInteger(n) ? PRIORITY_LABELS[n] : undefined;
110
+ if (word)
111
+ return word;
112
+ return `${v} (off-scale — 0 ${PRIORITY_LABELS[0]} … ${PRIORITY_LABELS.length - 1} ${PRIORITY_LABELS[PRIORITY_LABELS.length - 1]})`;
113
+ }
114
+ /**
115
+ * The date for a stored `dueAt`.
116
+ *
117
+ * ⚠ `0` IS "NO DUE DATE", AND PRINTING IT RAW READS AS A DATE IN 1970 — or, worse, as a field the
118
+ * reader has to decode. Every card without a due date carried `dueAt 0`.
119
+ *
120
+ * ⚠ AN UNPARSEABLE VALUE IS RETURNED AS ITSELF. Guessing between seconds and milliseconds would
121
+ * put a card's deadline half a century out with nothing to say so; showing the raw value is
122
+ * recoverable.
123
+ */
124
+ export function dueLabel(v) {
125
+ if (v === 0 || v === '0' || v === undefined || v === null || v === '')
126
+ return 'none';
127
+ const n = typeof v === 'number' ? v : Number(String(v).trim());
128
+ if (!Number.isFinite(n))
129
+ return String(v);
130
+ const d = new Date(n);
131
+ if (Number.isNaN(d.getTime()))
132
+ return String(v);
133
+ return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
134
+ }
135
+ function resolvePriority(v) {
136
+ if (v === undefined || v === null || v === '')
137
+ return undefined;
138
+ const s = String(v).trim().toLowerCase();
139
+ if (s in PRIORITY)
140
+ return PRIORITY[s];
141
+ if (/^\d+$/.test(s)) {
142
+ die(`--priority takes a word, not a number (you passed ${s}).`, 'The scale is inverted from P0/P1: 0 is LOW and 3 is URGENT. Use low | medium | high | urgent.');
143
+ }
144
+ die(`unknown priority "${v}"`, 'Use one of: low, medium, high, urgent.');
145
+ }
146
+ const STATUSES = ['todo', 'in-progress', 'in-review', 'done'];
147
+ const TYPES = ['epic', 'story', 'task', 'bug', 'subtask'];
148
+ const SPRINT_STATES = ['planned', 'active', 'completed'];
149
+ const REPORT_KINDS = ['summary', 'velocity', 'burndown', 'cycle-time'];
150
+ /**
151
+ * ⚠⚠ `--due` MUST REACH THE WIRE AS A NUMBER, AND THE TWO VERBS DISAGREED ABOUT THAT — CBH2-109.
152
+ *
153
+ * `ticket-create` writes `due_at: input.dueAt || 0` with no coercion, so a string goes STRAIGHT
154
+ * INTO the column. `ticket-update` writes it only `if (typeof input.updates.dueAt === 'number')`,
155
+ * so a string is silently dropped. One flag, two handlers, two different wrong answers:
156
+ *
157
+ * beehaven call ticket-create '{…,"dueAt":"2026-10-01"}'
158
+ * → SELECT due_at, typeof(due_at) → "2026-10-01" | text (measured, 2026-09-06)
159
+ *
160
+ * The column is epoch milliseconds everywhere else — `dueLabel` above renders it as one and
161
+ * `response_due_at` is computed in ms — so a text value there is a deadline no comparison can
162
+ * see. This verb parses ONCE, on the way in, for both create and edit.
163
+ *
164
+ * ⚠ A BARE NUMBER IS TAKEN AS EPOCH MS, NOT GUESSED BETWEEN SECONDS AND MILLISECONDS. `dueLabel`
165
+ * already refuses to guess on the way out for the same reason: a factor-of-1000 mistake puts a
166
+ * deadline half a century away and looks like a plausible date.
167
+ *
168
+ * ⚠ AN UNPARSEABLE VALUE IS REFUSED, NOT PASSED THROUGH. Passing it through is exactly what
169
+ * produced the text row above.
170
+ */
171
+ export function resolveDue(v) {
172
+ if (v === undefined || v === null || v === '')
173
+ return undefined;
174
+ const s = String(v).trim();
175
+ if (/^\d+$/.test(s))
176
+ return Number(s);
177
+ const ms = Date.parse(s);
178
+ if (Number.isFinite(ms))
179
+ return ms;
180
+ die(`--due "${s}" is not a date`, 'Use an ISO date (2026-10-01), a full timestamp (2026-10-01T09:00:00Z), or epoch milliseconds.');
181
+ }
182
+ /**
183
+ * ⚠⚠ THE CARD LIST WAS CAPPED AT 100 IN THE CLIENT WITH NOTHING SAID — CBH2-112.
184
+ *
185
+ * `rows.slice(0, 100)` printed a hundred cards and then a truthful-looking `N card(s)` count, so
186
+ * a 300-card board was INDISTINGUISHABLE from a 100-card board. The server was never the
187
+ * constraint: `ticket-list` defaults to 100 and caps at 500, and takes a `limit`.
188
+ *
189
+ * ⚠ THE CEILING IS CLAMPED AND ANNOUNCED, NOT SILENTLY APPLIED. The server does
190
+ * `Math.min(input.limit ?? 100, 500)` with no notice of its own, so asking for 900 and being
191
+ * handed 500 is the original defect one layer down. Every clamp prints.
192
+ */
193
+ const LIST_DEFAULT = 100;
194
+ /** The server's own ceiling — `Math.min(input.limit ?? 100, 500)` in `ticketList`. */
195
+ export const LIST_MAX = 500;
196
+ export function resolveLimit(v) {
197
+ if (v === undefined || v === null || v === '')
198
+ return { limit: LIST_DEFAULT, clamped: false };
199
+ const s = String(v).trim();
200
+ const n = Number(s);
201
+ if (!Number.isInteger(n) || n < 1) {
202
+ die(`--limit takes a whole number of cards (you passed ${s}).`, `Between 1 and ${LIST_MAX}.`);
203
+ }
204
+ return n > LIST_MAX ? { limit: LIST_MAX, clamped: true } : { limit: n, clamped: false };
205
+ }
206
+ /**
207
+ * A sprint, by id OR by name.
208
+ *
209
+ * ⚠ NAMES, BECAUSE A SPRINT ID IS A UUID AND NOTHING ELSE EVER SHOWS YOU ONE. `sprint-create`
210
+ * returns `{ id, name }` and `sprint-list` prints both, but the thing a person or an agent says
211
+ * out loud is "Sprint 3". Requiring the uuid is the trap `resolveBoard` exists to close for
212
+ * boards, one object over.
213
+ *
214
+ * ⚠ AMBIGUITY IS REFUSED, NEVER GUESSED. Auto-numbering means two boards routinely both hold a
215
+ * "Sprint 1"; picking one would start or DELETE the wrong sprint. `--board` narrows it.
216
+ */
217
+ async function resolveSprint(ref, values) {
218
+ const t = getTimeout(values);
219
+ const wanted = String(ref ?? '').trim();
220
+ if (!wanted)
221
+ die('a sprint is required', 'beehaven sprint list — to see the names');
222
+ const board = values.board ? await resolveBoard(values) : null;
223
+ const res = await rpcOnce('sprint-list', { ...(board ? { boardId: board.id } : {}) }, t);
224
+ const f = failure(res);
225
+ if (f)
226
+ die(f);
227
+ const rows = res?.sprints ?? [];
228
+ const byId = rows.find((s) => String(s.id) === wanted);
229
+ if (byId)
230
+ return { id: String(byId.id), name: String(byId.name ?? ''), state: String(byId.state ?? '') };
231
+ const named = rows.filter((s) => String(s.name ?? '').toLowerCase() === wanted.toLowerCase());
232
+ if (named.length === 1) {
233
+ return { id: String(named[0].id), name: String(named[0].name ?? ''), state: String(named[0].state ?? '') };
234
+ }
235
+ if (named.length > 1) {
236
+ die(`"${wanted}" names ${named.length} sprints`, 'Narrow it with --board <board>, or pass the sprint id (beehaven sprint list).');
237
+ }
238
+ const names = rows.map((s) => String(s.name ?? '')).filter(Boolean);
239
+ die(`no sprint called "${wanted}"`, names.length
240
+ ? `Sprints: ${names.slice(0, 10).join(', ')}${names.length > 10 ? ` … and ${names.length - 10} more` : ''}`
241
+ : 'There are no sprints yet — beehaven sprint create "Sprint 1" --board <board>');
242
+ }
243
+ /**
244
+ * ⚠⚠ `sprint-plan` TAKES ROW IDS AND REPORTS SUCCESS FOR A KEY IT NEVER MOVED. MEASURED:
245
+ *
246
+ * beehaven call sprint-plan '{"sprintId":"a53e…","add":["CBH2-107"]}'
247
+ * → { ok: true, moved: 1 } …and `SELECT sprint_id` on that card is still ''.
248
+ *
249
+ * The handler does `this.updateRow('tickets', id, …)` inside a `try`, increments its counter
250
+ * unconditionally, and swallows the miss. A key is the ONLY address anybody quotes — it is what
251
+ * `ticket-create` returns and what this file's own receipts print — so the natural call is the
252
+ * one that silently does nothing while answering `ok`.
253
+ *
254
+ * So this verb resolves every reference to a ROW id first and REFUSES on one it cannot find,
255
+ * naming it. A planning verb that reports moving work it did not move is worse than one that
256
+ * refuses.
257
+ *
258
+ * ⚠ `ticket-get` PER CARD, not a list scan: it resolves a key or a row id already (`resolveTicketRef`),
259
+ * so one call answers both shapes and cannot disagree with the server about what a key means.
260
+ */
261
+ async function resolveCardIds(refs, values) {
262
+ const t = getTimeout(values);
263
+ const out = [];
264
+ for (const ref of refs) {
265
+ const res = await rpcOnce('ticket-get', { id: ref }, t);
266
+ const f = failure(res);
267
+ if (f)
268
+ die(`${ref}: ${f}`, 'Cards are addressed by key (CLR-42) or row id — beehaven ticket list');
269
+ const id = String(res?.id ?? '');
270
+ if (!id)
271
+ die(`no card called "${ref}"`, 'beehaven ticket list — to see the keys');
272
+ out.push({ id, key: String(res?.key ?? ref) });
273
+ }
274
+ return out;
275
+ }
276
+ /**
277
+ * ⚠⚠ THE ONE BOARD-RESOLUTION RULE — oldest non-archived board, else create one.
278
+ *
279
+ * `ticket-create` refuses a null `boardId`, and four client call sites each invented their own
280
+ * answer to that before this rule was written down (three of them sent nothing and would have
281
+ * broken silently in production). This is that rule, in the CLI, once.
282
+ *
283
+ * ⚠ IT PREFERS THE OLDEST, not the newest. A workspace's first board is the one people mean by
284
+ * "the board"; the newest is whatever some script made last.
285
+ */
286
+ /**
287
+ * ⚠⚠ `opts.named` IS WHAT KEEPS A DESTRUCTIVE VERB OFF THE FALLBACK. With no `--board` this
288
+ * function picks the OLDEST board in the workspace — exactly right for filing a card, and
289
+ * catastrophic for `board delete`, which would then destroy a board nobody named. A caller that
290
+ * cannot survive a guess passes `named: true` and gets a refusal instead.
291
+ *
292
+ * ⚠ `includeArchived` EXISTS FOR THE SAME VERB. Archiving is how boards are retired today, so the
293
+ * boards most worth deleting are precisely the ones the default filter hides — a delete that
294
+ * cannot see them can only ever grow the collection.
295
+ */
296
+ async function resolveBoard(values, opts = {}) {
297
+ const t = getTimeout(values);
298
+ const wanted = typeof values.board === 'string' ? values.board.trim() : '';
299
+ if (opts.named && !wanted) {
300
+ die('name the board', 'beehaven board list — then pass its title or id.');
301
+ }
302
+ const list = await rpcOnce('board-list', opts.includeArchived ? { includeArchived: true } : {}, t);
303
+ const f = failure(list);
304
+ if (f)
305
+ die(f);
306
+ const isArchived = (b) => String(b?.is_archived ?? '') === 'true';
307
+ const boards = (list?.boards ?? list?.rows ?? list?.items ?? [])
308
+ .filter((b) => opts.includeArchived || !isArchived(b));
309
+ if (wanted) {
310
+ const hit = boards.find((b) => String(b.id) === wanted)
311
+ ?? boards.find((b) => String(b.title ?? '').toLowerCase() === wanted.toLowerCase());
312
+ if (!hit) {
313
+ /**
314
+ * ⚠ CAPPED. This workspace has 60+ boards and the uncapped version printed every title on
315
+ * one line — a "help" message longer than the terminal, which is the same as no help. Ten
316
+ * names plus a count is readable; `board list` is where the full set belongs.
317
+ */
318
+ const names = boards.map((b) => String(b.title ?? '')).filter(Boolean);
319
+ const shown = names.slice(0, 10).join(', ');
320
+ die(`no board called "${wanted}"`, names.length
321
+ ? `Boards: ${shown}${names.length > 10 ? ` … and ${names.length - 10} more (beehaven board list)` : ''}`
322
+ : 'There are no boards yet — one will be made for you if you omit --board.');
323
+ }
324
+ return { id: String(hit.id), title: String(hit.title ?? ''), archived: isArchived(hit) };
325
+ }
326
+ if (boards.length) {
327
+ /**
328
+ * ⚠ OLDEST, NOT NEWEST — a workspace's first board is what people mean by "the board"; the
329
+ * newest is whatever a script made last. Same rule the four client call sites follow.
330
+ *
331
+ * ⚠ AND THE CALLER IS TOLD WHICH. With no project context this is a guess across every board
332
+ * in the workspace, so the card's receipt names the board (`Filed SOU-8 · Sources`) — a card
333
+ * filed somewhere you did not choose and were not told about is the invisible-ticket failure
334
+ * `ticket-create` started refusing null boards to prevent.
335
+ */
336
+ const oldest = boards.slice().sort((a, b) => Number(a.created_at ?? 0) - Number(b.created_at ?? 0))[0];
337
+ return { id: String(oldest.id), title: String(oldest.title ?? ''), archived: isArchived(oldest) };
338
+ }
339
+ // ⚠ CREATED, NOT REFUSED. Nobody sets out to make an empty board — they set out to write down a
340
+ // piece of work, and a refusal here would make the first ticket in a workspace impossible.
341
+ const made = await rpcOnce('board-create', { title: 'Backlog', personal: true }, t);
342
+ const mf = failure(made);
343
+ if (mf)
344
+ die(mf, 'Could not create a board to put this ticket on.');
345
+ return { id: String(made?.id ?? made?.board?.id ?? ''), title: 'Backlog', archived: false };
346
+ }
347
+ export async function cmdTicket(sub, rest, values) {
348
+ const t = getTimeout(values);
349
+ if (!sub || sub === 'help') {
350
+ console.log(`
351
+ ${c.cyan('beehaven ticket')} — cards on a board
352
+
353
+ ticket create "<title>" Open a card (--board --body --type --priority --status --assignee
354
+ --due --allow-duplicate)
355
+ ticket list Cards on a board (--board --status --text --key --assignee --limit, max ${LIST_MAX})
356
+ ticket move <key> [status] ${STATUSES.join(' | ')} (--board to move it to another board)
357
+ ticket edit <key> Change a card (--title --body --type --priority --assignee --due
358
+ --points --epic --parent --sprint --resolution)
359
+ ticket show <key> One card
360
+ ticket comment <key> "…" Leave a note (--reply <id>)
361
+ ticket comments <key> Read the notes (--all for resolved)
362
+ ticket delete <key> Destroy a card (shows it; needs --yes to go through)
363
+
364
+ ${c.dim(`--priority takes a WORD: low | medium | high | urgent.`)}
365
+ ${c.dim(`--type takes: ${TYPES.join(' | ')}.`)}
366
+ ${c.dim('WHERE a card is lives on `move` (status and board); WHAT it says lives on `edit`.')}
367
+ ${c.dim('Read a whole board including its cards with: cat "~/<Board>.board.json"')}
368
+ `);
369
+ return;
370
+ }
371
+ if (sub === 'create') {
372
+ const title = rest.join(' ').trim();
373
+ if (!title)
374
+ die('a title is required', 'beehaven ticket create "Fix the login redirect"');
375
+ const board = await resolveBoard(values);
376
+ const issueType = typeof values.type === 'string' ? values.type.trim().toLowerCase() : '';
377
+ if (issueType && !TYPES.includes(issueType))
378
+ die(`unknown type "${issueType}"`, `Use one of: ${TYPES.join(', ')}.`);
379
+ const status = typeof values.status === 'string' ? values.status.trim().toLowerCase() : '';
380
+ if (status && !STATUSES.includes(status))
381
+ die(`unknown status "${status}"`, `Use one of: ${STATUSES.join(', ')}.`);
382
+ const priority = resolvePriority(values.priority);
383
+ const due = resolveDue(values.due);
384
+ const res = await rpcOnce('ticket-create', {
385
+ boardId: board.id,
386
+ title,
387
+ // ⚠ `body`, NEVER `description` — an unknown key is stripped and the card loses its detail.
388
+ ...(values.body ? { body: String(values.body) } : {}),
389
+ // ⚠ `issueType`, NEVER `type` — `type` is the wire's action discriminator.
390
+ ...(issueType ? { issueType } : {}),
391
+ ...(status ? { status } : {}),
392
+ ...(priority !== undefined ? { priority } : {}),
393
+ ...(values.assignee ? { assignee: String(values.assignee) } : {}),
394
+ // ⚠ A NUMBER, NEVER THE RAW STRING — see `resolveDue`. `ticket-create` writes this column
395
+ // with no coercion at all, so `--due 2026-10-01` used to store TEXT in an epoch-ms column.
396
+ ...(due !== undefined ? { dueAt: due } : {}),
397
+ /**
398
+ * ⚠⚠ THE FLAG THE NEXT FEW LINES ADVERTISE HAS TO EXIST — CBH2-110. The duplicate branch
399
+ * below printed *"Pass --allow-duplicate if you meant a second card"* while `parseArgs`
400
+ * had no such option and FATALS on an undeclared one, so following the CLI's own printed
401
+ * instruction exited 1. Printing an instruction a caller cannot carry out is worse than
402
+ * printing nothing; the server has supported `allowDuplicate` throughout.
403
+ */
404
+ ...(values['allow-duplicate'] ? { allowDuplicate: true } : {}),
405
+ }, t);
406
+ const f = failure(res);
407
+ if (f)
408
+ die(f);
409
+ if (values.json) {
410
+ console.log(JSON.stringify(res, null, 2));
411
+ return;
412
+ }
413
+ /**
414
+ * ⚠ SAY THE BOARD, NOT ONLY THE KEY. A ticket's key records where it was BORN and never
415
+ * follows it between boards, so `ticket-create` returns the CURRENT board beside the key
416
+ * precisely so a caller can print both without a second lookup. Dropping it here would be
417
+ * throwing away the mitigation.
418
+ */
419
+ const key = String(res?.key ?? '');
420
+ const where = String(res?.board ?? board.title ?? '');
421
+ if (res?.duplicate) {
422
+ // ⚠ A duplicate is `ok: true` — reporting it as a fresh create is how you get two people
423
+ // working the same card believing they each opened one.
424
+ console.log(` ${c.yellow('Already open')}: ${c.cyan(key)}${where ? ` · ${where}` : ''}`);
425
+ console.log(c.dim(' Pass --allow-duplicate if you meant a second card.'));
426
+ return;
427
+ }
428
+ console.log(` Filed ${c.cyan(key)}${where ? ` · ${where}` : ''}`);
429
+ if (res?.priorityCoerced) {
430
+ const p = res.priorityCoerced;
431
+ console.log(c.dim(` priority ${p.sent} stored as ${p.stored} — ${p.scale}`));
432
+ }
433
+ return;
434
+ }
435
+ if (sub === 'list') {
436
+ const board = values.board ? await resolveBoard(values) : null;
437
+ const { limit, clamped } = resolveLimit(values.limit);
438
+ const res = await rpcOnce('ticket-list', {
439
+ ...(board ? { boardId: board.id } : {}),
440
+ ...(values.status ? { status: String(values.status) } : {}),
441
+ ...(values.text ? { text: String(values.text) } : {}),
442
+ ...(values.key ? { key: String(values.key) } : {}),
443
+ ...(values.assignee ? { assignee: String(values.assignee) } : {}),
444
+ ...(values.cursor ? { cursor: String(values.cursor) } : {}),
445
+ ...(values.fields ? { fields: String(values.fields).split(',').map((s) => s.trim()).filter(Boolean) } : {}),
446
+ ...(values.summary ? { summary: true } : {}),
447
+ ...(values['count-only'] || values.countOnly ? { countOnly: true } : {}),
448
+ limit,
449
+ }, t);
450
+ const f = failure(res);
451
+ if (f)
452
+ die(f);
453
+ if (values.json) {
454
+ console.log(JSON.stringify(res, null, 2));
455
+ return;
456
+ }
457
+ if (res?.summary) {
458
+ console.log(JSON.stringify(res.summary, null, 2));
459
+ if (res.nextCursor)
460
+ console.log(c.dim(` next cursor: ${res.nextCursor}`));
461
+ return;
462
+ }
463
+ if (values['count-only'] || values.countOnly) {
464
+ console.log(` ${res?.count ?? 0} card(s)`);
465
+ return;
466
+ }
467
+ const rows = res?.tickets ?? res?.rows ?? res?.items ?? [];
468
+ if (!rows.length) {
469
+ console.log(' No cards.');
470
+ return;
471
+ }
472
+ console.log('');
473
+ /**
474
+ * ⚠⚠ EVERY RETURNED ROW IS PRINTED. The old `rows.slice(0, 100)` was a SECOND, invisible cap
475
+ * underneath the server's — it printed a hundred cards and then a `N card(s)` count that
476
+ * looked like the whole answer. The bound is the one the caller asked for, and it is stated.
477
+ */
478
+ for (const r of rows) {
479
+ const key = String(r.key ?? '').padEnd(9);
480
+ const st = String(r.status ?? '').padEnd(12);
481
+ console.log(` ${c.cyan(key)} ${c.dim(st)} ${String(r.title ?? '')}`);
482
+ }
483
+ console.log(c.dim(`\n ${rows.length} card(s)`));
484
+ if (clamped) {
485
+ console.log(c.dim(` --limit was clamped to ${LIST_MAX} — ticket-list will not return more in one call.`));
486
+ }
487
+ /**
488
+ * ⚠⚠ A FULL PAGE IS NOT AN ANSWER, AND SAYING SO IS THE WHOLE FIX. `rows.length === limit`
489
+ * means the list stopped because it ran out of budget, not because it ran out of cards — the
490
+ * one state the old output could not distinguish from a complete board.
491
+ *
492
+ * ⚠ THE HINT MUST BE FOLLOWABLE. At the ceiling there is no bigger `--limit` to suggest, so it
493
+ * names the filters instead; below the ceiling it names the flag.
494
+ */
495
+ if (res?.nextCursor)
496
+ console.log(c.dim(` next cursor: ${res.nextCursor}`));
497
+ if (rows.length >= limit) {
498
+ console.log(c.dim(limit >= LIST_MAX
499
+ ? ` that is the whole page — ${LIST_MAX} is ticket-list's ceiling, so narrow with --board or --status to see the rest.`
500
+ : ` that is the whole page — there may be more. Raise it with --limit (max ${LIST_MAX}).`));
501
+ }
502
+ return;
503
+ }
504
+ /**
505
+ * ⚠⚠ ONE VERB, TWO AXES — AND THERE IS DELIBERATELY NO `board move`.
506
+ *
507
+ * A column and a board are both "where the card is", and the thing that MOVES is the TICKET.
508
+ * `board move` would name the wrong object — it reads as moving the board. `mv` in a shell
509
+ * relocates and renames through one verb for the same reason.
510
+ *
511
+ * ticket move <key> done column
512
+ * ticket move <key> --board "Sprint 2" board
513
+ * ticket move <key> done --board "…" both
514
+ */
515
+ if (sub === 'move') {
516
+ const key = rest[0];
517
+ const status = (rest[1] ?? '').trim().toLowerCase();
518
+ const toBoard = typeof values.board === 'string' && values.board.trim() ? values.board.trim() : '';
519
+ if (!key)
520
+ die('usage: beehaven ticket move <key> [<status>] [--board <board>]', `Statuses: ${STATUSES.join(' | ')}`);
521
+ if (!status && !toBoard) {
522
+ die('nothing to move it to', `Pass a status (${STATUSES.join(' | ')}) or --board <board>.`);
523
+ }
524
+ if (status && !STATUSES.includes(status))
525
+ die(`unknown status "${status}"`, `Use one of: ${STATUSES.join(', ')}.`);
526
+ const board = toBoard ? await resolveBoard({ ...values, board: toBoard }) : null;
527
+ // ⚠ `updates` IS A NESTED PARTIAL — a flat `{ id, status }` is silently ignored by the handler.
528
+ const res = await rpcOnce('ticket-update', {
529
+ id: key,
530
+ updates: { ...(status ? { status } : {}), ...(board ? { boardId: board.id } : {}) },
531
+ }, t);
532
+ const f = failure(res);
533
+ if (f)
534
+ die(f);
535
+ if (values.json) {
536
+ console.log(JSON.stringify(res, null, 2));
537
+ return;
538
+ }
539
+ const parts = [status, board ? `board ${board.title}` : ''].filter(Boolean);
540
+ console.log(` ${c.cyan(key)} → ${parts.join(' · ')}`);
541
+ /**
542
+ * ⚠⚠ THE KEY DOES NOT FOLLOW THE CARD, AND THIS IS THE ONLY PLACE ANYONE SEES THAT.
543
+ *
544
+ * `tickets.key` forbids re-keying — a key that comes back pointing at different work turns
545
+ * every historical mention of it into a quiet lie — so the PREFIX records where a card was
546
+ * BORN, never where it lives now. Verified: MA-1 moved from "MoveTest A" to "MoveTest B" and
547
+ * stayed MA-1. Silently, that reads as the move having failed.
548
+ */
549
+ if (board) {
550
+ console.log(c.dim(` keeps the key ${key} — a key records where a card was created, not where it lives`));
551
+ }
552
+ return;
553
+ }
554
+ if (sub === 'show') {
555
+ const key = rest[0];
556
+ if (!key)
557
+ die('usage: beehaven ticket show <key>');
558
+ /**
559
+ * ⚠⚠ `ticket-get`, NOT A CLIENT-SIDE FILTER OVER `ticket-list`. This verb originally listed
560
+ * EVERY card in the workspace and picked one out in JS, because on 2026-09-04 there was no
561
+ * single-card read at all — `ticket-list` was the only way in. Writing that workaround is what
562
+ * revealed the verb was MISSING rather than badly named, and `ticket-get` was added for it.
563
+ *
564
+ * ⚠ The server also returns the card's CURRENT board, which a client-side filter had no cheap
565
+ * way to resolve — a key records where a card was born and never follows it between boards.
566
+ */
567
+ const res = await rpcOnce('ticket-get', { id: key }, t);
568
+ const f = failure(res);
569
+ if (f)
570
+ die(f);
571
+ if (values.json) {
572
+ console.log(JSON.stringify(res, null, 2));
573
+ return;
574
+ }
575
+ console.log('');
576
+ /**
577
+ * ⚠ `issueType`, never `type` — the transport envelope's `type: 'ack'` shadows it.
578
+ *
579
+ * ⚠⚠ TWO FIELDS ARE STORED AS NUMBERS AND MEAN WORDS. `String(v)` was correct for the other
580
+ * seven and silently wrong for these two: `priority 3` re-exposes the inverted scale this verb
581
+ * exists to hide, and `dueAt 0` shows a sentinel where "none" is meant. The renderers are
582
+ * exported so the mapping is asserted by running it, not by grepping this loop.
583
+ */
584
+ const RENDER = { priority: priorityLabel, dueAt: dueLabel };
585
+ for (const k of ['key', 'title', 'board', 'status', 'issueType', 'priority', 'assignee', 'dueAt', 'body']) {
586
+ const v = res[k];
587
+ if (v === undefined || v === null || v === '')
588
+ continue;
589
+ console.log(` ${c.dim(k.padEnd(10))} ${RENDER[k] ? RENDER[k](v) : String(v)}`);
590
+ }
591
+ console.log('');
592
+ return;
593
+ }
594
+ /**
595
+ * ⚠⚠ A CARD COULD BE FILED AND NEVER REMOVED — CBH2-141. `ticket-delete` has been registered and
596
+ * allowlisted throughout; there was simply no verb, so the CLI could create cards forever and
597
+ * take none back. Same shape as CBH2-111 for boards, one object down.
598
+ *
599
+ * ⚠⚠ IT WAS LEFT OUT OF CBH2-111 RATHER THAN SWEPT IN, because the safe shape was a DECISION and
600
+ * the action's one-line description ("Delete a board card (ticket) by id.") answers none of it.
601
+ * The four facts below were established by CALLING it against a real workspace on 2026-09-06,
602
+ * not read off the schema — and one of them contradicts what a reader would assume:
603
+ *
604
+ * 1. IT RESOLVES A KEY. `{"id":"CBH2-146"}` deleted the card and returned BOTH `id` and `key`.
605
+ * So this verb takes the same reference every other `ticket` subcommand takes, and does NOT
606
+ * have the row-id-only trap CBH2-137 records for `sprint-plan`.
607
+ * 2. IT IS A HARD DELETE. The row is gone from `tickets` — verified by SQL, not inferred from
608
+ * the reply. There is no archive flag, no trash, and nothing to restore from.
609
+ * 3. IT IS NOT IDEMPOTENT. A second delete answers `ok:false, code:'not-found'`, identical to
610
+ * a typo'd key. So a retry after a timeout cannot be distinguished from a wrong key.
611
+ * 4. ⚠⚠ COMMENTS ARE ORPHANED, NOT REMOVED. A comment left on a deleted card survives in
612
+ * `ticket-comments`, addressed to a ticket id that resolves to nothing — unreachable and
613
+ * permanent. Filed separately; the preview states it because the person about to destroy
614
+ * the card is the only one who can decide whether that matters.
615
+ *
616
+ * ⚠⚠ SO IT PREVIEWS BY DEFAULT AND NEEDS `--yes`, WHICH IS NOT THE `board delete` SHAPE. Board
617
+ * deletion is safe to fire because the SERVER refuses while any card references it; nothing
618
+ * equivalent protects a card, and (2)+(3) together mean a mistake is unrecoverable AND
619
+ * indistinguishable from a typo afterwards. The same dry-run-by-default rule the workspace's own
620
+ * destructive operator verbs follow (`thought-vacuum`, `notes-repair-timestamps`).
621
+ *
622
+ * ⚠ THE PREVIEW SHOWS THE CARD, IT DOES NOT MERELY REFUSE. A key is opaque — `CBH2-146` and
623
+ * `CBH2-147` differ by one character and name different work — so the one thing a confirmation
624
+ * must do here is prove the key names what the caller thinks it does. A bare "pass --yes" makes
625
+ * the second attempt exactly as blind as the first.
626
+ */
627
+ if (sub === 'delete') {
628
+ const key = rest[0];
629
+ if (!key)
630
+ die('usage: beehaven ticket delete <key> --yes', 'The key is the one `ticket list` prints.');
631
+ /**
632
+ * ⚠ RESOLVED FIRST, ON BOTH PATHS. Without this, `--yes` on a mistyped key reaches the server
633
+ * and comes back `not-found` — the same answer a card someone else already deleted gives, and
634
+ * neither tells you which happened. Reading it first turns that into "no such card" BEFORE
635
+ * anything is destroyed.
636
+ */
637
+ const got = await rpcOnce('ticket-get', { id: key }, t);
638
+ const gf = failure(got);
639
+ if (gf)
640
+ die(gf, `Is that the key \`ticket list\` prints? Nothing was deleted.`);
641
+ const label = `${c.cyan(String(got.key ?? key))} ${String(got.title ?? '')}`.trim();
642
+ const board = got.board ? ` ${c.dim(`board ${got.board}`)}` : '';
643
+ if (!values.yes) {
644
+ console.log('');
645
+ console.log(` Would delete ${label}${board}`);
646
+ console.log('');
647
+ console.log(` ${c.yellow('This is permanent')} — the row is destroyed, not archived, and there is no restore.`);
648
+ console.log(` ${c.dim('Its comments are NOT removed with it; they survive addressed to a card that no longer exists.')}`);
649
+ console.log('');
650
+ console.log(` ${c.dim(`Go ahead with: beehaven ticket delete ${got.key ?? key} --yes`)}`);
651
+ console.log('');
652
+ process.exit(1);
653
+ }
654
+ const res = await rpcOnce('ticket-delete', { id: key }, t);
655
+ const f = failure(res);
656
+ /**
657
+ * ⚠ A `not-found` HERE MEANS SOMETHING ELSE THAN IT DID ABOVE. The card resolved moments ago,
658
+ * so it was deleted in between — worth saying, because the bare server text reads as a typo.
659
+ */
660
+ if (f)
661
+ die(f, `It resolved a moment ago, so it was removed between the read and the delete.`);
662
+ if (values.json) {
663
+ console.log(JSON.stringify(res, null, 2));
664
+ return;
665
+ }
666
+ console.log(` Deleted ${label}${board}`);
667
+ return;
668
+ }
669
+ /**
670
+ * ⚠⚠ A CARD COULD BE CREATED AND NEVER CHANGED — CBH2-109.
671
+ *
672
+ * `ticket move` was the CLI's only update path and it sends `{ status?, boardId? }` and nothing
673
+ * else, so `--body`, `--priority`, `--assignee`, `--due` and `--type` were settable ONLY at
674
+ * create time and points / epic / parent had no flag at all. The web has full edit; the server
675
+ * has accepted all of it throughout (`UPDATABLE` in `ticketUpdate`). This was a CLI-side hole.
676
+ *
677
+ * ⚠⚠ IT DELIBERATELY TAKES NEITHER `--status` NOR `--board` — those are `ticket move`, and two
678
+ * verbs writing one field is the two-doors failure this file names in its own header. The split
679
+ * is WHERE a card is (move) against WHAT it says (edit), which is also why `move` keeps both of
680
+ * its axes rather than shedding `--board` to here.
681
+ *
682
+ * ⚠ AN EMPTY EDIT IS REFUSED. The server answers `no-updates` with a helpful hint, but a round
683
+ * trip to be told you typed nothing is a round trip; and `ticket edit CLR-42` with no flags is
684
+ * far more likely to be a forgotten flag than a deliberate no-op.
685
+ */
686
+ if (sub === 'edit') {
687
+ const key = rest[0];
688
+ if (!key) {
689
+ die('usage: beehaven ticket edit <key> [--title …] [--body …] [--priority …]', 'Status and board live on `ticket move` — this verb changes what a card SAYS.');
690
+ }
691
+ if (values.status !== undefined || values.board !== undefined) {
692
+ die('`ticket edit` does not move a card', `Use: beehaven ticket move ${key} <${STATUSES.join('|')}> [--board <board>]`);
693
+ }
694
+ const issueType = typeof values.type === 'string' ? values.type.trim().toLowerCase() : '';
695
+ if (issueType && !TYPES.includes(issueType))
696
+ die(`unknown type "${issueType}"`, `Use one of: ${TYPES.join(', ')}.`);
697
+ const priority = resolvePriority(values.priority);
698
+ const due = resolveDue(values.due);
699
+ /**
700
+ * ⚠ `points` IS A TOP-LEVEL COLUMN, and a non-number there is what makes `board-report` read
701
+ * zero for a board somebody carefully sized — the report's own docstring records that trap.
702
+ */
703
+ let points;
704
+ if (values.points !== undefined && values.points !== '') {
705
+ const n = Number(String(values.points).trim());
706
+ if (!Number.isFinite(n) || n < 0)
707
+ die(`--points takes a number (you passed ${String(values.points)}).`);
708
+ points = n;
709
+ }
710
+ const updates = {
711
+ ...(values.title ? { title: String(values.title) } : {}),
712
+ // ⚠ `body`, NEVER `description` — an unknown key is stripped and the edit silently does nothing.
713
+ ...(values.body ? { body: String(values.body) } : {}),
714
+ // ⚠ `issueType`, NEVER `type` — `type` is the wire's action discriminator.
715
+ ...(issueType ? { issueType } : {}),
716
+ ...(priority !== undefined ? { priority } : {}),
717
+ ...(values.assignee ? { assignee: String(values.assignee) } : {}),
718
+ ...(due !== undefined ? { dueAt: due } : {}),
719
+ ...(points !== undefined ? { points } : {}),
720
+ ...(values.epic ? { epicId: String(values.epic) } : {}),
721
+ ...(values.parent ? { parentId: String(values.parent) } : {}),
722
+ ...(values.sprint ? { sprintId: String(values.sprint) } : {}),
723
+ /**
724
+ * ⚠ `resolution` EXISTS BECAUSE A CARD CLOSED WITH NO REASON IS INDISTINGUISHABLE FROM ONE
725
+ * LOST. The column sat unreachable until 2026-08-25 and the reason had to go in `body`,
726
+ * where it is indistinguishable from the original report on every future read.
727
+ */
728
+ ...(values.resolution ? { resolution: String(values.resolution) } : {}),
729
+ };
730
+ if (!Object.keys(updates).length) {
731
+ die('nothing to change', 'Pass at least one of --title --body --type --priority --assignee --due --points --epic --parent --sprint --resolution.');
732
+ }
733
+ // ⚠ `updates` IS A NESTED PARTIAL — a flat `{ id, title }` is lifted by the handler, but only
734
+ // for fields on its own allowlist; nesting is the shape the server documents.
735
+ const res = await rpcOnce('ticket-update', { id: key, updates }, t);
736
+ const f = failure(res);
737
+ if (f)
738
+ die(f);
739
+ if (values.json) {
740
+ console.log(JSON.stringify(res, null, 2));
741
+ return;
742
+ }
743
+ /**
744
+ * ⚠ SAY WHAT MOVED, NOT "updated". A field list is the only thing that distinguishes an edit
745
+ * that landed from one whose flag was misspelled into a different field.
746
+ */
747
+ const changed = Object.keys(updates).map((k) => (k === 'dueAt' ? `due ${dueLabel(updates[k])}`
748
+ : k === 'priority' ? `priority ${priorityLabel(updates[k])}` : k));
749
+ console.log(` ${c.cyan(key)} · ${changed.join(' · ')}`);
750
+ return;
751
+ }
752
+ /**
753
+ * ⚠ COMMENTS ARE A CARD'S THREAD, NOT A SECOND OBJECT. `board-item-comments` is a DIFFERENT
754
+ * table (the older board-items system); tickets had no comment surface until 2026-09-04.
755
+ */
756
+ if (sub === 'comment') {
757
+ const key = rest[0];
758
+ const text = rest.slice(1).join(' ').trim();
759
+ if (!key || !text)
760
+ die('usage: beehaven ticket comment <key> "<note>"');
761
+ const res = await rpcOnce('ticket-comment', {
762
+ id: key, body: text, ...(values.reply ? { parentId: String(values.reply) } : {}),
763
+ }, t);
764
+ const f = failure(res);
765
+ if (f)
766
+ die(f);
767
+ if (values.json) {
768
+ console.log(JSON.stringify(res, null, 2));
769
+ return;
770
+ }
771
+ console.log(` Noted on ${c.cyan(String(res?.ticket ?? key))}`);
772
+ // ⚠ SS-41 — say who the @s reached, and which reached nobody.
773
+ const { printMentionReceipt } = await import('./mention.js');
774
+ printMentionReceipt(res);
775
+ return;
776
+ }
777
+ if (sub === 'comments') {
778
+ const key = rest[0];
779
+ if (!key)
780
+ die('usage: beehaven ticket comments <key>');
781
+ const res = await rpcOnce('ticket-comments', {
782
+ id: key, ...(values.all ? { includeResolved: true } : {}),
783
+ }, t);
784
+ const f = failure(res);
785
+ if (f)
786
+ die(f);
787
+ if (values.json) {
788
+ console.log(JSON.stringify(res, null, 2));
789
+ return;
790
+ }
791
+ const rows = res?.comments ?? [];
792
+ if (!rows.length) {
793
+ console.log(' No comments.');
794
+ return;
795
+ }
796
+ console.log('');
797
+ for (const r of rows)
798
+ console.log(` ${c.cyan(String(r.author))} ${String(r.body)}`);
799
+ /** ⚠ SAY HOW MANY ARE HIDDEN — resolved notes are part of the record, not deleted. */
800
+ const hidden = Number(res?.resolved ?? 0);
801
+ console.log(c.dim(`\n ${rows.length} shown${hidden && !values.all ? ` · ${hidden} resolved (--all)` : ''}`));
802
+ return;
803
+ }
804
+ die(`unknown subcommand "${sub}"`, 'beehaven ticket help');
805
+ }
806
+ export async function cmdBoard(sub, rest, values) {
807
+ const t = getTimeout(values);
808
+ if (!sub || sub === 'help') {
809
+ console.log(`
810
+ ${c.cyan('beehaven board')} — where cards live
811
+
812
+ board list Every board
813
+ board create "<title>" A new board (--project <id>)
814
+ board delete "<title|id>" Remove an EMPTY board (refuses while cards are on it)
815
+ board columns ["<title>"] Its workflow, and which cards no column can hold
816
+ board report ["<title>"] Tracker metrics (--kind ${REPORT_KINDS.join('|')} --sprint <id>)
817
+ board backlog ["<title>"] Cards on the board that are in no sprint
818
+
819
+ ${c.dim('To READ a board with its cards: cat "~/<Board>.board.json"')}
820
+ ${c.dim('To move a card: beehaven ticket move <key> <status>')}
821
+ ${c.dim('To plan a sprint: beehaven sprint help')}
822
+ `);
823
+ return;
824
+ }
825
+ if (sub === 'list') {
826
+ const res = await rpcOnce('board-list', {}, t);
827
+ const f = failure(res);
828
+ if (f)
829
+ die(f);
830
+ if (values.json) {
831
+ console.log(JSON.stringify(res, null, 2));
832
+ return;
833
+ }
834
+ const rows = (res?.boards ?? res?.rows ?? res?.items ?? [])
835
+ .filter((b) => String(b?.is_archived ?? '') !== 'true');
836
+ if (!rows.length) {
837
+ console.log(' No boards.');
838
+ return;
839
+ }
840
+ console.log('');
841
+ for (const b of rows)
842
+ console.log(` ${c.cyan(String(b.title ?? 'Untitled'))} ${c.dim(String(b.id ?? ''))}`);
843
+ console.log(c.dim(`\n ${rows.length} board(s) · read one with: cat "~/<title>.board.json"`));
844
+ return;
845
+ }
846
+ if (sub === 'create') {
847
+ const title = rest.join(' ').trim();
848
+ if (!title)
849
+ die('a title is required', 'beehaven board create "Sprint 2"');
850
+ const res = await rpcOnce('board-create', {
851
+ title, ...(values.project ? { projectId: String(values.project) } : {}),
852
+ ...(!values.project ? { personal: true } : {}),
853
+ }, t);
854
+ const f = failure(res);
855
+ if (f)
856
+ die(f);
857
+ if (values.json) {
858
+ console.log(JSON.stringify(res, null, 2));
859
+ return;
860
+ }
861
+ console.log(` Created board ${c.cyan(title)}`);
862
+ return;
863
+ }
864
+ /**
865
+ * ⚠⚠ BOARDS COULD ONLY EVER ACCUMULATE — CBH2-111. The web creates and archives them and has no
866
+ * delete call site at all; the CLI had no verb. `board-delete` has been registered, allowlisted
867
+ * and guarded throughout, so this is a DOOR, not a new capability — and deliberately the same
868
+ * door as `board list` / `board create` rather than a second one somewhere else.
869
+ *
870
+ * ⚠⚠ THE SERVER IS WHAT MAKES THIS SAFE, AND THE CLI MUST NOT SOFTEN IT. `board-delete` refuses
871
+ * while ANY ticket still references the board — including closed ones — because unpinning them
872
+ * would leave real cards on no board, which is the invisible-card state `ticket-create` started
873
+ * requiring a board to prevent. The refusal names the counts; it is printed verbatim.
874
+ *
875
+ * ⚠ IT REQUIRES A NAME (`named: true`). With no `--board`, `resolveBoard` falls back to the
876
+ * OLDEST board in the workspace — correct when filing a card, catastrophic here.
877
+ *
878
+ * ⚠ AND IT SEES ARCHIVED BOARDS. Archiving is how boards are retired today, so the ones most
879
+ * worth deleting are exactly the ones the default filter hides.
880
+ */
881
+ if (sub === 'delete') {
882
+ const named = rest.join(' ').trim();
883
+ const board = await resolveBoard({ ...values, board: named || values.board }, { named: true, includeArchived: true });
884
+ const res = await rpcOnce('board-delete', { id: board.id }, t);
885
+ const f = failure(res);
886
+ if (f) {
887
+ /**
888
+ * ⚠ THE OCCUPIED-BOARD REFUSAL IS AN ANSWER, NOT AN ERROR. It already names the counts and
889
+ * the fix (`ticket-update { id, boardId }`), so it is passed through with the CLI's own
890
+ * verb beside it rather than being restated in worse words.
891
+ */
892
+ die(f, `Move them first: beehaven ticket move <key> --board "<other board>"`);
893
+ }
894
+ if (values.json) {
895
+ console.log(JSON.stringify(res, null, 2));
896
+ return;
897
+ }
898
+ if (res?.alreadyDeleted) {
899
+ console.log(` ${c.yellow('Already gone')}: ${c.cyan(board.title || board.id)}`);
900
+ return;
901
+ }
902
+ console.log(` Deleted board ${c.cyan(board.title || board.id)}`);
903
+ return;
904
+ }
905
+ /**
906
+ * ⚠⚠ A BOARD'S WORKFLOW WAS WRITABLE BY AN AGENT AND READABLE BY NOBODY — CBH2-130.
907
+ *
908
+ * `board-columns-set` is registered, allowlisted and exposed as an MCP tool, and has NO caller on
909
+ * any human surface — not the web app, not a CLI verb. Its own description says "there is no UI
910
+ * for this". So the columns are the vocabulary a person works in every day, and the only
911
+ * principal that could change them was the one that does not look at them.
912
+ *
913
+ * ⚠⚠ THIS IS THE READ HALF ONLY, AND THE OMISSION IS DELIBERATE. **CBH2-143 is `in-review` right
914
+ * now** and is changing what `board-columns-set` does about stranded cards — report, refuse, or
915
+ * migrate. The entire value a CLI writer would add over `beehaven call board-columns-set` IS that
916
+ * check, so writing one today puts two implementations of one safety rule in flight at once, in
917
+ * the direction where disagreeing is most expensive. `call` remains the door for the write until
918
+ * that lands.
919
+ *
920
+ * ⚠⚠ AND THE READ IS WHAT MAKES THE `call` SAFE IN THE MEANTIME, which is why it is worth having
921
+ * on its own. CBH2-143's finding is that removing a column leaves every card holding that status
922
+ * word in NO column — "40 in-review cards … draw in no column, and are now unreachable by drag"
923
+ * — and until now there was no way to ask, from any surface, which cards those would be. This
924
+ * answers it before the call rather than after.
925
+ *
926
+ * ⚠ IT EARNS ITS PLACE ON `BEYOND`, not on convenience (`verb-surface.ts`). It is a MULTI-CALL
927
+ * FLOW no single action performs: `board-get` for the definition, `ticket-list` for the
928
+ * occupancy, joined. `beehaven call board-get` returns `columns` as a JSON STRING inside a raw
929
+ * row — beside `is_archived: "false"`, the truthy-string trap — and answers nothing about cards.
930
+ *
931
+ * ⚠ THE BOARD IS RESOLVED BY NAME (the `resolveBoard` ladder), which is the client-side STATE a
932
+ * raw `call` cannot express: `board-columns-set` takes `boardId`, "note: `boardId`, not `id`",
933
+ * and nothing in a terminal knows a board's uuid.
934
+ */
935
+ if (sub === 'columns') {
936
+ const named = rest.join(' ').trim();
937
+ const board = await resolveBoard({ ...values, board: named || values.board }, { includeArchived: true });
938
+ const got = await rpcOnce('board-get', { id: board.id }, t);
939
+ const gf = failure(got);
940
+ if (gf)
941
+ die(gf);
942
+ /**
943
+ * ⚠⚠ `columns` COMES BACK AS A JSON **STRING**, not an array — it is a `json` column, which is
944
+ * a TYPE HINT and not a serializer. Reading it as an array yields nothing and reports a board
945
+ * with no workflow, which is indistinguishable from a real defect.
946
+ */
947
+ const raw = (got?.board ?? got)?.columns;
948
+ let cols = [];
949
+ try {
950
+ cols = typeof raw === 'string' ? JSON.parse(raw) : Array.isArray(raw) ? raw : [];
951
+ }
952
+ catch {
953
+ die('the board\'s columns are not readable JSON', `Raw value: ${String(raw).slice(0, 120)}`);
954
+ }
955
+ /**
956
+ * ⚠ THE CARDS ARE FETCHED AT THE SERVER'S CEILING, and a full page is REPORTED rather than
957
+ * silently trusted. A stranded-card count taken from a truncated list is a confident wrong
958
+ * answer about exactly the thing this verb exists to surface — the CBH2-112 defect, one verb
959
+ * over.
960
+ */
961
+ const listed = await rpcOnce('ticket-list', { boardId: board.id, limit: LIST_MAX }, t);
962
+ const lf = failure(listed);
963
+ if (lf)
964
+ die(lf);
965
+ const cards = listed?.tickets ?? listed?.items ?? listed?.rows ?? [];
966
+ const keys = new Set(cols.map((c2) => String(c2.key)));
967
+ const held = new Map();
968
+ for (const card of cards) {
969
+ const s = String(card?.status ?? '');
970
+ held.set(s, (held.get(s) ?? 0) + 1);
971
+ }
972
+ const stranded = [...held.entries()].filter(([s]) => s && !keys.has(s));
973
+ if (values.json) {
974
+ console.log(JSON.stringify({
975
+ board: { id: board.id, title: board.title },
976
+ columns: cols,
977
+ cards: cards.length,
978
+ truncated: cards.length >= LIST_MAX,
979
+ stranded: stranded.map(([status, count]) => ({ status, count })),
980
+ }, null, 2));
981
+ return;
982
+ }
983
+ console.log('');
984
+ console.log(` ${c.cyan(board.title || board.id)}`);
985
+ console.log('');
986
+ if (!cols.length)
987
+ console.log(` ${c.yellow('No columns defined')} — cards on this board draw nowhere.`);
988
+ for (const col of cols) {
989
+ const key = String(col.key ?? '');
990
+ const n = held.get(key) ?? 0;
991
+ const cat = col.category ? c.dim(` ${String(col.category)}`) : '';
992
+ const wip = col.wipLimit ? c.dim(` wip ${col.wipLimit}`) : '';
993
+ console.log(` ${key.padEnd(14)} ${String(col.label ?? key).padEnd(16)} ${String(n).padStart(4)} card(s)${cat}${wip}`);
994
+ }
995
+ console.log('');
996
+ if (stranded.length) {
997
+ /**
998
+ * ⚠ NAMED, NOT COUNTED. "3 stranded" is not actionable; the STATUS WORD is what a caller
999
+ * passes to `ticket move` to rescue them, so it is the thing to print.
1000
+ */
1001
+ console.log(` ${c.yellow('Stranded')} — no column holds these, so the cards draw nowhere and cannot be dragged:`);
1002
+ for (const [status, count] of stranded) {
1003
+ console.log(` ${c.cyan(status.padEnd(14))} ${count} card(s) ${c.dim(`beehaven ticket move <key> <a column above>`)}`);
1004
+ }
1005
+ console.log('');
1006
+ }
1007
+ if (cards.length >= LIST_MAX) {
1008
+ console.log(c.dim(` ⚠ ${cards.length} cards is a full page — the counts above may be incomplete.`));
1009
+ console.log('');
1010
+ }
1011
+ console.log(c.dim(` The workflow is changed with: beehaven call board-columns-set '{"boardId":"${board.id}","columns":[…]}'`));
1012
+ console.log(c.dim(` ⚠ It migrates nothing — a card whose status word you remove keeps it and lands here as stranded.`));
1013
+ console.log('');
1014
+ return;
1015
+ }
1016
+ /**
1017
+ * ⚠ REPORTS ARE BOARD-SCOPED, WHICH IS WHY THEY LIVE HERE AND NOT ON `sprint`. `board-report`
1018
+ * takes a `boardId` and refuses without one; `--sprint` only narrows a burndown.
1019
+ *
1020
+ * ⚠ `burndown` AND `velocity` ARE ALIASES OF THIS ACTION SERVER-SIDE, and the daemon's own agent
1021
+ * prompt tells agents to call `burndown`. They are `--kind` values here rather than subcommands,
1022
+ * so there is one door to one action.
1023
+ */
1024
+ if (sub === 'report') {
1025
+ const named = rest.join(' ').trim();
1026
+ const board = await resolveBoard({ ...values, board: named || values.board });
1027
+ const kind = typeof values.kind === 'string' ? values.kind.trim().toLowerCase() : '';
1028
+ if (kind && !REPORT_KINDS.includes(kind))
1029
+ die(`unknown --kind "${kind}"`, `Use one of: ${REPORT_KINDS.join(', ')}.`);
1030
+ const res = await rpcOnce('board-report', {
1031
+ boardId: board.id,
1032
+ ...(kind ? { kind } : {}),
1033
+ ...(values.sprint ? { sprintId: String(values.sprint) } : {}),
1034
+ }, t);
1035
+ const f = failure(res);
1036
+ if (f)
1037
+ die(f);
1038
+ if (values.json) {
1039
+ console.log(JSON.stringify(res, null, 2));
1040
+ return;
1041
+ }
1042
+ console.log('');
1043
+ console.log(` ${c.cyan(board.title || board.id)} · ${String(res?.kind ?? (kind || 'summary'))}`);
1044
+ /**
1045
+ * ⚠ THE SHAPE DIFFERS PER KIND (summary counts · velocity per sprint · burndown series ·
1046
+ * cycle-time), so this prints the payload rather than inventing four renderers that would each
1047
+ * go stale independently. `--json` is the same bytes without the heading.
1048
+ */
1049
+ const { ok: _ok, type: _type, requestId: _rid, kind: _kind, boardId: _bid, ...rest2 } = (res ?? {});
1050
+ console.log(JSON.stringify(rest2, null, 2).split('\n').map((l) => ` ${l}`).join('\n'));
1051
+ console.log('');
1052
+ /** ⚠ POINTS COME FROM THE `points` COLUMN — a board nobody sized reports zero, truthfully. */
1053
+ if (Number(res?.points ?? 0) === 0 && String(res?.kind ?? 'summary') === 'summary') {
1054
+ console.log(c.dim(' points read 0 because no card is sized — beehaven ticket edit <key> --points N'));
1055
+ }
1056
+ return;
1057
+ }
1058
+ /**
1059
+ * ⚠ `--limit` IS DELIBERATELY NOT WIRED HERE. `backlog-list`'s `limit` bounds the ticket SCAN
1060
+ * (default 500), not the number returned — the opposite of what `--limit` means on `ticket list`.
1061
+ * One flag with two meanings across two verbs is how a caller learns the wrong one; the scan
1062
+ * default stands, and `board` stays off `LIMIT_CAPABLE` so the flag is refused rather than
1063
+ * quietly reinterpreted.
1064
+ */
1065
+ if (sub === 'backlog') {
1066
+ const named = rest.join(' ').trim();
1067
+ const board = await resolveBoard({ ...values, board: named || values.board });
1068
+ const res = await rpcOnce('backlog-list', { boardId: board.id }, t);
1069
+ const f = failure(res);
1070
+ if (f)
1071
+ die(f);
1072
+ if (values.json) {
1073
+ console.log(JSON.stringify(res, null, 2));
1074
+ return;
1075
+ }
1076
+ const backlog = res?.backlog ?? [];
1077
+ console.log('');
1078
+ console.log(` ${c.cyan(board.title || board.id)} · backlog`);
1079
+ if (!backlog.length)
1080
+ console.log(c.dim(' nothing unplanned'));
1081
+ for (const card of backlog) {
1082
+ const key = String(card.key ?? '').padEnd(9);
1083
+ const st = String(card.status ?? '').padEnd(12);
1084
+ console.log(` ${c.cyan(key)} ${c.dim(st)} ${String(card.title ?? '')}`);
1085
+ }
1086
+ /**
1087
+ * ⚠⚠ SAY THAT THE BACKLOG IS NOT FILTERED BY STATUS. `backlog-list` has no status predicate,
1088
+ * so finished cards that were never put in a sprint sit in this list too — reading its length
1089
+ * as "work remaining" is wrong, and nothing else on screen says so.
1090
+ */
1091
+ console.log(c.dim(`\n ${backlog.length} unplanned card(s) · ${Number(res?.backlogPoints ?? 0)} points`
1092
+ + ' — NOT status-filtered, so finished cards appear here too'));
1093
+ for (const s of (res?.sprints ?? [])) {
1094
+ console.log(c.dim(` ${String(s.name ?? '')} (${String(s.state ?? '')}) · ${(s.cards ?? []).length} card(s) · ${Number(s.points ?? 0)} points`));
1095
+ }
1096
+ return;
1097
+ }
1098
+ die(`unknown subcommand "${sub}"`, 'beehaven board help');
1099
+ }
1100
+ /**
1101
+ * `beehaven sprint` — the planning cycle.
1102
+ *
1103
+ * ⚠⚠ WHY THIS EXISTS: THE DAEMON'S OWN AGENT PROMPT SENDS AGENTS HERE AND THERE WAS NOTHING TO
1104
+ * FIND — CBH2-107. `daemon-omni-company.ts` instructs agents to call `sprint-create`,
1105
+ * `sprint-plan`, `sprint-start`, `sprint-complete` and `burndown`; all six sprint actions plus
1106
+ * `backlog-list` and `board-report` were registered, allowlisted and reachable ONLY as
1107
+ * `beehaven call <action> '{…}'`. A shipped prompt naming a verb the CLI does not have is the
1108
+ * same defect already recorded at `commands/omni.ts:138` for `ticket-link`.
1109
+ *
1110
+ * ⚠ SO THIS IS A DOOR, NOT A CAPABILITY. Every action here already existed on
1111
+ * `USER_ACTION_ALLOWLIST` with an `ACTION_DESCRIPTIONS` entry — nothing was granted for it.
1112
+ *
1113
+ * ⚠⚠ AND IT EXISTS TO MAKE TWO MEASURED TRAPS UNTYPEABLE, the same reason `ticket` does:
1114
+ * · A SPRINT IS ADDRESSED BY UUID and nothing ever shows you one in prose. `resolveSprint`
1115
+ * takes the name people actually say, and REFUSES an ambiguous one rather than starting or
1116
+ * deleting whichever sprint sorted first — auto-numbering means two boards routinely both
1117
+ * hold a "Sprint 1".
1118
+ * · `sprint-plan` TAKES ROW IDS AND ANSWERS `ok:true, moved:1` FOR A KEY IT NEVER MOVED. See
1119
+ * `resolveCardIds` — this verb resolves every reference first and refuses on a miss.
1120
+ */
1121
+ export async function cmdSprint(sub, rest, values) {
1122
+ const t = getTimeout(values);
1123
+ if (!sub || sub === 'help') {
1124
+ console.log(`
1125
+ ${c.cyan('beehaven sprint')} — plan, run and close a sprint
1126
+
1127
+ sprint list Sprints (--board <b> --state ${SPRINT_STATES.join('|')})
1128
+ sprint create ["<name>"] A new sprint, state ${c.dim('planned')} (--board <b> --goal "…")
1129
+ sprint plan <s> <key…> Put cards IN (--remove to send them back to the backlog)
1130
+ sprint start <s> planned → active (one active sprint per board)
1131
+ sprint complete <s> active → completed (--sprint <id> for the unfinished cards)
1132
+ sprint delete <s> Discard it — its cards go back to the backlog (--force if active)
1133
+
1134
+ ${c.dim('A sprint is named or addressed by id. Names must be unique — narrow with --board.')}
1135
+ ${c.dim('What is not in a sprint: beehaven board backlog "<board>"')}
1136
+ ${c.dim('How it went: beehaven board report "<board>" --kind velocity')}
1137
+ ${c.dim('⚠ delete, not complete, discards a mistake — a COMPLETED sprint is a permanent')}
1138
+ ${c.dim(' velocity datapoint that board-report averages over.')}
1139
+ `);
1140
+ return;
1141
+ }
1142
+ if (sub === 'list') {
1143
+ const board = values.board ? await resolveBoard(values) : null;
1144
+ const state = typeof values.state === 'string' ? values.state.trim().toLowerCase() : '';
1145
+ if (state && !SPRINT_STATES.includes(state)) {
1146
+ die(`unknown --state "${state}"`, `Use one of: ${SPRINT_STATES.join(', ')}.`);
1147
+ }
1148
+ const res = await rpcOnce('sprint-list', {
1149
+ ...(board ? { boardId: board.id } : {}),
1150
+ ...(state ? { state } : {}),
1151
+ }, t);
1152
+ const f = failure(res);
1153
+ if (f)
1154
+ die(f);
1155
+ if (values.json) {
1156
+ console.log(JSON.stringify(res, null, 2));
1157
+ return;
1158
+ }
1159
+ const rows = res?.sprints ?? [];
1160
+ if (!rows.length) {
1161
+ console.log(' No sprints.');
1162
+ return;
1163
+ }
1164
+ console.log('');
1165
+ for (const s of rows) {
1166
+ const st = String(s.state ?? '').padEnd(10);
1167
+ const pts = `${Number(s.completedPoints ?? 0)}/${Number(s.committedPoints ?? 0)} pts`;
1168
+ console.log(` ${c.cyan(String(s.name ?? '').padEnd(24))} ${c.dim(st)} ${c.dim(pts)} ${c.dim(String(s.id ?? ''))}`);
1169
+ }
1170
+ console.log(c.dim(`\n ${rows.length} sprint(s)`));
1171
+ return;
1172
+ }
1173
+ if (sub === 'create') {
1174
+ // ⚠ `boardId` IS REQUIRED — `sprint-create` refuses without one. Same rule as a ticket, and
1175
+ // the same resolver, so a sprint cannot land somewhere the caller was not told about.
1176
+ const board = await resolveBoard(values);
1177
+ const name = rest.join(' ').trim();
1178
+ const res = await rpcOnce('sprint-create', {
1179
+ boardId: board.id,
1180
+ ...(name ? { name } : {}),
1181
+ ...(values.goal ? { goal: String(values.goal) } : {}),
1182
+ }, t);
1183
+ const f = failure(res);
1184
+ if (f)
1185
+ die(f);
1186
+ if (values.json) {
1187
+ console.log(JSON.stringify(res, null, 2));
1188
+ return;
1189
+ }
1190
+ /** ⚠ SAY THE BOARD, for the reason `ticket create` does: with no --board this was a guess. */
1191
+ console.log(` Created ${c.cyan(String(res?.name ?? name))} · ${board.title || board.id} · ${c.dim('planned')}`);
1192
+ console.log(c.dim(` beehaven sprint start "${String(res?.name ?? name)}" — when you are ready to run it`));
1193
+ return;
1194
+ }
1195
+ /**
1196
+ * ⚠⚠ ONE VERB, TWO DIRECTIONS — the shape `ticket move` already uses. Removing a card FROM a
1197
+ * sprint IS returning it to the backlog server-side (`sprint_id: ''`), so `--remove` covers
1198
+ * both and there is no second `sprint backlog` verb naming the same write.
1199
+ */
1200
+ if (sub === 'plan') {
1201
+ const sprintRef = rest[0];
1202
+ const cardRefs = rest.slice(1).filter(Boolean);
1203
+ if (!sprintRef || !cardRefs.length) {
1204
+ die('usage: beehaven sprint plan <sprint> <key> [<key>…] [--remove]', 'Keys look like CLR-42. `--remove` sends them back to the backlog.');
1205
+ }
1206
+ const sprint = await resolveSprint(sprintRef, values);
1207
+ const cards = await resolveCardIds(cardRefs, values);
1208
+ const ids = cards.map((x) => x.id);
1209
+ const res = await rpcOnce('sprint-plan', {
1210
+ sprintId: sprint.id,
1211
+ ...(values.remove ? { remove: ids } : { add: ids }),
1212
+ }, t);
1213
+ const f = failure(res);
1214
+ if (f)
1215
+ die(f);
1216
+ if (values.json) {
1217
+ console.log(JSON.stringify(res, null, 2));
1218
+ return;
1219
+ }
1220
+ const dir = values.remove ? 'out of' : 'into';
1221
+ console.log(` ${cards.map((x) => x.key).join(', ')} ${dir} ${c.cyan(sprint.name)}`);
1222
+ /**
1223
+ * ⚠⚠ RECONCILE THE SERVER'S COUNT AGAINST WHAT WAS ASKED FOR. `sprint-plan` increments its
1224
+ * counter inside a `try` and swallows a failed write, so `moved` can be smaller than the
1225
+ * request with nothing else saying so. Every card here resolved to a real row id first, so a
1226
+ * shortfall is a genuine server-side miss and must be visible.
1227
+ */
1228
+ const moved = Number(res?.moved ?? 0);
1229
+ if (moved !== ids.length) {
1230
+ console.log(c.yellow(` the server moved ${moved} of ${ids.length} — re-check with: beehaven ticket show ${cards[0].key}`));
1231
+ }
1232
+ return;
1233
+ }
1234
+ if (sub === 'start') {
1235
+ const sprint = await resolveSprint(rest.join(' ').trim(), values);
1236
+ const res = await rpcOnce('sprint-start', { id: sprint.id }, t);
1237
+ const f = failure(res);
1238
+ /**
1239
+ * ⚠ THE "ANOTHER SPRINT IS ACTIVE" REFUSAL IS THE COMMON ONE and it names the blocker, so it
1240
+ * is passed through with the verb that clears it rather than restated.
1241
+ */
1242
+ if (f)
1243
+ die(f, 'One sprint per board runs at a time — beehaven sprint complete "<the active one>"');
1244
+ if (values.json) {
1245
+ console.log(JSON.stringify(res, null, 2));
1246
+ return;
1247
+ }
1248
+ console.log(` ${c.cyan(sprint.name)} → ${c.dim('active')} · ${Number(res?.cards ?? 0)} card(s) · ${Number(res?.committedPoints ?? 0)} points committed`);
1249
+ /**
1250
+ * ⚠ COMMITTED POINTS ARE FROZEN HERE AND NEVER RECOMPUTED — that is what makes velocity
1251
+ * honest, and it means a card sized AFTER this moment does not count. Nothing else says so.
1252
+ */
1253
+ if (Number(res?.committedPoints ?? 0) === 0 && Number(res?.cards ?? 0) > 0) {
1254
+ console.log(c.dim(' 0 points committed — size the cards BEFORE starting, the commitment is frozen now'));
1255
+ }
1256
+ return;
1257
+ }
1258
+ if (sub === 'complete') {
1259
+ const sprint = await resolveSprint(rest.join(' ').trim(), values);
1260
+ const res = await rpcOnce('sprint-complete', {
1261
+ id: sprint.id,
1262
+ // ⚠ Omitted means "back to the backlog" — the server's default, and the safe one.
1263
+ ...(values.sprint ? { moveUnfinishedTo: String(values.sprint) } : {}),
1264
+ }, t);
1265
+ const f = failure(res);
1266
+ if (f)
1267
+ die(f);
1268
+ if (values.json) {
1269
+ console.log(JSON.stringify(res, null, 2));
1270
+ return;
1271
+ }
1272
+ console.log(` ${c.cyan(sprint.name)} → ${c.dim('completed')}`);
1273
+ console.log(` ${String(res?.message ?? '')}`);
1274
+ /**
1275
+ * ⚠⚠ COMPLETING IS PERMANENT AND IS NOT THE UNDO. A completed sprint is a velocity datapoint
1276
+ * `board-report` averages over — so completing a junk sprint corrupts the metric rather than
1277
+ * discarding it. `sprint delete` is the discard.
1278
+ */
1279
+ console.log(c.dim(' recorded as a velocity datapoint — beehaven board report --kind velocity'));
1280
+ return;
1281
+ }
1282
+ if (sub === 'delete') {
1283
+ const sprint = await resolveSprint(rest.join(' ').trim(), values);
1284
+ const res = await rpcOnce('sprint-delete', {
1285
+ id: sprint.id, ...(values.force ? { force: true } : {}),
1286
+ }, t);
1287
+ const f = failure(res);
1288
+ // ⚠ An ACTIVE sprint refuses without --force, deliberately. Name the flag.
1289
+ if (f)
1290
+ die(f, sprint.state === 'active' ? 'Pass --force to delete the sprint people are working in.' : undefined);
1291
+ if (values.json) {
1292
+ console.log(JSON.stringify(res, null, 2));
1293
+ return;
1294
+ }
1295
+ console.log(` Deleted ${c.cyan(sprint.name)}`);
1296
+ /** ⚠ THE CARDS SURVIVE — they go back to the backlog, and saying so is the point. */
1297
+ console.log(c.dim(` ${String(res?.message ?? '')}`));
1298
+ return;
1299
+ }
1300
+ die(`unknown subcommand "${sub}"`, 'beehaven sprint help');
1301
+ }