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.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- 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
|
+
}
|