dsh-plugin-bmad-sprint-board 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,4577 @@
1
+ /**
2
+ * BMAD Sprint Board -- the Client half of the bundle. GENERATED -- do not edit.
3
+ *
4
+ * Built from src/ by `node build.mjs`. Edit src/core.js, src/shell-*.js.
5
+ * src/boundary.test.js asserts the core names no DSH symbol; src/core.test.js
6
+ * exercises it directly.
7
+ *
8
+ * Contributes one page-type tab to the Session's right Sidebar and one guide
9
+ * entry that opens it. The board is strictly read-only: it reads workspace
10
+ * files through the Host `workspaceFiles` Remote face and never writes.
11
+ *
12
+ * @module @local/dsh-plugin-bmad-sprint-board/client
13
+ */
14
+
15
+
16
+ /**
17
+ * BMAD Sprint Board -- the Client half of the bundle.
18
+ *
19
+ * Contributes one page-type tab to the Session's right Sidebar (the column that
20
+ * already holds Chat, Guide, Files and Terminal) and one guide entry that opens
21
+ * it. The board is strictly read-only: it reads three workspace files through
22
+ * the Host `workspaceFiles` Remote face and never writes to them.
23
+ *
24
+ * @module dsh-plugin-bmad-sprint-board/client
25
+ */
26
+
27
+ window.__ModuleLoader__.load({
28
+ id: 'dsh-plugin-bmad-sprint-board',
29
+ factory(require) {
30
+ const React = require('react');
31
+ const h = React.createElement;
32
+
33
+ /** Dictionary namespace owned by this plugin. */
34
+ const NS = 'bmadSprintBoard';
35
+ /** This implementation's identity: the tab type id and the body's cell key. */
36
+ const PLUGIN_ID = 'dsh-plugin-bmad-sprint-board';
37
+ /** Page kind the guide entry opens. */
38
+ const TAB_KIND = 'bmad-sprint-board';
39
+ /** Command id of the keybinding that opens the board. */
40
+ /**
41
+ * How many file reads the loader keeps in flight.
42
+ *
43
+ * The reads are round-trips to the Host's workspace face. All of them at
44
+ * once would be quickest and would also hand the far side thirty
45
+ * simultaneous requests for a board that refreshes on a click; eight keeps
46
+ * the wait short without turning a refresh into a burst.
47
+ */
48
+ const READ_CONCURRENCY = 8;
49
+
50
+ const BOARD_COMMAND = 'bmad.sprint-board';
51
+ // --- Host-specific: theme palette and file addressing ---
52
+ // Carved out of the core so it never names a DSH token or URI scheme.
53
+ // Carved out of the core so it never names a DSH token or URI scheme.
54
+ /**
55
+ * Story status -> the theme token that carries its marker colour, read in
56
+ * workflow order: grey while it waits, blue while it is being worked, orange
57
+ * while it is being reviewed, green once it is done. The epic markers reuse
58
+ * this same mapping for the epic's own status.
59
+ *
60
+ * The blue is `state-business-primary`, not `brand-primary`: the latter is
61
+ * the primary *ink* (near-black in light, near-white in dark) that a solid
62
+ * primary button fills with, so it reads as no colour at all on a 7px mark.
63
+ * `state-business-primary` is the theme's real blue in both themes, and it
64
+ * keeps the ramp inside one family of `state-*` aliases.
65
+ */
66
+ const STATUS_TOKEN = {
67
+ backlog: '--dsw-alias-state-idle-primary',
68
+ 'ready-for-dev': '--dsw-alias-label-secondary',
69
+ 'in-progress': '--dsw-alias-state-business-primary',
70
+ review: '--dsw-alias-state-warn-primary',
71
+ done: '--dsw-alias-state-success-primary',
72
+ };
73
+ const IDLE_TOKEN = '--dsw-alias-state-idle-primary';
74
+ /** The token a lane uses when its contents want attention rather than praise. */
75
+ const WARN_TOKEN = '--dsw-alias-state-warn-primary';
76
+ const CHEVRON_OPEN = '\u25BE';
77
+ const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
78
+ /**
79
+ * Component-encode one path segment, keeping `:` literal for drive letters.
80
+ * @param segment - one `/`-separated path segment.
81
+ * @returns the encoded segment.
82
+ */
83
+ function encodeSegment(segment) {
84
+ return encodeURIComponent(segment).replace(/%3A/gi, ':');
85
+ }
86
+ /**
87
+ * Build the `dsh-resource://file/session/<id>/<path>` address of a workspace file.
88
+ * @param sessionId - the Session whose Host workspace resolves the path.
89
+ * @param path - absolute or workspace-relative path.
90
+ * @returns the resource address the document preview claims.
91
+ */
92
+ function sessionFileAddress(sessionId, path) {
93
+ const normalized = String(path)
94
+ .replace(/\\/g, '/')
95
+ .replace(/^(?:\.\/)+/, '');
96
+ const encoded = normalized.split('/').map(encodeSegment).join('/');
97
+ return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encoded}`;
98
+ }
99
+ const CHEVRON_CLOSED = '\u25B8';
100
+
101
+ /**
102
+ * BMAD Sprint Board -- the host-independent core.
103
+ *
104
+ * Pure functions over text: discover the tracking file, parse it and the
105
+ * deferred ledger, build the model, and (from v1) evaluate detectors against it.
106
+ *
107
+ * This module must never reference DSH, Cordis, React, or the DOM. Anything
108
+ * that needs a host arrives as an injected reader; anything that needs a theme
109
+ * arrives as a palette. That constraint is what lets the same core back a
110
+ * second shell, and it is asserted by src/boundary.test.js.
111
+ *
112
+ * @module dsh-plugin-bmad-sprint-board/core
113
+ */
114
+
115
+ /** Names a BMAD sprint-tracking file goes by, in preference order. */
116
+ const SPRINT_FILENAMES = ['sprint-status.yaml', 'sprint-status.yml'];
117
+
118
+ /**
119
+ * Directories a BMAD install keeps its tracking file in, relative to the
120
+ * workspace root, probed before any scan. The first is the conventional
121
+ * layout; the rest cover a reconfigured output folder, so the common case
122
+ * costs a couple of stats rather than a walk. The empty string is the root.
123
+ */
124
+ const SPRINT_CANDIDATES = [
125
+ '_bmad-output/implementation-artifacts',
126
+ '_bmad-output',
127
+ '_bmad/implementation-artifacts',
128
+ '_bmad',
129
+ 'docs',
130
+ 'bmad',
131
+ '.bmad',
132
+ '',
133
+ ];
134
+
135
+ /** Directory names a scan never descends into: build output and vendored trees. */
136
+ const SCAN_SKIP = new Set([
137
+ '.cache',
138
+ '.git',
139
+ '.next',
140
+ '.nuxt',
141
+ '.output',
142
+ '.pnpm-store',
143
+ '.react-router',
144
+ '.svelte-kit',
145
+ '.tmp',
146
+ '.tox',
147
+ '.venv',
148
+ '.yarn',
149
+ '__pycache__',
150
+ 'build',
151
+ 'coverage',
152
+ 'dist',
153
+ 'node_modules',
154
+ 'out',
155
+ 'playwright-report',
156
+ 'target',
157
+ 'test-results',
158
+ 'tmp',
159
+ 'vendor',
160
+ 'venv',
161
+ ]);
162
+
163
+ /** How deep a scan descends below the workspace root. */
164
+ const SCAN_MAX_DEPTH = 4;
165
+
166
+ /** Hard ceiling on the directory listings one scan may issue. */
167
+ const SCAN_MAX_DIRS = 400;
168
+
169
+ /** Resolved tracking-file locations, per Session, so a refresh need not rescan. */
170
+ const locatedSprint = new Map();
171
+
172
+ /**
173
+ * Join a workspace-relative directory and a child name.
174
+ * @param dir - the directory, or the empty string for the workspace root.
175
+ * @param name - the child's basename.
176
+ * @returns the workspace-relative path.
177
+ */
178
+ function under(dir, name) {
179
+ return dir === '' ? name : `${dir}/${name}`;
180
+ }
181
+
182
+ /**
183
+ * The directory part of a workspace-relative path.
184
+ * @param path - a workspace-relative path.
185
+ * @returns the directory, or the empty string for a root-level file.
186
+ */
187
+ function dirOf(path) {
188
+ const at = path.lastIndexOf('/');
189
+ return at === -1 ? '' : path.slice(0, at);
190
+ }
191
+
192
+ /**
193
+ * Whether a workspace-relative path names a readable regular file.
194
+ * @param stat - the injected workspace stat reader.
195
+ * @param path - the workspace-relative path to test.
196
+ * @param signal - caller cancellation.
197
+ * @returns true when the file is there.
198
+ */
199
+ async function fileExists(stat, path, signal) {
200
+ try {
201
+ return (await stat(path, signal)).ok;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Probe the conventional BMAD locations, most likely first.
209
+ * @param readers - the injected workspace readers.
210
+ * @param signal - caller cancellation.
211
+ * @returns the tracking file's path, or null when no candidate is there.
212
+ */
213
+ async function probeCandidates({ stat }, signal) {
214
+ for (const dir of SPRINT_CANDIDATES) {
215
+ for (const name of SPRINT_FILENAMES) {
216
+ const path = under(dir, name);
217
+ if (await fileExists(stat, path, signal)) return path;
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Walk the workspace breadth-first for a tracking file, because a project may
225
+ * keep BMAD output anywhere. The walk is bounded three ways -- depth, a
226
+ * directory budget, and a skip list for build and vendored trees -- so a large
227
+ * or unfamiliar workspace still settles instead of walking forever.
228
+ * @param readers - the injected workspace readers.
229
+ * @param signal - caller cancellation.
230
+ * @returns the tracking file's path, or null when the workspace has none.
231
+ */
232
+ async function scanForSprint({ list }, signal) {
233
+ const queue = [{ dir: '', depth: 0 }];
234
+ let visited = 0;
235
+
236
+ while (queue.length > 0 && visited < SCAN_MAX_DIRS) {
237
+ const { dir, depth } = queue.shift();
238
+ visited += 1;
239
+
240
+ let listing;
241
+ try {
242
+ listing = await list(dir, signal);
243
+ } catch {
244
+ continue;
245
+ }
246
+
247
+ for (const name of SPRINT_FILENAMES) {
248
+ if (
249
+ listing.some((entry) => entry.type === 'file' && entry.name === name)
250
+ ) {
251
+ return under(dir, name);
252
+ }
253
+ }
254
+ if (depth >= SCAN_MAX_DEPTH) continue;
255
+
256
+ for (const entry of listing) {
257
+ if (entry.type !== 'directory' || SCAN_SKIP.has(entry.name)) continue;
258
+ queue.push({ dir: under(dir, entry.name), depth: depth + 1 });
259
+ }
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Locate the tracking file in the Session's selected workspace: the cached
267
+ * answer while it still holds, then the conventional locations, then a
268
+ * bounded scan.
269
+ * @param readers - the injected workspace readers.
270
+ * @param sessionId - the Session whose workspace to search.
271
+ * @param signal - caller cancellation.
272
+ * @returns the tracking file's path, or null when the workspace has none.
273
+ */
274
+ async function locateSprintPath(readers, sessionId, signal) {
275
+ const cached = locatedSprint.get(sessionId);
276
+ if (
277
+ cached !== undefined &&
278
+ (await fileExists(readers.stat, cached, signal))
279
+ ) {
280
+ return cached;
281
+ }
282
+
283
+ const found =
284
+ (await probeCandidates(readers, signal)) ??
285
+ (await scanForSprint(readers, signal));
286
+
287
+ if (found === null) locatedSprint.delete(sessionId);
288
+ else locatedSprint.set(sessionId, found);
289
+ return found;
290
+ }
291
+
292
+
293
+ /**
294
+ * Story lanes, in the order the board draws them: what is being worked on
295
+ * first, then what is waiting on review, then what is finished, and the
296
+ * not-yet-started stories last. A lane still only appears when a story is
297
+ * actually in that status, so an empty block costs nothing.
298
+ */
299
+ const STATUS_ORDER = [
300
+ 'in-progress',
301
+ 'review',
302
+ 'done',
303
+ 'ready-for-dev',
304
+ 'backlog',
305
+ ];
306
+
307
+
308
+ /**
309
+ * Status -> the badge tone that reads it, drawn from the host's own tag
310
+ * palette: green for finished, amber for something wanting attention, blue
311
+ * for work in flight, and the neutral platform fill for a plain fact. The
312
+ * action and retrospective statuses live here too, so one lookup covers
313
+ * every badge the board draws.
314
+ */
315
+ const STATUS_TONE = {
316
+ backlog: 'neutral',
317
+ 'ready-for-dev': 'neutral',
318
+ 'in-progress': 'info',
319
+ review: 'warning',
320
+ done: 'success',
321
+ open: 'warning',
322
+ optional: 'neutral',
323
+ };
324
+
325
+ /**
326
+ * The tone for a status, defaulting to the neutral fill.
327
+ * @param status - a story, action, or retrospective status.
328
+ * @returns the tag tone.
329
+ */
330
+ function toneOf(status) {
331
+ return STATUS_TONE[status] ?? 'neutral';
332
+ }
333
+
334
+ /** The colour an epic with no recognised status falls back to. */
335
+
336
+ /** The marker a fold header uses while its body is open and closed. */
337
+
338
+ /** How many lines one `workspaceFiles.read` page asks for. */
339
+ const PAGE_LINES = 2000;
340
+
341
+ // --- File addressing ---------------------------------------------------
342
+
343
+ /** Scheme and type prefix of every file address the Sidebar resolves. */
344
+
345
+
346
+
347
+ // --- Reading -----------------------------------------------------------
348
+
349
+ /**
350
+ * Read a whole text file, one page at a time, following `eof`.
351
+ * @param face - the `remote.workspaceFiles` namespace.
352
+ * @param sessionId - scope id the Host resolves the workspace from.
353
+ * @param path - workspace-relative or absolute path.
354
+ * @param signal - caller cancellation.
355
+ * @returns the file's complete text.
356
+ */
357
+ async function readWholeFile(face, sessionId, path, signal) {
358
+ const pages = [];
359
+ let offset = 1;
360
+ for (let guard = 0; guard < 40; guard += 1) {
361
+ const result = await face.read(
362
+ sessionId,
363
+ path,
364
+ { offset, limit: PAGE_LINES },
365
+ signal,
366
+ );
367
+ if (!result.ok) throw result.error;
368
+ const page = result.value;
369
+ if (page.lines > 0 && page.text !== '') pages.push(page.text);
370
+ if (page.eof || page.lines <= 0) break;
371
+ offset += page.lines;
372
+ }
373
+ return pages.join('\n');
374
+ }
375
+
376
+ // --- Parsing -----------------------------------------------------------
377
+
378
+ /**
379
+ * Whether a scalar's quoting is still open, so the next line continues it.
380
+ * @param value - the raw accumulated value.
381
+ * @returns true while an odd number of double quotes has been seen.
382
+ */
383
+ function quotesOpen(value) {
384
+ let count = 0;
385
+ for (let index = 0; index < value.length; index += 1) {
386
+ if (value[index] === '"') count += 1;
387
+ }
388
+ return count % 2 === 1;
389
+ }
390
+
391
+ /**
392
+ * Strip one layer of matching quotes and surrounding space.
393
+ * @param value - a raw scalar.
394
+ * @returns the scalar's text.
395
+ */
396
+ function unquote(value) {
397
+ const trimmed = String(value ?? '').trim();
398
+ if (trimmed.length >= 2) {
399
+ const first = trimmed[0];
400
+ const last = trimmed[trimmed.length - 1];
401
+ if (
402
+ (first === '"' && last === '"') ||
403
+ (first === "'" && last === "'")
404
+ ) {
405
+ return trimmed.slice(1, -1);
406
+ }
407
+ }
408
+ return trimmed;
409
+ }
410
+
411
+ /** `key: value` at column zero: one document-level entry. */
412
+ const TOP_LEVEL = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/;
413
+ /** ` key: value` under `development_status`. */
414
+ const STATUS_ENTRY = /^\s+([^\s:#][^:]*):[ \t]*(.*)$/;
415
+ /** ` - key: value` opening one `action_items` element. */
416
+ const ACTION_START = /^\s*-[ \t]+([A-Za-z_][\w-]*):[ \t]*(.*)$/;
417
+ /** ` key: value` continuing the current `action_items` element. */
418
+ const ACTION_FIELD = /^\s+([A-Za-z_][\w-]*):[ \t]*(.*)$/;
419
+ /** Any line that is indented, so it belongs to the block above it. */
420
+ const INDENTED = /^\s/;
421
+
422
+ /**
423
+ * Whether a document line carries no content.
424
+ * @param line - one raw line.
425
+ * @returns true when the reader should skip it.
426
+ */
427
+ function isSkippable(line) {
428
+ const trimmed = line.trim();
429
+ return trimmed === '' || trimmed.startsWith('#');
430
+ }
431
+
432
+ /**
433
+ * Read the `development_status` map body, a flat run of indented entries.
434
+ * @param lines - the document's lines.
435
+ * @param start - index of the first body line.
436
+ * @param into - the map to fill.
437
+ * @returns the index just past the block.
438
+ */
439
+ function readStatusMap(lines, start, into) {
440
+ let index = start;
441
+ while (index < lines.length) {
442
+ const line = lines[index];
443
+ if (isSkippable(line)) {
444
+ index += 1;
445
+ continue;
446
+ }
447
+ if (!INDENTED.test(line)) break;
448
+ const entry = STATUS_ENTRY.exec(line);
449
+ if (entry !== null) into[entry[1].trim()] = unquote(entry[2]);
450
+ index += 1;
451
+ }
452
+ return index;
453
+ }
454
+
455
+ /**
456
+ * Fold one `action_items` line into the element being read. A quoted value
457
+ * that has not closed yet keeps absorbing the lines under it.
458
+ * @param state - the reader's cursor, current element, and open field.
459
+ * @param line - the line to consume.
460
+ * @param into - the list that completed elements are pushed to.
461
+ */
462
+ function consumeActionLine(state, line, into) {
463
+ const { item, field } = state;
464
+ if (item !== null && field !== null && quotesOpen(item[field])) {
465
+ item[field] = `${item[field]} ${line.trim()}`.trim();
466
+ return;
467
+ }
468
+
469
+ const start = ACTION_START.exec(line);
470
+ if (start !== null) {
471
+ if (item !== null) into.push(item);
472
+ state.item = { [start[1]]: start[2].trim() };
473
+ state.field = start[1];
474
+ return;
475
+ }
476
+
477
+ const next = ACTION_FIELD.exec(line);
478
+ if (next === null || item === null) return;
479
+ item[next[1]] = next[2].trim();
480
+ state.field = next[1];
481
+ }
482
+
483
+ /**
484
+ * Read the `action_items` list body, whose string values fold across lines.
485
+ * @param lines - the document's lines.
486
+ * @param start - index of the first body line.
487
+ * @param into - the list to fill.
488
+ * @returns the index just past the block.
489
+ */
490
+ function readActionItems(lines, start, into) {
491
+ const state = { index: start, item: null, field: null };
492
+ while (state.index < lines.length) {
493
+ const line = lines[state.index];
494
+ if (line.trim() === '') {
495
+ state.index += 1;
496
+ continue;
497
+ }
498
+ if (!INDENTED.test(line)) break;
499
+ consumeActionLine(state, line, into);
500
+ state.index += 1;
501
+ }
502
+ if (state.item !== null) into.push(state.item);
503
+ return state.index;
504
+ }
505
+
506
+ /**
507
+ * Unquote one raw `action_items` element into the shape the board reads.
508
+ * @param raw - the element's fields, still quoted as the file spells them.
509
+ * @returns the normalized action item.
510
+ */
511
+ function normalizeAction(raw) {
512
+ return {
513
+ id: unquote(raw.id),
514
+ epic: unquote(raw.epic),
515
+ action: unquote(raw.action),
516
+ owner: unquote(raw.owner),
517
+ status: unquote(raw.status) || 'open',
518
+ ref: unquote(raw.ref),
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Read the tracked subset of `sprint-status.yaml`: the scalar header, the
524
+ * flat `development_status` map, and the folded `action_items` list. This is
525
+ * deliberately not a general YAML parser -- it accepts exactly the shape the
526
+ * BMAD sprint-planning skill writes, and ignores anything else.
527
+ * @param text - the file's text.
528
+ * @returns the header scalars, status map, and action items.
529
+ */
530
+ function parseSprintStatus(text) {
531
+ const lines = String(text).split(/\r?\n/);
532
+ const meta = {};
533
+ const status = {};
534
+ const actions = [];
535
+ let index = 0;
536
+
537
+ while (index < lines.length) {
538
+ const line = lines[index];
539
+ if (isSkippable(line)) {
540
+ index += 1;
541
+ continue;
542
+ }
543
+ const top = TOP_LEVEL.exec(line);
544
+ if (top === null) {
545
+ index += 1;
546
+ continue;
547
+ }
548
+ if (top[1] === 'development_status') {
549
+ index = readStatusMap(lines, index + 1, status);
550
+ continue;
551
+ }
552
+ if (top[1] === 'action_items') {
553
+ index = readActionItems(lines, index + 1, actions);
554
+ continue;
555
+ }
556
+ meta[top[1]] = unquote(top[2]);
557
+ index += 1;
558
+ }
559
+
560
+ return { meta, status, actions: actions.map(normalizeAction) };
561
+ }
562
+
563
+ /** `## <heading>` opening one legacy ledger entry. */
564
+ const LEDGER_HEADING = /^##[ \t]+(.+?)[ \t]*$/;
565
+ /** `**Source:** value` and its two siblings. */
566
+ const LEDGER_LABEL = /^\*\*(Source|File|Description):\*\*[ \t]*(.*)$/;
567
+ /** A bullet block's own `- key:` line, or an indented `key:` sibling. */
568
+ const LEDGER_FIELD = /^(?:-[ \t]+|[ \t]+)([A-Za-z_]+):[ \t]*(.*)$/;
569
+ /** The `D<n>` number a legacy heading carries. */
570
+ const LEDGER_ID = /^(D\d+)\s*:/;
571
+
572
+ /**
573
+ * Start one empty ledger entry.
574
+ * @param heading - the entry's `## ` heading text, when it had one.
575
+ * @returns a blank entry.
576
+ */
577
+ function blankLedgerEntry(heading) {
578
+ const text = heading ?? '';
579
+ const id = LEDGER_ID.exec(text);
580
+ return {
581
+ id: id === null ? '' : id[1],
582
+ title: text.replace(/^D\d+\s*:[ \t]*/, '').trim(),
583
+ source: '',
584
+ file: '',
585
+ description: '',
586
+ spec: '',
587
+ summary: '',
588
+ evidence: '',
589
+ bullet: false,
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Whether a `source_spec` bullet opens a new entry rather than continuing
595
+ * one: a bullet block is always an entry of its own, and a heading that
596
+ * already carries its own metadata has been closed by the block above it.
597
+ * @param entry - the entry being read.
598
+ * @returns true when the block starts its own entry.
599
+ */
600
+ function closesLedgerEntry(entry) {
601
+ return entry.bullet || entry.spec !== '' || entry.description !== '';
602
+ }
603
+
604
+ /**
605
+ * Apply one bullet-block field to its entry.
606
+ * @param entry - the entry being read.
607
+ * @param key - the field name.
608
+ * @param value - the raw field value.
609
+ */
610
+ function applyLedgerField(entry, key, value) {
611
+ if (key === 'source_spec') {
612
+ entry.bullet = true;
613
+ const spec = value.replace(/`/g, '').trim();
614
+ entry.spec = spec === 'none' ? '' : spec;
615
+ return;
616
+ }
617
+ if (key === 'summary') {
618
+ entry.summary = value.trim();
619
+ if (entry.title === '') entry.title = entry.summary;
620
+ return;
621
+ }
622
+ if (key === 'evidence') entry.evidence = value.trim();
623
+ }
624
+
625
+ /**
626
+ * Consume one ledger line.
627
+ * @param current - the entry being read.
628
+ * @param line - the line to consume.
629
+ * @returns the entry to carry forward, plus any entry this line closed.
630
+ */
631
+ function consumeLedgerLine(current, line) {
632
+ const heading = LEDGER_HEADING.exec(line);
633
+ if (heading !== null) {
634
+ return { entry: blankLedgerEntry(heading[1]), closed: current };
635
+ }
636
+
637
+ const labelled = LEDGER_LABEL.exec(line);
638
+ if (labelled !== null) {
639
+ current[labelled[1].toLowerCase()] = labelled[2].trim();
640
+ return { entry: current, closed: null };
641
+ }
642
+
643
+ const field = LEDGER_FIELD.exec(line);
644
+ if (field === null) return { entry: current, closed: null };
645
+
646
+ if (field[1] === 'source_spec' && closesLedgerEntry(current)) {
647
+ const entry = blankLedgerEntry(null);
648
+ applyLedgerField(entry, field[1], field[2]);
649
+ return { entry, closed: current };
650
+ }
651
+
652
+ applyLedgerField(current, field[1], field[2]);
653
+ return { entry: current, closed: null };
654
+ }
655
+
656
+ /**
657
+ * Read `deferred-work.md`. The ledger mixes two shapes on purpose: the older
658
+ * hand-written `## D<n>: <title>` block carrying `**Source:**`/`**File:**`/
659
+ * `**Description:**` lines, and the shape the build workflow appends for a
660
+ * newly split goal -- a bare `- source_spec:`/`summary:`/`evidence:` bullet
661
+ * block that deliberately has no heading and numbers nothing. Each is one
662
+ * entry, so a bullet block always opens a new one.
663
+ * @param text - the file's text.
664
+ * @returns the deferred entries, in file order.
665
+ */
666
+ function parseDeferredWork(text) {
667
+ const items = [];
668
+ let current = null;
669
+
670
+ for (const line of String(text).split(/\r?\n/)) {
671
+ if (current === null) {
672
+ const heading = LEDGER_HEADING.exec(line);
673
+ if (heading !== null) current = blankLedgerEntry(heading[1]);
674
+ continue;
675
+ }
676
+ const step = consumeLedgerLine(current, line);
677
+ if (step.closed !== null) items.push(step.closed);
678
+ current = step.entry;
679
+ }
680
+
681
+ if (current !== null) items.push(current);
682
+ return items.filter((item) => item.title !== '');
683
+ }
684
+
685
+ // --- Model -------------------------------------------------------------
686
+
687
+ /**
688
+ * Turn a story slug into display text.
689
+ * @param slug - a kebab-case slug.
690
+ * @returns the slug with dashes as spaces and words capitalised.
691
+ */
692
+ function humanize(slug) {
693
+ return String(slug)
694
+ .split('-')
695
+ .filter((word) => word !== '')
696
+ .map((word) =>
697
+ /^\d+$/.test(word) ? word : word[0].toUpperCase() + word.slice(1),
698
+ )
699
+ .join(' ');
700
+ }
701
+
702
+ /** An epic rollup key. */
703
+ const EPIC_KEY = /^epic-(\d+)$/;
704
+ /** An epic retrospective key. */
705
+ const RETRO_KEY = /^epic-(\d+)-retrospective$/;
706
+ /** A story key, `<epic>-<index>-<truncated slug>`. */
707
+ const STORY_KEY = /^(\d+)-(\d+)-(.+)$/;
708
+
709
+ /**
710
+ * Split the `development_status` map into its three kinds of row.
711
+ * @param status - the key -> status map.
712
+ * @returns the epic rollups, the stories, and epic number -> retrospective status.
713
+ */
714
+ function classifyStatus(status) {
715
+ const epics = [];
716
+ const stories = [];
717
+ const retrospectives = new Map();
718
+
719
+ for (const [key, value] of Object.entries(status)) {
720
+ const epic = EPIC_KEY.exec(key);
721
+ if (epic !== null) {
722
+ epics.push({
723
+ number: Number(epic[1]),
724
+ key,
725
+ status: value,
726
+ retro: '',
727
+ total: 0,
728
+ done: 0,
729
+ });
730
+ continue;
731
+ }
732
+ const retro = RETRO_KEY.exec(key);
733
+ if (retro !== null) {
734
+ retrospectives.set(Number(retro[1]), value);
735
+ continue;
736
+ }
737
+ const story = STORY_KEY.exec(key);
738
+ if (story !== null) {
739
+ stories.push({
740
+ key,
741
+ epic: Number(story[1]),
742
+ index: Number(story[2]),
743
+ slug: story[3],
744
+ status: value,
745
+ file: null,
746
+ title: humanize(story[3]),
747
+ deferred: 0,
748
+ });
749
+ }
750
+ }
751
+
752
+ return { epics, stories, retrospectives };
753
+ }
754
+
755
+ /**
756
+ * The spec slugs a story-directory listing holds.
757
+ * @param entries - the directory listing, if it was read.
758
+ * @returns the names with the `spec-` prefix and `.md` suffix removed.
759
+ */
760
+ function specSlugsOf(entries) {
761
+ return (entries ?? [])
762
+ .filter(
763
+ (entry) =>
764
+ entry.type === 'file' &&
765
+ entry.name.startsWith('spec-') &&
766
+ entry.name.endsWith('.md'),
767
+ )
768
+ .map((entry) => entry.name.slice('spec-'.length, -'.md'.length));
769
+ }
770
+
771
+ /**
772
+ * A story key is a truncated prefix of its spec file's slug, so the shortest
773
+ * slug that starts with the key is that story's spec.
774
+ * @param slugs - every spec slug in the directory.
775
+ * @param key - the story's truncated key.
776
+ * @returns the matching slug, or null when the story has no spec yet.
777
+ */
778
+ function bestSpecSlug(slugs, key) {
779
+ const pick = (matches) => {
780
+ let best = null;
781
+ for (const slug of slugs) {
782
+ if (!matches(slug)) continue;
783
+ if (best === null || slug.length < best.length) best = slug;
784
+ }
785
+ return best;
786
+ };
787
+ // Literal prefix first, exactly as before.
788
+ const literal = pick((slug) => slug === key || slug.startsWith(key));
789
+ if (literal !== null) return literal;
790
+ // Fallback for a truncated key. The tracking file caps a slug at 64
791
+ // characters, and the cut can land inside a hyphenated token and drop the
792
+ // separator: `...-resultsc` against the file's `...-results-card-...`. So
793
+ // compare with the separators removed. Story 1-5 in the reference repo is
794
+ // unresolvable without this -- its spec never linked.
795
+ const wanted = normalizeSlug(key);
796
+ return pick((slug) => normalizeSlug(slug).startsWith(wanted));
797
+ }
798
+
799
+ /** A slug reduced to letters and digits, for separator-insensitive matching. */
800
+ function normalizeSlug(value) {
801
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
802
+ }
803
+
804
+ /**
805
+ * Point every story at its spec file and give it a full display title.
806
+ * @param stories - the story list, mutated in place.
807
+ * @param entries - the story directory's listing.
808
+ */
809
+ function linkStoriesToSpecs(stories, entries, storyDir) {
810
+ const slugs = specSlugsOf(entries);
811
+ for (const story of stories) {
812
+ const best = bestSpecSlug(slugs, story.key);
813
+ if (best === null) continue;
814
+ story.file = under(storyDir, `spec-${best}.md`);
815
+ story.title = humanize(best.replace(/^\d+-\d+-/, ''));
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Fill in each epic's retrospective status and story progress.
821
+ * @param epics - the epic list, mutated in place.
822
+ * @param stories - every story.
823
+ * @param retrospectives - epic number -> retrospective status.
824
+ */
825
+ function summarizeEpics(epics, stories, retrospectives) {
826
+ for (const epic of epics) {
827
+ epic.retro = retrospectives.get(epic.number) ?? '';
828
+ const own = stories.filter((story) => story.epic === epic.number);
829
+ epic.total = own.length;
830
+ epic.done = own.filter((story) => story.status === 'done').length;
831
+ // The Status view badges each card with its epic, and colours that badge
832
+ // by the epic's state, so every story carries its epic's status.
833
+ for (const story of own) story.epicStatus = epic.status;
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Count stories per status, keeping a slot for every known status.
839
+ * @param stories - every story.
840
+ * @returns status -> count.
841
+ */
842
+ function countByStatus(stories) {
843
+ const counts = {};
844
+ for (const name of STATUS_ORDER) counts[name] = 0;
845
+ for (const story of stories) {
846
+ counts[story.status] = (counts[story.status] ?? 0) + 1;
847
+ }
848
+ return counts;
849
+ }
850
+
851
+ /**
852
+ * Connect each deferred entry to the story whose spec it names, and count the
853
+ * entries hanging off each story. A ledger entry's `source_spec` is the same
854
+ * workspace path a story card opens, so this link is exact rather than a
855
+ * guess from prose. An entry naming a freeform spec -- one with no
856
+ * `<epic>-<index>-` prefix -- belongs to no story and keeps a null link.
857
+ * @param deferred - the deferred entries, annotated in place.
858
+ * @param stories - every story, already linked to its spec file.
859
+ */
860
+ function linkDeferredToStories(deferred, stories) {
861
+ const byFile = new Map();
862
+ for (const story of stories) {
863
+ if (story.file !== null) byFile.set(story.file, story);
864
+ }
865
+ for (const item of deferred) {
866
+ const story = item.spec === '' ? undefined : byFile.get(item.spec);
867
+ item.story = story ?? null;
868
+ if (story !== undefined) story.deferred += 1;
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Fold the parsed files into the board's model.
874
+ * @param sprintText - `sprint-status.yaml` contents.
875
+ * @param deferredText - `deferred-work.md` contents, or empty.
876
+ * @param entries - the story directory's listing, or empty.
877
+ * @returns the board model.
878
+ */
879
+ function buildModel(sprintText, deferredText, entries, sprintPath) {
880
+ const sprint = parseSprintStatus(sprintText);
881
+ const deferred = parseDeferredWork(deferredText);
882
+ const { epics, stories, retrospectives } = classifyStatus(sprint.status);
883
+ // The tracking file names the directory its spec files live in; only when
884
+ // it is silent do we fall back to sitting beside the tracking file.
885
+ const storyDir = (sprint.meta.story_location ?? '').trim() || dirOf(sprintPath);
886
+
887
+ stories.sort(
888
+ (left, right) => left.epic - right.epic || left.index - right.index,
889
+ );
890
+ linkStoriesToSpecs(stories, entries, storyDir);
891
+ summarizeEpics(epics, stories, retrospectives);
892
+ linkDeferredToStories(deferred, stories);
893
+
894
+ const counts = countByStatus(stories);
895
+ const openActions = sprint.actions.filter(
896
+ (item) => item.status !== 'done',
897
+ );
898
+
899
+ return {
900
+ project: sprint.meta.project ?? '',
901
+ lastUpdated: sprint.meta.last_updated ?? '',
902
+ epics,
903
+ stories,
904
+ counts,
905
+ doneStories: counts.done ?? 0,
906
+ totalStories: stories.length,
907
+ openActions,
908
+ doneActionCount: sprint.actions.length - openActions.length,
909
+ deferred,
910
+ };
911
+ }
912
+
913
+ /**
914
+ * BMAD configuration -- where the modules say their artefacts live.
915
+ *
916
+ * BMAD lets a project move its output folders, so a board that assumes a layout
917
+ * is wrong the moment someone changes one. `_bmad/config.toml` declares the real
918
+ * paths per module, and that declaration is the primary mechanism: the
919
+ * conventional-candidate probe and the bounded scan stay, but only as the
920
+ * fallback for when config is absent or silent.
921
+ *
922
+ * @module dsh-plugin-bmad-sprint-board/config
923
+ */
924
+
925
+ /** The token BMAD writes where the project root belongs. */
926
+ const PROJECT_ROOT = /\{project-root\}\/?/g
927
+
928
+ /**
929
+ * Reduce a config value to a workspace-relative path.
930
+ *
931
+ * The shell reads through the workspace API, which resolves relative paths
932
+ * against the Session's selected workspace -- so `{project-root}` maps to the
933
+ * empty string and the remainder is relative.
934
+ *
935
+ * @param {string} value - the raw config value.
936
+ * @returns {string} the workspace-relative path.
937
+ */
938
+ function resolveProjectPath(value) {
939
+ return String(value).replace(PROJECT_ROOT, '').replace(/^\.?\//, '').replace(/\/+$/, '')
940
+ }
941
+
942
+ /**
943
+ * Parse the module sections of a `config.toml`.
944
+ *
945
+ * A deliberately small reader, not a TOML implementation: BMAD's config is flat
946
+ * `[modules.<name>]` tables of string values, and a full parser would be a
947
+ * dependency the bundle does not have. Anything it cannot understand it skips --
948
+ * a path it fails to read becomes a missing declaration, never a wrong one.
949
+ *
950
+ * @param {string} text - the contents of `_bmad/config.toml`.
951
+ * @returns {Map<string, Map<string, string>>} module name -> key -> value.
952
+ */
953
+ function parseBmadConfig(text) {
954
+ const modules = new Map()
955
+ let current = null
956
+
957
+ for (const raw of String(text).split('\n')) {
958
+ const line = raw.replace(/#.*$/, '').trim()
959
+ if (line === '') continue
960
+
961
+ const section = /^\[modules\.([A-Za-z0-9_-]+)\]$/.exec(line)
962
+ if (section !== null) {
963
+ current = new Map()
964
+ modules.set(section[1], current)
965
+ continue
966
+ }
967
+ if (/^\[/.test(line)) {
968
+ current = null
969
+ continue
970
+ }
971
+ if (current === null) continue
972
+
973
+ const entry = /^([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"$/.exec(line)
974
+ if (entry !== null) current.set(entry[1], entry[2])
975
+ }
976
+
977
+ return modules
978
+ }
979
+
980
+ /**
981
+ * The artefact locations the board reads, in the order it needs them.
982
+ *
983
+ * @param {Map<string, Map<string, string>>} modules - from {@link parseBmadConfig}.
984
+ * @returns {{implementation: string|null, planning: string|null, knowledge: string|null}} workspace-relative paths, or null when undeclared.
985
+ */
986
+ function artifactPaths(modules) {
987
+ const bmm = modules.get('bmm') ?? new Map()
988
+ const pick = (key) => {
989
+ const value = bmm.get(key)
990
+ return value === undefined || value === '' ? null : resolveProjectPath(value)
991
+ }
992
+ return {
993
+ implementation: pick('implementation_artifacts'),
994
+ planning: pick('planning_artifacts'),
995
+ knowledge: pick('project_knowledge'),
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Detectors -- the gaps board's whole reason to exist.
1001
+ *
1002
+ * Every detector is a pure function over the model plus whatever file content
1003
+ * the shell has already read. Two rules hold throughout:
1004
+ *
1005
+ * 1. **Risks are derived.** Nothing here is written back, so nothing here can
1006
+ * be silently overwritten by the framework's own writer.
1007
+ * 2. **Unverifiable is not clean.** A check that could not be evaluated
1008
+ * returns an `unverifiable` entry rather than nothing, because a board
1009
+ * showing nothing must never be confusable with a board that cannot see.
1010
+ *
1011
+ * @module dsh-plugin-bmad-sprint-board/detect
1012
+ */
1013
+
1014
+ /** Severity vocabulary, borrowed from bmad-loop's deferred ledger. */
1015
+ const SEVERITY = ['critical', 'high', 'medium', 'low']
1016
+
1017
+ /**
1018
+ * A gap: something the evidence contradicts.
1019
+ * @typedef {object} Gap
1020
+ * @property {string} id - detector id, e.g. `B1`
1021
+ * @property {string} title - what kind of problem this is
1022
+ * @property {string} severity - one of {@link SEVERITY}
1023
+ * @property {string} subject - the key the reader recognises, e.g. a story key
1024
+ * @property {string} subjectKind - `story` | `epic` | `requirement` | `area`
1025
+ * @property {string} evidence - what was observed, in one sentence
1026
+ * @property {object} action - what to do about it
1027
+ * @property {string} action.kind - `command` | `prompt` | `open`
1028
+ * @property {string} action.label - the button's words
1029
+ * @property {string} action.payload - the string to copy, or the path to open
1030
+ */
1031
+
1032
+ /**
1033
+ * A check that could not be evaluated. Never rendered as clean.
1034
+ * @typedef {object} Unverifiable
1035
+ * @property {string} id - detector id
1036
+ * @property {string} subject - what could not be checked
1037
+ * @property {string} reason - why, naming the artefact that was missing or unreadable
1038
+ * @property {object} action - the prompt that would find out
1039
+ */
1040
+
1041
+ const storyLabel = (story) => story.key
1042
+
1043
+ /**
1044
+ * **B1 -- done but owing.** A story is `done` while deferred entries still name
1045
+ * its spec. Deferred work is, by definition, work that was *not* done, so the
1046
+ * tracking file asserts a completeness the evidence contradicts.
1047
+ *
1048
+ * Fires on real data: story 3-2 in the reference repo.
1049
+ *
1050
+ * @param {object} model - the model from `buildModel`.
1051
+ * A deferral that attributes to no story is *not* claimed here -- an unattributed
1052
+ * entry is the orphan-deferred detector's business, and guessing which story owns
1053
+ * it would be exactly the silent wrongness this board exists to catch.
1054
+ *
1055
+ * @returns {{gaps: Gap[], unverifiable: Unverifiable[]}}
1056
+ */
1057
+ function detectDoneButOwing(model) {
1058
+ const gaps = []
1059
+ const unverifiable = []
1060
+
1061
+ for (const story of model.stories) {
1062
+ if (story.status !== 'done') continue
1063
+ // `linkDeferredToStories` attaches the story OBJECT, not its key
1064
+ const owed = (model.deferred ?? []).filter((item) => item.story?.key === story.key)
1065
+ if (owed.length === 0) continue
1066
+
1067
+ gaps.push({
1068
+ id: 'B1',
1069
+ title: 'done but owing',
1070
+ severity: 'high',
1071
+ subject: story.key,
1072
+ subjectKind: 'story',
1073
+ evidence:
1074
+ `marked done, yet ${owed.length} deferred entr${owed.length === 1 ? 'y' : 'ies'} ` +
1075
+ `still name${owed.length === 1 ? 's' : ''} this story's spec`,
1076
+ action: {
1077
+ kind: 'command',
1078
+ label: 'Decide the deferrals',
1079
+ payload: `/bmad-correct-course -- story ${story.key} is done with ${owed.length} open deferral(s)`,
1080
+ },
1081
+ })
1082
+ }
1083
+
1084
+ return { gaps, unverifiable }
1085
+ }
1086
+
1087
+ /**
1088
+ * **B3 -- review patches never applied.** A story is `done` while unticked
1089
+ * `[Review][Patch]` checkboxes remain in its file. Those are findings a review
1090
+ * triaged into work and nobody did.
1091
+ *
1092
+ * This began as a loose proxy for "a stage was skipped" and inspection made it a
1093
+ * rule: story 1-5 in the reference repo carries exactly two, while story 3-2 has
1094
+ * 53 checkboxes and none unticked. So the tag is the signal -- not unticked boxes
1095
+ * in general, which would flag every optional task anyone ever skipped.
1096
+ *
1097
+ * @param {object} model - the model from `buildModel`.
1098
+ * @param {(path: string) => string|null|undefined} readStory - story-file text,
1099
+ * `null` when the file is known unreadable, `undefined` when not fetched.
1100
+ * @returns {{gaps: Gap[], unverifiable: Unverifiable[]}}
1101
+ */
1102
+ function detectReviewPatches(model, readStory) {
1103
+ const gaps = []
1104
+ const unverifiable = []
1105
+ const PATTERN = /^\s*- \[ \]\s*\[Review\]\[Patch\]\s*(.*)$/
1106
+
1107
+ for (const story of model.stories) {
1108
+ if (story.status !== 'done') continue
1109
+ if (story.file === null) {
1110
+ unverifiable.push({
1111
+ id: 'B3',
1112
+ subject: story.key,
1113
+ reason: 'no story file resolves for this key, so its review state cannot be read',
1114
+ action: {
1115
+ kind: 'prompt',
1116
+ label: 'Find the missing story file',
1117
+ payload:
1118
+ `/bmad-sprint-planning -- story ${story.key} is marked done but no story file resolves for it under the ` +
1119
+ 'declared story_location. Find out whether the file was never created, was renamed, ' +
1120
+ 'or lives elsewhere, and tell me which.',
1121
+ },
1122
+ })
1123
+ continue
1124
+ }
1125
+
1126
+ const text = readStory(story.file)
1127
+ if (text === null || text === undefined) {
1128
+ unverifiable.push({
1129
+ id: 'B3',
1130
+ subject: story.key,
1131
+ reason: `${story.file} could not be read`,
1132
+ action: {
1133
+ kind: 'prompt',
1134
+ label: 'Investigate the unreadable story file',
1135
+ payload:
1136
+ `/bmad-sprint-planning -- I cannot read ${story.file} (story ${story.key}, marked done), so I cannot tell ` +
1137
+ 'whether its review findings were applied. Find out why it is unreadable and what ' +
1138
+ 'its review state actually is.',
1139
+ },
1140
+ })
1141
+ continue
1142
+ }
1143
+
1144
+ const pending = text
1145
+ .split('\n')
1146
+ .map((line) => PATTERN.exec(line))
1147
+ .filter((m) => m !== null)
1148
+ .map((m) => m[1].replace(/\s*\[[^\]]*\]\s*$/, '').trim())
1149
+
1150
+ if (pending.length === 0) continue
1151
+
1152
+ gaps.push({
1153
+ id: 'B3',
1154
+ title: 'review patches never applied',
1155
+ severity: 'high',
1156
+ subject: story.key,
1157
+ subjectKind: 'story',
1158
+ // Phrased like B1's: what was observed, in one sentence, without naming a
1159
+ // single item out of several. A slice of the first one read as a truncation
1160
+ // mid-word and told the reader less than the count does.
1161
+ evidence:
1162
+ `marked done, yet ${pending.length} unticked entr` +
1163
+ `${pending.length === 1 ? 'y' : 'ies'} in the spec`,
1164
+ action: {
1165
+ kind: 'command',
1166
+ label: 'Apply or reject the patches',
1167
+ payload:
1168
+ `/bmad-code-review -- ${pending.length} [Review][Patch] item(s) triaged on ` +
1169
+ `${story.key} were never applied`,
1170
+ },
1171
+ })
1172
+ }
1173
+
1174
+ return { gaps, unverifiable }
1175
+ }
1176
+
1177
+ /**
1178
+ * Run every detector and merge the results, worst first.
1179
+ *
1180
+ * @param {object} model - the model from `buildModel`.
1181
+ * @param {object} [files] - content the shell has already read.
1182
+ * @param {(path: string) => string|null|undefined} [files.readStory] - story-file reader.
1183
+ * @returns {{gaps: Gap[], unverifiable: Unverifiable[]}}
1184
+ */
1185
+ function detect(model, files = {}) {
1186
+ const readStory = files.readStory ?? (() => undefined)
1187
+
1188
+ const results = [detectDoneButOwing(model), detectReviewPatches(model, readStory)]
1189
+
1190
+ const gaps = results.flatMap((r) => r.gaps)
1191
+ const unverifiable = results.flatMap((r) => r.unverifiable)
1192
+ const rank = (s) => SEVERITY.indexOf(s)
1193
+
1194
+ gaps.sort((a, b) => rank(a.severity) - rank(b.severity) || a.subject.localeCompare(b.subject))
1195
+ return { gaps, unverifiable }
1196
+ }
1197
+
1198
+ /**
1199
+ * Planning evidence: requirement coverage and the next-action ladder.
1200
+ *
1201
+ * Both read material BMAD already writes. The coverage map lives in
1202
+ * `planning-artifacts/epics.md`; the ladder is the framework's own six-rule
1203
+ * recommendation, rendered rather than reinvented -- `{skill, story_key, reason}`
1204
+ * is the canonical status view's fourth element, and inventing a rival opinion
1205
+ * about priority is exactly what the research said not to do.
1206
+ *
1207
+ * @module dsh-plugin-bmad-sprint-board/plan
1208
+ */
1209
+
1210
+ /** Statuses in the order the framework ranks them, worst-first for action. */
1211
+ const LADDER = [
1212
+ {
1213
+ status: 'in-progress',
1214
+ skill: 'bmad-build',
1215
+ reason: 'it is in progress -- resume it',
1216
+ ask: 'Resume {}, which is in progress, and take it to review.',
1217
+ },
1218
+ {
1219
+ status: 'review',
1220
+ skill: 'bmad-code-review',
1221
+ reason: 'the implementation is complete and awaiting review',
1222
+ ask: 'Review {}, whose implementation is complete and waiting.',
1223
+ },
1224
+ {
1225
+ status: 'ready-for-dev',
1226
+ skill: 'bmad-build',
1227
+ reason: 'it is ready to start',
1228
+ ask: 'Start {}, which is ready for development.',
1229
+ },
1230
+ {
1231
+ status: 'backlog',
1232
+ skill: 'bmad-build',
1233
+ reason: 'it is the next unstarted story',
1234
+ ask: 'Start {}, the next unstarted story.',
1235
+ },
1236
+ ]
1237
+
1238
+ /**
1239
+ * Parse the FR Coverage Map from an `epics.md`.
1240
+ *
1241
+ * The map is a bullet list, `- FRn (name): Epic 1, Epic 2 (Divider), ...`, and it
1242
+ * is genuinely many-to-many: three requirements in the reference repo cross all
1243
+ * four epics. The epic annotations inside parentheses are deliberately ignored --
1244
+ * only the epic *numbers* matter for coverage.
1245
+ *
1246
+ * @param {string} text - the contents of `epics.md`, or any text holding the map.
1247
+ * @returns {Map<string, {name: string, epics: number[]}>} keyed by `FRn`.
1248
+ */
1249
+ function parseFrCoverage(text) {
1250
+ const coverage = new Map()
1251
+ const heading = /^#{2,4}[ \t]*FR Coverage Map[ \t]*$/m.exec(text)
1252
+ if (heading === null) return coverage
1253
+
1254
+ // the section runs to the next heading of the same or a higher level
1255
+ const rest = text.slice(heading.index + heading[0].length)
1256
+ const next = /^#{1,4}[ \t]+\S/m.exec(rest)
1257
+ const section = next === null ? rest : rest.slice(0, next.index)
1258
+
1259
+ const LINE = /^[ \t]*[-*][ \t]+(FR\d+)[ \t]*\((.+)\)[ \t]*:[ \t]*(.+)$/gm
1260
+ for (const [, id, name, targets] of section.matchAll(LINE)) {
1261
+ const epics = [...targets.matchAll(/Epic[ \t]+(\d+)/gi)].map((m) => Number(m[1]))
1262
+ coverage.set(id, { name: name.trim(), epics: [...new Set(epics)].sort((a, b) => a - b) })
1263
+ }
1264
+ return coverage
1265
+ }
1266
+
1267
+ /** The requirement classes BMAD inventories, and the prefix each uses. */
1268
+ const REQUIREMENT_CLASSES = [
1269
+ { code: 'FR', heading: 'Functional Requirements', label: 'functional' },
1270
+ { code: 'NFR', heading: 'NonFunctional Requirements', label: 'non-functional' },
1271
+ { code: 'AR', heading: 'Additional Requirements', label: 'additional' },
1272
+ { code: 'UX-DR', heading: 'UX Design Requirements', label: 'UX design' },
1273
+ ]
1274
+
1275
+ /**
1276
+ * Every requirement the inventory declares, by class.
1277
+ *
1278
+ * `epics.md` inventories four classes and only functional requirements get a
1279
+ * coverage map. Reading just the map understates the project: on the reference
1280
+ * repository it reports 17 where the inventory holds 35, so the bar measures a
1281
+ * little over half the requirements and says nothing about the rest.
1282
+ *
1283
+ * The inventory form is `PREFIX<n>: Name -- description`, one per line, under a
1284
+ * `### <heading>` section.
1285
+ *
1286
+ * @param {string} text - the contents of `epics.md`.
1287
+ * @returns {Map<string, {id: string, name: string}[]>} keyed by class code.
1288
+ */
1289
+ function parseRequirementInventory(text) {
1290
+ const inventory = new Map()
1291
+ for (const { code, heading } of REQUIREMENT_CLASSES) {
1292
+ const start = new RegExp(`^#{2,4}[ \\t]*${heading}[ \\t]*$`, 'm').exec(text)
1293
+ if (start === null) {
1294
+ inventory.set(code, [])
1295
+ continue
1296
+ }
1297
+ const rest = text.slice(start.index + start[0].length)
1298
+ const next = /^#{1,4}[ \t]+\S/m.exec(rest)
1299
+ const section = next === null ? rest : rest.slice(0, next.index)
1300
+
1301
+ const item = new RegExp(`^[ \\t]*(${code}\\d+)[ \\t]*:[ \\t]*(.+?)[ \\t]*$`, 'gm')
1302
+ inventory.set(
1303
+ code,
1304
+ [...section.matchAll(item)].map((m) => ({
1305
+ id: m[1],
1306
+ // Three classes separate a short name from the description with an
1307
+ // em dash; the functional list carries no separator at all, so the
1308
+ // first clause stands in and the UI truncates what is left.
1309
+ name: m[2].split(/\s+(?:--|\u2014)\s+/)[0].trim().slice(0, 120),
1310
+ // The whole statement, kept because the components a requirement is
1311
+ // about are as often named after the dash as before it -- reading only
1312
+ // the short name left four UX requirements looking undelivered.
1313
+ text: m[2].trim(),
1314
+ })),
1315
+ )
1316
+ }
1317
+ return inventory
1318
+ }
1319
+
1320
+ /**
1321
+ * The requirement bar: how many requirements the epics have actually delivered.
1322
+
1323
+ *
1324
+ * A requirement is **delivered** only when *every* epic that carries it is done.
1325
+ * That is the whole point of the second bar: the story count can read 80% while
1326
+ * most requirements sit half-built across unfinished epics.
1327
+ *
1328
+ * @param {Map<string, {name: string, epics: number[]}>} coverage - from {@link parseFrCoverage}.
1329
+ * @param {Array<{key: string, status: string}>} epics - the model's epics.
1330
+ * @returns {{total: number, delivered: number, partial: object[], unstarted: object[], unverifiable: string|null}}
1331
+ */
1332
+ function requirementBar(coverage, epics, inventory) {
1333
+ if (coverage.size === 0) {
1334
+ return {
1335
+ total: 0,
1336
+ delivered: 0,
1337
+ partial: [],
1338
+ unstarted: [],
1339
+ unverifiable: 'no FR Coverage Map found, so requirement delivery cannot be measured',
1340
+ }
1341
+ }
1342
+
1343
+ const unowned = inventory === undefined ? { count: 0, byClass: [] } : unmappedRequirements(inventory, coverage)
1344
+ const declared = [...(inventory?.values() ?? [])].reduce((n, list) => n + list.length, 0)
1345
+
1346
+ const state = new Map(epics.map((e) => [Number(e.key.replace('epic-', '')), e.status]))
1347
+ const partial = []
1348
+ const unstarted = []
1349
+ let delivered = 0
1350
+
1351
+ for (const [id, { name, epics: carriers }] of coverage) {
1352
+ const known = carriers.filter((n) => state.has(n))
1353
+ if (known.length !== carriers.length || known.length === 0) {
1354
+ partial.push({ id, name, epics: carriers, done: [], unknown: true })
1355
+ continue
1356
+ }
1357
+ const done = known.filter((n) => state.get(n) === 'done')
1358
+ if (done.length === known.length) delivered += 1
1359
+ else if (done.length > 0) partial.push({ id, name, epics: known, done })
1360
+ else unstarted.push({ id, name, epics: known })
1361
+ }
1362
+
1363
+ return {
1364
+ total: coverage.size,
1365
+ delivered,
1366
+ partial,
1367
+ unstarted,
1368
+ unverifiable: null,
1369
+ unmapped: unowned.count,
1370
+ unmappedByClass: unowned.byClass,
1371
+ declared: declared === 0 ? coverage.size : declared,
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * Requirements the inventory declares but no coverage map claims.
1377
+ *
1378
+ * These are the honest remainder: inventoried, unowned by any epic, and cited by
1379
+ * no story, so nothing can verify whether they were delivered. Reporting only
1380
+ * the mapped ones would put a number on the board that reads as complete.
1381
+ *
1382
+ * @param {Map<string, {id: string, name: string}[]>} inventory - from {@link parseRequirementInventory}.
1383
+ * @param {Map<string, {name: string, epics: number[]}>} coverage - from {@link parseFrCoverage}.
1384
+ * @returns {{count: number, byClass: {code: string, label: string, items: object[]}[]}}
1385
+ */
1386
+ function unmappedRequirements(inventory, coverage) {
1387
+ const byClass = []
1388
+ let count = 0
1389
+ for (const { code, label } of REQUIREMENT_CLASSES) {
1390
+ const items = (inventory.get(code) ?? []).filter((item) => !coverage.has(item.id))
1391
+ if (items.length === 0) continue
1392
+ byClass.push({ code, label, items })
1393
+ count += items.length
1394
+ }
1395
+ return { count, byClass }
1396
+ }
1397
+
1398
+ const byPosition = (a, b) => a.epic - b.epic || a.index - b.index || a.key.localeCompare(b.key)
1399
+
1400
+ /**
1401
+ * The next action, from the framework's own ladder.
1402
+ *
1403
+ * Returns exactly one answer with its reason. A board that offers three has not
1404
+ * answered, which is why this is a single recommendation and not a ranked list.
1405
+ *
1406
+ * @param {object} model - the model from `buildModel`.
1407
+ * @returns {{skill: string, key: string|null, label: string, reason: string}}
1408
+ */
1409
+ function nextAction(model) {
1410
+ const stories = [...model.stories].sort(byPosition)
1411
+
1412
+ for (const rung of LADDER) {
1413
+ const match = stories.find((s) => s.status === rung.status)
1414
+ if (match !== undefined) {
1415
+ return {
1416
+ skill: rung.skill,
1417
+ key: match.key,
1418
+ label: match.title ?? match.key,
1419
+ reason: rung.reason,
1420
+ // The board cannot emit a runnable command: a skill is invoked through
1421
+ // `_bmad/scripts/render_skill.py`, which needs the absolute project root
1422
+ // and skill root, and the workspace face exposes neither. So this is a
1423
+ // request, and it says so rather than wearing a command's clothes.
1424
+ action: {
1425
+ kind: 'prompt',
1426
+ label: 'Ask an agent to run it',
1427
+ payload:
1428
+ `/${rung.skill} -- ${rung.ask.replace('{}', match.key)}\n\n` +
1429
+ `Story: ${match.key}\nStatus: ${match.status}\n` +
1430
+ `The sprint board reads this as the next action: ${rung.reason}.`,
1431
+ },
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ const openRetro = model.epics.find((e) => e.retro === 'optional')
1437
+ if (openRetro !== undefined) {
1438
+ return {
1439
+ skill: 'bmad-retrospective',
1440
+ key: openRetro.key,
1441
+ label: openRetro.key,
1442
+ reason: 'all stories are done and this retrospective is still open',
1443
+ action: {
1444
+ kind: 'prompt',
1445
+ label: 'Ask an agent to run it',
1446
+ payload:
1447
+ `/bmad-retrospective -- every story is done and ${openRetro.key} is still open.\n\n` +
1448
+ 'Run the retrospective and record its verdict.',
1449
+ },
1450
+ }
1451
+ }
1452
+
1453
+ return { skill: null, key: null, label: '', reason: 'every story is done', action: null }
1454
+ }
1455
+
1456
+ /**
1457
+ * The two bars. Requirements never shrink their denominator when something
1458
+ * cannot be verified -- an unknown counts against the number, so a board cannot
1459
+ * improve its score by going blind.
1460
+ *
1461
+ * @param {object} model - the model from `buildModel`.
1462
+ * @param {object} requirements - the result of {@link requirementBar}.
1463
+ * @returns {{stories: object, requirements: object}}
1464
+ */
1465
+ function bars(model, requirements) {
1466
+ const total = model.stories.length
1467
+ const done = model.stories.filter((s) => s.status === 'done').length
1468
+ return {
1469
+ stories: { done, total, unverifiable: 0, percent: total === 0 ? 0 : Math.round((done / total) * 100) },
1470
+ requirements: {
1471
+ done: requirements.delivered,
1472
+ total: requirements.total,
1473
+ unverifiable: requirements.unverifiable === null ? 0 : requirements.total,
1474
+ // A missing measurement is not a zero. Without a coverage map the bar has
1475
+ // no denominator at all, and `0 / 0` would read as "nothing to do" rather
1476
+ // than "cannot measure" -- the one confusion this board must not permit.
1477
+ noData: requirements.unverifiable !== null,
1478
+ // declared but unowned: present so the bar cannot read as complete
1479
+ unmapped: requirements.unmapped ?? 0,
1480
+ declared: requirements.declared ?? 0,
1481
+ percent:
1482
+ requirements.total === 0
1483
+ ? 0
1484
+ : Math.round((requirements.delivered / requirements.total) * 100),
1485
+ },
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * UX evidence: whether the design artefacts and the UX requirements are tied to
1491
+ * anything that can verify them.
1492
+ *
1493
+ * UX is a core requirement class (11 `UX-DR` entries on the reference project),
1494
+ * and it produces its own artefacts under `planning-artifacts/ux-designs/`. Both
1495
+ * halves can come adrift: a design run nothing downstream claims is a document
1496
+ * nobody reads, and a UX requirement no artefact names is one nothing can close.
1497
+ * Neither shows up as a gap anywhere else, because the coverage map is
1498
+ * functional-only.
1499
+ *
1500
+ * @module dsh-plugin-bmad-sprint-board/ux
1501
+ */
1502
+
1503
+ /** The directory UX runs live in, relative to the declared planning folder. */
1504
+ const UX_DIR = 'ux-designs'
1505
+
1506
+ /**
1507
+ * The UX design runs present, from a listing of the `ux-designs` directory.
1508
+ *
1509
+ * A run is a directory; the loose files beside them belong to no run.
1510
+ *
1511
+ * @param {Array<{type: string, name: string}>} entries - the directory listing.
1512
+ * @returns {Array<{name: string, file: string}>} the runs, in name order.
1513
+ */
1514
+ function parseUxRuns(entries) {
1515
+ return (entries ?? [])
1516
+ .filter((entry) => entry.type === 'directory')
1517
+ .map((entry) => ({ name: entry.name, file: `${UX_DIR}/${entry.name}` }))
1518
+ .sort((a, b) => a.name.localeCompare(b.name))
1519
+ }
1520
+
1521
+ /**
1522
+ * Which runs the epics document names as an input.
1523
+ *
1524
+ * The check is deliberately narrow: `epics.md` is the document that turns design
1525
+ * into work, so a run it does not cite is a run the plan does not carry forward.
1526
+ * That is a weaker claim than "nothing cites it" and a checkable one, which
1527
+ * matters more.
1528
+ *
1529
+ * @param {Array<{name: string, file: string}>} runs - from {@link parseUxRuns}.
1530
+ * @param {string} epicsText - the contents of the epics document.
1531
+ * @returns {Array<{name: string, file: string, claimed: boolean}>} the runs, marked.
1532
+ */
1533
+ function linkUxRuns(runs, epicsText) {
1534
+ const text = epicsText ?? ''
1535
+ return runs.map((run) => ({ ...run, claimed: text.includes(run.name) }))
1536
+ }
1537
+
1538
+ /**
1539
+ * **U1: a UX design run the plan does not carry.**
1540
+ *
1541
+ * Fires when the epics document names no such run. It is an orphan in the
1542
+ * ordinary sense: produced, and then not built on.
1543
+ *
1544
+ * @param {Array<{name: string, file: string}>} runs - from {@link parseUxRuns}.
1545
+ * @param {string} epicsText - the contents of the epics document.
1546
+ * @returns {{gaps: object[], unverifiable: object[]}}
1547
+ */
1548
+ function detectUxRuns(runs, epicsText) {
1549
+ const gaps = []
1550
+ const unverifiable = []
1551
+
1552
+ if (runs.length === 0) {
1553
+ // No runs at all is only worth saying when the inventory asks for UX work.
1554
+ return { gaps, unverifiable }
1555
+ }
1556
+ if ((epicsText ?? '').trim() === '') {
1557
+ unverifiable.push({
1558
+ id: 'U1',
1559
+ subject: `${runs.length} UX design run${runs.length === 1 ? '' : 's'}`,
1560
+ reason: 'the epics document could not be read, so no run can be checked against the plan',
1561
+ action: {
1562
+ kind: 'prompt',
1563
+ label: 'Find the epics document',
1564
+ payload:
1565
+ '/bmad-correct-course -- the UX design runs exist but the epics document could not be read, so I cannot tell ' +
1566
+ 'which of them the plan carries forward. Find out whether epics.md moved, and where.',
1567
+ },
1568
+ })
1569
+ return { gaps, unverifiable }
1570
+ }
1571
+
1572
+ for (const run of linkUxRuns(runs, epicsText)) {
1573
+ if (run.claimed) continue
1574
+ gaps.push({
1575
+ id: 'U1',
1576
+ title: 'UX run not carried into the plan',
1577
+ severity: 'medium',
1578
+ subject: run.name,
1579
+ subjectKind: 'area',
1580
+ evidence: 'the epics document names no input from this UX design run',
1581
+ action: {
1582
+ kind: 'prompt',
1583
+ label: 'Ask whether it still applies',
1584
+ payload:
1585
+ `/bmad-correct-course -- the UX design run ${run.name} exists, and the epics document declares inputs from ` +
1586
+ 'no part of it. Tell me whether its decisions are already covered elsewhere, whether ' +
1587
+ 'it should feed the plan, or whether it is superseded and can be retired.',
1588
+ },
1589
+ })
1590
+ }
1591
+
1592
+ return { gaps, unverifiable }
1593
+ }
1594
+
1595
+ /**
1596
+ * Component identifiers a piece of prose names.
1597
+ *
1598
+ * A UX requirement describes components by name -- `ValueField`,
1599
+ * `SeriesRowControls` -- and story files name the same components because they
1600
+ * build them. That shared vocabulary is the only join between a design
1601
+ * requirement and the epic that delivers it: the ids are never cross-referenced.
1602
+ *
1603
+ * The shape matters. A capitalised word is usually just a sentence starting, so
1604
+ * the filter takes PascalCase with an internal capital: `ValueField` qualifies,
1605
+ * `Component` and `HTML` do not. Without it, "Error" links to nineteen stories
1606
+ * and every requirement appears covered.
1607
+ *
1608
+ * @param {string} text - prose to scan.
1609
+ * @returns {string[]} the identifiers, sorted.
1610
+ */
1611
+ function componentNames(text) {
1612
+ const words = String(text ?? '').match(/\b[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+\b/g) ?? []
1613
+ return [...new Set(words)].sort()
1614
+ }
1615
+
1616
+ /**
1617
+ * Which stories and epics carry each UX requirement.
1618
+ *
1619
+ * The link is derived, never declared: a requirement names components, a story
1620
+ * names the same components, and the story knows its epic. It is a claim about
1621
+ * evidence, so the caller can see the components it rests on.
1622
+ *
1623
+ * @param {Map<string, {id: string, name: string}[]>} inventory - from `parseRequirementInventory`.
1624
+ * @param {Array<{key: string, epic: number, index: number, title: string, file: string|null}>} stories - the model's stories.
1625
+ * @param {Map<string, string>} storyText - story path to contents.
1626
+ * @returns {Map<string, {components: string[], stories: string[], epics: number[]}>} keyed by requirement id.
1627
+ */
1628
+ function linkUxToEpics(inventory, stories, storyText) {
1629
+ const links = new Map()
1630
+ const byFile = new Map(stories.map((story) => [story.file, story]))
1631
+
1632
+ for (const item of inventory?.get('UX-DR') ?? []) {
1633
+ const components = componentNames(item.text ?? item.name)
1634
+ const hits = new Map()
1635
+
1636
+ for (const [file, text] of storyText ?? []) {
1637
+ const story = byFile.get(file)
1638
+ if (story === undefined) continue
1639
+ for (const component of components) {
1640
+ if (!new RegExp(`\\b${component}\\b`).test(text)) continue
1641
+ if (!hits.has(story.key)) hits.set(story.key, { story, components: new Set() })
1642
+ hits.get(story.key).components.add(component)
1643
+ }
1644
+ }
1645
+
1646
+ const matched = [...hits.values()]
1647
+ links.set(item.id, {
1648
+ components,
1649
+ stories: matched.map((hit) => hit.story.key),
1650
+ epics: [...new Set(matched.map((hit) => hit.story.epic))].sort((a, b) => a - b),
1651
+ })
1652
+ }
1653
+
1654
+ return links
1655
+ }
1656
+
1657
+ /**
1658
+ * **U2: a UX requirement nothing names.**
1659
+ *
1660
+ * Fires when no story file cites the requirement id. The reference project names
1661
+ * all 11 of its `UX-DR` entries exclusively in two retrospective documents, so
1662
+ * the work they describe is delivered without any story claiming it.
1663
+ *
1664
+ * @param {Map<string, {id: string, name: string}[]>} inventory - from `parseRequirementInventory`.
1665
+ * @param {string[]} storyTexts - the text of every story file the shell has read.
1666
+ * @returns {{gaps: object[], unverifiable: object[]}}
1667
+ */
1668
+ function detectUxRequirements(inventory, stories, storyText) {
1669
+ const items = inventory?.get('UX-DR') ?? []
1670
+ if (items.length === 0) return { gaps: [], unverifiable: [] }
1671
+
1672
+ if ((storyText?.size ?? 0) === 0) {
1673
+ return {
1674
+ gaps: [],
1675
+ unverifiable: [
1676
+ {
1677
+ id: 'U2',
1678
+ subject: `${items.length} UX design requirements`,
1679
+ reason: 'no story file was readable, so none can be checked for the work it describes',
1680
+ action: {
1681
+ kind: 'prompt',
1682
+ label: 'Ask which stories carry the UX work',
1683
+ payload:
1684
+ `/bmad-correct-course -- the inventory declares ${items.length} UX design requirements and I could read ` +
1685
+ 'no story file, so I cannot tell which of them any story carries. Find out which ' +
1686
+ 'stories carry this work.',
1687
+ },
1688
+ },
1689
+ ],
1690
+ }
1691
+ }
1692
+
1693
+ const links = linkUxToEpics(inventory, stories, storyText)
1694
+ const unlinked = items.filter((item) => (links.get(item.id)?.epics.length ?? 0) === 0)
1695
+ if (unlinked.length === 0) return { gaps: [], unverifiable: [] }
1696
+
1697
+ return {
1698
+ gaps: [
1699
+ {
1700
+ id: 'U2',
1701
+ title: 'UX requirement no epic delivers',
1702
+ severity: 'high',
1703
+ subject: `${unlinked.length} of ${items.length} UX design requirements`,
1704
+ subjectKind: 'requirement',
1705
+ evidence:
1706
+ 'no story names the components ' +
1707
+ `${unlinked.map((item) => item.id).join(', ')} describe`,
1708
+ action: {
1709
+ kind: 'prompt',
1710
+ label: 'Ask where this UX work went',
1711
+ payload:
1712
+ `/bmad-correct-course -- ${unlinked.length} of ${items.length} UX design requirements name components that no ` +
1713
+ `story mentions: ${unlinked
1714
+ .map((item) => `${item.id} (${componentNames(item.text ?? item.name).join(', ') || item.name})`)
1715
+ .join('; ')}. Tell me for each whether the work shipped under another name, was ` +
1716
+ 'dropped, or still needs an epic.',
1717
+ },
1718
+ },
1719
+ ],
1720
+ unverifiable: [],
1721
+ }
1722
+ }
1723
+
1724
+ /**
1725
+ * The requirements view: every requirement the project declares, grouped by
1726
+ * class, each carrying the strongest state its evidence supports.
1727
+ *
1728
+ * Four classes are inventoried and only functional requirements get a coverage
1729
+ * map. That asymmetry is the point of this view: it does not pretend the other
1730
+ * three are unstarted, and it does not hide them. A requirement with no evidence
1731
+ * path reads as unverifiable, which is a different thing from not done.
1732
+ *
1733
+ * @module dsh-plugin-bmad-sprint-board/requirements
1734
+ */
1735
+
1736
+
1737
+
1738
+ /** The states a requirement row can carry. */
1739
+ const REQ_STATE = {
1740
+ done: 'done',
1741
+ partial: 'partial',
1742
+ todo: 'todo',
1743
+ unknown: 'unknown',
1744
+ }
1745
+
1746
+ /**
1747
+ * How many acceptance criteria a story file states.
1748
+ *
1749
+ * They are the bullets under `**Acceptance Criteria:**` inside
1750
+ * `## Tasks & Acceptance`. They carry no checkbox of their own -- verification
1751
+ * is by test, not by ticking -- so a story's own status is the only completion
1752
+ * signal that exists for them, and the view says so rather than inventing one.
1753
+ *
1754
+ * @param {string} text - a story file's contents.
1755
+ * @returns {number} the criteria stated, or 0 when the section is absent.
1756
+ */
1757
+ function countAcceptance(text) {
1758
+ if (typeof text !== 'string' || text === '') return 0
1759
+
1760
+ // Walked line by line rather than matched. A regex terminator of
1761
+ // `(?=^## |$)` looks right and is not: under the `m` flag `$` matches at the
1762
+ // end of the heading's own line, so the section came out empty and every
1763
+ // story reported zero criteria.
1764
+ let inSection = false
1765
+ let inCriteria = false
1766
+ let count = 0
1767
+
1768
+ for (const line of text.split('\n')) {
1769
+ if (/^##[ \t]/.test(line)) {
1770
+ inSection = /^##[ \t]+Tasks & Acceptance[ \t]*$/.test(line)
1771
+ inCriteria = false
1772
+ continue
1773
+ }
1774
+ if (!inSection) continue
1775
+ if (/^\*\*[^*]+\*\*[ \t]*$/.test(line)) {
1776
+ inCriteria = /^\*\*Acceptance Criteria:\*\*[ \t]*$/.test(line)
1777
+ continue
1778
+ }
1779
+ if (inCriteria && /^[ \t]*[-*][ \t]+\S/.test(line)) count += 1
1780
+ }
1781
+
1782
+ return count
1783
+ }
1784
+
1785
+ /**
1786
+ * Functional requirements, resolved through the coverage map and epic statuses.
1787
+ *
1788
+ * A requirement is done when every epic claiming it is done, not started when
1789
+ * none has begun, and partial in between -- the same reading the summary bar
1790
+ * uses, so the two can never disagree.
1791
+ *
1792
+ * @param {Map<string, {name: string, epics: number[]}>} coverage - from `parseFrCoverage`.
1793
+ * @param {Array<{key: string, status: string}>} epics - the model's epics.
1794
+ * @returns {Array<object>} rows.
1795
+ */
1796
+ function functionalRows(coverage, epics) {
1797
+ const status = new Map(epics.map((e) => [Number(String(e.key).replace('epic-', '')), e.status]))
1798
+ const rows = []
1799
+ for (const [id, entry] of coverage) {
1800
+ const states = entry.epics.map((n) => status.get(n) ?? 'backlog')
1801
+ const all = states.length > 0 && states.every((s) => s === 'done')
1802
+ const any = states.some((s) => s !== 'backlog')
1803
+ rows.push({
1804
+ id,
1805
+ name: entry.name,
1806
+ state: all ? REQ_STATE.done : any ? REQ_STATE.partial : REQ_STATE.todo,
1807
+ note: states.length === 0 ? 'no epic claims it' : `epic ${entry.epics.join(', ')}`,
1808
+ })
1809
+ }
1810
+ return rows
1811
+ }
1812
+
1813
+ /**
1814
+ * Acceptance criteria, one row per story that states any.
1815
+ *
1816
+ * The row's state is the story's, because an individual criterion has no
1817
+ * completion marker. This is a proxy and the note names it as one.
1818
+ *
1819
+ * @param {Array<{key: string, title: string, status: string, file: string|null}>} stories - the model's stories.
1820
+ * @param {Map<string, string>} storyText - story path to contents.
1821
+ * @returns {Array<object>} rows, in board order.
1822
+ */
1823
+ function acceptanceRows(stories, storyText) {
1824
+ const rows = []
1825
+ for (const story of stories) {
1826
+ const text = story.file === null ? undefined : storyText.get(story.file)
1827
+ const count = countAcceptance(text ?? '')
1828
+ if (count === 0) continue
1829
+ rows.push({
1830
+ id: `${story.epic}.${story.index}`,
1831
+ name: story.title,
1832
+ state:
1833
+ story.status === 'done'
1834
+ ? REQ_STATE.done
1835
+ : story.status === 'backlog'
1836
+ ? REQ_STATE.todo
1837
+ : REQ_STATE.partial,
1838
+ note: `${count} criteria, state follows the story`,
1839
+ })
1840
+ }
1841
+ return rows
1842
+ }
1843
+
1844
+ /**
1845
+ * Requirements from a class with no coverage map.
1846
+ *
1847
+ * No epic claims them, no story cites them, and no gate reports on them, so
1848
+ * there is nothing to read a state from. They are listed as unverifiable rather
1849
+ * than as outstanding work, because the board cannot tell the two apart and
1850
+ * saying "not done" would be a guess.
1851
+ *
1852
+ * @param {Array<{id: string, name: string}>} items - the inventory's entries.
1853
+ * @param {string} why - what is missing, short enough for a row.
1854
+ * @returns {Array<object>} rows.
1855
+ */
1856
+ function unverifiableRows(items, why) {
1857
+ return items.map((item) => ({
1858
+ id: item.id,
1859
+ name: item.name,
1860
+ state: REQ_STATE.unknown,
1861
+ note: why,
1862
+ }))
1863
+ }
1864
+
1865
+ /**
1866
+ * Every requirement, grouped into the lanes the view renders.
1867
+ *
1868
+ * @param {object} input - the parsed artefacts.
1869
+ * @param {string} input.epicsText - the epics document, for the inventory.
1870
+ * @param {Map<string, {name: string, epics: number[]}>} input.coverage - the FR coverage map.
1871
+ * @param {Array<object>} input.epics - the model's epics.
1872
+ * @param {Array<object>} input.stories - the model's stories.
1873
+ * @param {Map<string, string>} [input.storyText] - story path to contents.
1874
+ * @returns {{lanes: object[], totals: object}} the lanes and their totals.
1875
+ */
1876
+ function requirementsView(input) {
1877
+ // Declared before the lanes: three of them read an epic's status, and one
1878
+ // that sits above this line fails at render with a dead-zone error.
1879
+ const epicStatus = new Map(
1880
+ (input.epics ?? []).map((e) => [Number(String(e.key).replace('epic-', '')), e.status]),
1881
+ )
1882
+
1883
+ const inventory = parseRequirementInventory(input.epicsText ?? '')
1884
+ const storyText = input.storyText ?? new Map()
1885
+
1886
+ const functional = functionalRows(input.coverage ?? new Map(), input.epics ?? [])
1887
+ const acceptance = acceptanceRows(input.stories ?? [], storyText)
1888
+
1889
+ const lanes = [
1890
+ { code: 'FR', label: 'functional', rows: functional, verifiable: true },
1891
+ { code: 'AC', label: 'acceptance', rows: acceptance, verifiable: true },
1892
+ ]
1893
+
1894
+ // Architecture decisions are cited by id, so this lane reads like the
1895
+ // functional one: a decision is delivered when the epics citing it are.
1896
+ const decisions = input.decisions ?? new Map()
1897
+ if (decisions.size > 0) {
1898
+ lanes.push({
1899
+ code: 'AD',
1900
+ label: 'architecture',
1901
+ verifiable: true,
1902
+ rows: [...decisions.entries()].map(([id, link]) => {
1903
+ const states = link.epics.map((n) => epicStatus.get(n) ?? 'backlog')
1904
+ return {
1905
+ id,
1906
+ name: input.decisionTitles?.get(id) ?? '',
1907
+ state:
1908
+ link.epics.length === 0
1909
+ ? REQ_STATE.unknown
1910
+ : states.every((state) => state === 'done')
1911
+ ? REQ_STATE.done
1912
+ : states.some((state) => state !== 'backlog')
1913
+ ? REQ_STATE.partial
1914
+ : REQ_STATE.todo,
1915
+ note:
1916
+ link.epics.length === 0
1917
+ ? 'no story cites it'
1918
+ : `epic ${link.epics.join(', ')} across ${link.stories.length} stor${link.stories.length === 1 ? 'y' : 'ies'}`,
1919
+ }
1920
+ }),
1921
+ })
1922
+ }
1923
+
1924
+ for (const { code, label } of REQUIREMENT_CLASSES) {
1925
+ if (code === 'FR') continue
1926
+ const items = inventory.get(code) ?? []
1927
+ if (items.length === 0) continue
1928
+
1929
+ // UX is the one class with a join to the plan: a requirement names the
1930
+ // components it is about, and a story names them because it builds them. So
1931
+ // its rows read like functional ones instead of as a wall of unknowns.
1932
+ if (code === 'UX-DR') {
1933
+ const links = input.uxLinks ?? new Map()
1934
+ lanes.push({
1935
+ code,
1936
+ label,
1937
+ verifiable: true,
1938
+ rows: items.map((item) => {
1939
+ const link = links.get(item.id)
1940
+ const epics = link?.epics ?? []
1941
+ const states = epics.map((n) => epicStatus.get(n) ?? 'backlog')
1942
+ const done = states.length > 0 && states.every((state) => state === 'done')
1943
+ return {
1944
+ id: item.id,
1945
+ name: item.name,
1946
+ state:
1947
+ epics.length === 0
1948
+ ? REQ_STATE.unknown
1949
+ : done
1950
+ ? REQ_STATE.done
1951
+ : states.some((state) => state !== 'backlog')
1952
+ ? REQ_STATE.partial
1953
+ : REQ_STATE.todo,
1954
+ note:
1955
+ epics.length === 0
1956
+ ? 'no story names the components it describes'
1957
+ : `epic ${epics.join(', ')} via ${link.components.slice(0, 2).join(', ')}`,
1958
+ }
1959
+ }),
1960
+ })
1961
+ continue
1962
+ }
1963
+
1964
+ lanes.push({
1965
+ code,
1966
+ label,
1967
+ rows: unverifiableRows(items, 'no coverage map, no story cites it'),
1968
+ verifiable: false,
1969
+ })
1970
+ }
1971
+
1972
+ const count = (state) =>
1973
+ lanes.reduce((n, lane) => n + lane.rows.filter((row) => row.state === state).length, 0)
1974
+ const declared = lanes.reduce((n, lane) => n + lane.rows.length, 0)
1975
+
1976
+ return {
1977
+ lanes,
1978
+ totals: {
1979
+ declared,
1980
+ done: count(REQ_STATE.done),
1981
+ partial: count(REQ_STATE.partial),
1982
+ todo: count(REQ_STATE.todo),
1983
+ unknown: count(REQ_STATE.unknown),
1984
+ },
1985
+ }
1986
+ }
1987
+
1988
+ /**
1989
+ * What a spec file says about itself: its title, its tasks, and how many are
1990
+ * ticked.
1991
+ *
1992
+ * A standalone spec is one no tracking key claims, so the sprint file knows
1993
+ * nothing about it and the file is the only source. Its checkboxes are the only
1994
+ * progress signal that exists.
1995
+ *
1996
+ * @param {string} text - a spec file's contents.
1997
+ * @returns {{title: string, tasks: number, ticked: number, criteria: number}} the reading.
1998
+ */
1999
+ function parseSpecProgress(text) {
2000
+ const body = typeof text === 'string' ? text : ''
2001
+ const boxes = [...body.matchAll(/^[ \t]*- \[([ xX])\]/gm)]
2002
+ const title = /^#[ \t]+(.+?)[ \t]*$/m.exec(body)
2003
+ return {
2004
+ title: title === null ? '' : title[1].trim(),
2005
+ tasks: boxes.length,
2006
+ ticked: boxes.filter((match) => match[1].toLowerCase() === 'x').length,
2007
+ criteria: countAcceptance(body),
2008
+ }
2009
+ }
2010
+
2011
+ /**
2012
+ * The standalone specs, split by whether anything is left to do.
2013
+ *
2014
+ * These are the specs no story in the sprint file claims: refactors, style
2015
+ * extractions, migrations. Most are finished, which is the point of showing
2016
+ * them together -- the interesting ones are the few still open, and a spec
2017
+ * written and never started is invisible everywhere else on the board because
2018
+ * no tracking key exists to hang it on.
2019
+ *
2020
+ * @param {Array<{file: string, text: string}>} specs - the unclaimed spec files.
2021
+ * @returns {{lanes: object[], totals: object}} the lanes and their counts.
2022
+ */
2023
+ function standaloneView(specs) {
2024
+ const rows = (specs ?? []).map((spec) => {
2025
+ const progress = parseSpecProgress(spec.text)
2026
+ const slug = spec.file.split('/').pop().replace(/^spec-/, '').replace(/\.md$/, '')
2027
+ const state =
2028
+ progress.tasks === 0
2029
+ ? REQ_STATE.unknown
2030
+ : progress.ticked === progress.tasks
2031
+ ? REQ_STATE.done
2032
+ : progress.ticked === 0
2033
+ ? REQ_STATE.todo
2034
+ : REQ_STATE.partial
2035
+ return {
2036
+ id: slug,
2037
+ name: progress.title === '' ? slug : progress.title,
2038
+ state,
2039
+ note: `${progress.ticked} of ${progress.tasks} tasks, ${progress.criteria} criteria`,
2040
+ file: spec.file,
2041
+ }
2042
+ })
2043
+
2044
+ rows.sort((a, b) => a.state.localeCompare(b.state) || a.id.localeCompare(b.id))
2045
+ const open = rows.filter((row) => row.state !== REQ_STATE.done)
2046
+ const finished = rows.filter((row) => row.state === REQ_STATE.done)
2047
+
2048
+ const lanes = [
2049
+ { code: 'OPEN', label: 'still open', rows: open, verifiable: true },
2050
+ { code: 'DONE', label: 'finished', rows: finished, verifiable: true },
2051
+ ].filter((lane) => lane.rows.length > 0)
2052
+
2053
+ return {
2054
+ lanes,
2055
+ totals: { declared: rows.length, open: open.length, done: finished.length },
2056
+ }
2057
+ }
2058
+
2059
+ /**
2060
+ * Planning-artefact evidence: whether the architecture, brief and PRD are tied
2061
+ * to the work, and to what.
2062
+ *
2063
+ * The three carry different proofs, so they are read differently:
2064
+ *
2065
+ * - **Architecture decisions** are cited by id. Story files name `AD-3` when
2066
+ * they implement it, which is an exact link and needs no guessing.
2067
+ * - **Brief and PRD** are upstream documents with no vocabulary of their own.
2068
+ * The only link is the one the plan writes down, in the epics document's
2069
+ * `inputDocuments`.
2070
+ *
2071
+ * The trap is that this repository runs more than one planning track. `prd.md`
2072
+ * is named by no part of the tools plan because it belongs to the i18n plan,
2073
+ * which is correct and would still look like an orphan to a naive check. So a
2074
+ * document is only reported when nothing in the plan carries it, and the finding
2075
+ * says which plan was asked.
2076
+ *
2077
+ * @module dsh-plugin-bmad-sprint-board/planning
2078
+ */
2079
+
2080
+ /** A directory in the planning folder that holds dated artefacts. */
2081
+ const ARTEFACT_FAMILIES = ['architecture', 'briefs', 'ux-designs', 'research', 'epics-reviews']
2082
+
2083
+ /**
2084
+ * The architecture decisions a spine declares.
2085
+ *
2086
+ * `### AD-3 - Shopify Metaobject CMS Backing`, with either a hyphen or an em
2087
+ * dash after the id.
2088
+ *
2089
+ * @param {string} text - the contents of an architecture spine.
2090
+ * @returns {Array<{id: string, title: string}>} the decisions, in document order.
2091
+ */
2092
+ function parseArchitectureDecisions(text) {
2093
+ const found = String(text ?? '').matchAll(/^#{2,4}[ \t]+(AD-\d+)[ \t]*[-:\u2014]+[ \t]*(.+?)[ \t]*$/gm)
2094
+ return [...found].map((match) => ({ id: match[1], title: match[2].trim() }))
2095
+ }
2096
+
2097
+ /**
2098
+ * Which stories and epics cite each decision.
2099
+ *
2100
+ * A story names `AD-3` because it is implementing that decision, so this is a
2101
+ * declared link rather than an inferred one. A decision no story cites is
2102
+ * reported, but not as undelivered: `AD-10` names the tri-state parser contract
2103
+ * and story 1.3 is the tri-state parser, so the work can be plainly done while
2104
+ * the id goes unquoted. The board cannot tell those apart, and says so.
2105
+ *
2106
+ * @param {Array<{id: string, title: string}>} decisions - from {@link parseArchitectureDecisions}.
2107
+ * @param {Array<{key: string, epic: number, index: number}>} stories - the model's stories.
2108
+ * @param {Map<string, string>} storyText - story path to contents.
2109
+ * @returns {Map<string, {stories: string[], epics: number[]}>} keyed by decision id.
2110
+ */
2111
+ function linkDecisionsToEpics(decisions, stories, storyText) {
2112
+ const byFile = new Map(stories.map((story) => [story.file, story]))
2113
+ const links = new Map()
2114
+
2115
+ for (const decision of decisions) {
2116
+ const hit = []
2117
+ for (const [file, text] of storyText ?? []) {
2118
+ const story = byFile.get(file)
2119
+ if (story === undefined) continue
2120
+ if (new RegExp(`\\b${decision.id}\\b`).test(text)) hit.push(story)
2121
+ }
2122
+ links.set(decision.id, {
2123
+ stories: hit.map((story) => story.key),
2124
+ epics: [...new Set(hit.map((story) => story.epic))].sort((a, b) => a - b),
2125
+ })
2126
+ }
2127
+
2128
+ return links
2129
+ }
2130
+
2131
+ /**
2132
+ * The documents the plan declares as its inputs.
2133
+ *
2134
+ * Read from the epics document's `inputDocuments` list, which is the only place
2135
+ * a brief or a PRD is tied to the work.
2136
+ *
2137
+ * @param {string} epicsText - the contents of the epics document.
2138
+ * @returns {string[]} the declared paths, as written, since a caller opens them.
2139
+ */
2140
+ function parseDeclaredInputs(epicsText) {
2141
+ const block = /^inputDocuments:[ \t]*\n((?:[ \t]*-[ \t]+.*\n)+)/m.exec(String(epicsText ?? ''))
2142
+ if (block === null) return []
2143
+ return [...block[1].matchAll(/^[ \t]*-[ \t]*['"]?(.+?)['"]?[ \t]*$/gm)]
2144
+ // The block runs to the closing `---`, which the list pattern also matches.
2145
+ .map((match) => match[1].trim())
2146
+ .filter((path) => path !== '' && !/^-+$/.test(path))
2147
+ .map((path) => path.replace(/^['"]|['"]$/g, ''))
2148
+ }
2149
+
2150
+ /**
2151
+ * Artefact directories present in the planning folder.
2152
+ *
2153
+ * @param {Array<{type: string, name: string}>} entries - the planning listing.
2154
+ * @returns {string[]} the families present, in {@link ARTEFACT_FAMILIES} order.
2155
+ */
2156
+ function artefactFamilies(entries) {
2157
+ const dirs = new Set(
2158
+ (entries ?? []).filter((entry) => entry.type === 'directory').map((entry) => entry.name),
2159
+ )
2160
+ return ARTEFACT_FAMILIES.filter((family) => dirs.has(family))
2161
+ }
2162
+
2163
+ /**
2164
+ * Document-shaped markers a reader can recognise as one artefact or another.
2165
+ *
2166
+ * @param {string} path - a declared input path.
2167
+ * @returns {string|null} the family it belongs to, or null.
2168
+ */
2169
+ function familyOf(path) {
2170
+ const text = String(path ?? '').toLowerCase()
2171
+ if (text.includes('architecture')) return 'architecture'
2172
+ if (text.includes('brief')) return 'briefs'
2173
+ if (text.includes('prd')) return 'prd'
2174
+ if (text.includes('ux-design')) return 'ux-designs'
2175
+ if (text.includes('research')) return 'research'
2176
+ return null
2177
+ }
2178
+
2179
+ /**
2180
+ * The composed entry: read -> parse -> model -> detect -> gaps -> actions.
2181
+ *
2182
+ * Kept in its own module so the build can inline it: the shell needs the
2183
+ * function, not the module graph, and `build.mjs` strips the imports below
2184
+ * because every symbol is already in scope once the pieces are concatenated.
2185
+ *
2186
+ * @module dsh-plugin-bmad-sprint-board/analyse
2187
+ */
2188
+
2189
+
2190
+
2191
+
2192
+
2193
+
2194
+
2195
+ /**
2196
+ * Everything the board renders, from content the shell has already read.
2197
+ *
2198
+ * One entry point on purpose: the pieces have an order -- parse, model, detect,
2199
+ * measure -- and a shell that reassembles them itself will eventually get the
2200
+ * order wrong.
2201
+ *
2202
+ * @param {object} input - the workspace content.
2203
+ * @param {string} input.sprintText - the tracking file's text.
2204
+ * @param {string} [input.deferredText] - the deferred ledger's text.
2205
+ * @param {Array<{type: string, name: string}>} [input.entries] - the story directory listing.
2206
+ * @param {string} input.sprintPath - the tracking file's path, for relative resolution.
2207
+ * @param {string} [input.epicsText] - `planning-artifacts/epics.md`, for the coverage map.
2208
+ * @param {(path: string) => string|null|undefined} [input.readStory] - story-file reader.
2209
+ * @param {Array<{type: string, name: string}>} [input.uxRuns] - the `ux-designs` listing.
2210
+ * @param {string[]} [input.storyTexts] - the text of every story file read, for citation checks.
2211
+ * @param {string} [input.spineText] - the architecture spine, for its decisions.
2212
+ * @param {Array<{type: string, name: string}>} [input.planningEntries] - the planning folder listing.
2213
+ * @param {string} [input.spineText] - the architecture spine, for its decisions.
2214
+ * @param {Array<{type: string, name: string}>} [input.planningEntries] - the planning folder listing.
2215
+ * @param {Array<{type: string, name: string}>} [input.uxRuns] - the `ux-designs` listing.
2216
+ * @param {string[]} [input.storyTexts] - the text of every story file read, for citation checks.
2217
+ * @param {string} [input.spineText] - the architecture spine, for its decisions.
2218
+ * @param {Array<{type: string, name: string}>} [input.planningEntries] - the planning folder listing.
2219
+ * @param {string} [input.spineText] - the architecture spine, for its decisions.
2220
+ * @param {Array<{type: string, name: string}>} [input.planningEntries] - the planning folder listing.
2221
+ * @param {string} [input.spineText] - the architecture spine, for its decisions.
2222
+ * @param {Array<{type: string, name: string}>} [input.planningEntries] - the planning folder listing.
2223
+ * @param {Array<{file: string, text: string}>} [input.standalone] - specs no tracking key claims.
2224
+ * @returns {{model: object, gaps: object[], unverifiable: object[], bars: object, next: object, requirements: object}}
2225
+ */
2226
+ function analyse(input) {
2227
+ const model = buildModel(
2228
+ input.sprintText,
2229
+ input.deferredText ?? '',
2230
+ input.entries ?? [],
2231
+ input.sprintPath,
2232
+ )
2233
+
2234
+ const { gaps, unverifiable } = detect(model, { readStory: input.readStory })
2235
+ // UX is a core requirement class and carries its own artefacts, so it gets its
2236
+ // own detectors: a design run the plan does not carry, and a UX requirement no
2237
+ // story names. Neither appears in the functional coverage map.
2238
+ const uxRuns = detectUxRuns(input.uxRuns ?? [], input.epicsText ?? '')
2239
+ // Keyed by story path, because the link runs component -> story -> epic and
2240
+ // the epic only exists on the story record.
2241
+ const storyFiles = new Map(
2242
+ (input.storyFiles ?? []).map((entry) => [entry.file, entry.text]),
2243
+ )
2244
+ const decisions = parseArchitectureDecisions(input.spineText ?? '')
2245
+ const decisionLinks = linkDecisionsToEpics(decisions, model.stories, storyFiles)
2246
+ const uxRequirements = detectUxRequirements(
2247
+ parseRequirementInventory(input.epicsText ?? ''),
2248
+ model.stories,
2249
+ storyFiles,
2250
+ )
2251
+ const coverage = parseFrCoverage(input.epicsText ?? '')
2252
+ const inventory = parseRequirementInventory(input.epicsText ?? '')
2253
+ const requirements = requirementBar(coverage, model.epics, inventory)
2254
+
2255
+ // The inventory holds more than the coverage map claims. Those requirements
2256
+ // are declared and unowned, so nothing can say whether they were delivered --
2257
+ // which is an unverifiable, not a silence.
2258
+ const unowned = []
2259
+ if (requirements.unmapped > 0) {
2260
+ const classes = requirements.unmappedByClass
2261
+ .map((group) => `${group.code} (${group.items.length})`)
2262
+ .join(', ')
2263
+ // One action, offered twice: as the card in the unverifiable lane and as the
2264
+ // badge's button under the requirements bar. Two copies would drift.
2265
+ requirements.unmappedAction = {
2266
+ kind: 'prompt',
2267
+ label: 'Ask why they are unmapped',
2268
+ payload:
2269
+ `/bmad-correct-course -- ${classes} are declared in epics.md but appear in no coverage map and are cited by ` +
2270
+ 'no story. Tell me whether each was delivered anyway, and whether the coverage map ' +
2271
+ 'should be extended or the requirements retired.',
2272
+ }
2273
+ unowned.push({
2274
+ id: 'REQ',
2275
+ subject: `${requirements.unmapped} requirements have no coverage map`,
2276
+ reason:
2277
+ `The inventory declares ${requirements.declared} requirements; the coverage map ` +
2278
+ `claims ${requirements.total}. ${classes} are inventoried but mapped to no epic, ` +
2279
+ 'and no story cites them, so their delivery cannot be measured.',
2280
+ action: requirements.unmappedAction,
2281
+ })
2282
+ }
2283
+
2284
+ return {
2285
+ model,
2286
+ standalone: standaloneView(input.standalone ?? []),
2287
+ decisionTitles: new Map(decisions.map((decision) => [decision.id, decision.title])),
2288
+ declaredInputs: parseDeclaredInputs(input.epicsText ?? ''),
2289
+ artefactFamilies: artefactFamilies(input.planningEntries ?? []),
2290
+ requirementView: requirementsView({
2291
+ epicsText: input.epicsText ?? '',
2292
+ coverage,
2293
+ epics: model.epics,
2294
+ stories: model.stories,
2295
+ // Keyed by sprint path, the way the model records a story's file.
2296
+ storyText: storyFiles,
2297
+ decisions: decisionLinks,
2298
+ decisionTitles: new Map(decisions.map((decision) => [decision.id, decision.title])),
2299
+ uxLinks: linkUxToEpics(
2300
+ parseRequirementInventory(input.epicsText ?? ''),
2301
+ model.stories,
2302
+ storyFiles,
2303
+ ),
2304
+ }),
2305
+ gaps: [...gaps, ...uxRuns.gaps, ...uxRequirements.gaps],
2306
+ unverifiable: [...unverifiable, ...unowned, ...uxRuns.unverifiable, ...uxRequirements.unverifiable],
2307
+ requirements,
2308
+ bars: bars(model, requirements),
2309
+ next: nextAction(model),
2310
+ }
2311
+ }
2312
+
2313
+ // --- Styles ------------------------------------------------------------
2314
+
2315
+ const CSS = `
2316
+ .bmad-sb-root{display:flex;flex-direction:column;height:100%;min-height:0;font-size:12px;line-height:1.45;color:var(--dsw-alias-label-primary)}
2317
+ .bmad-sb-head{flex:0 0 auto;display:flex;flex-direction:column;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)}
2318
+ .bmad-sb-titlerow{display:flex;align-items:center;gap:8px}
2319
+ .bmad-sb-title{font-size:13px;font-weight:600;white-space:nowrap}
2320
+ .bmad-sb-project{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary);font-size:11px}
2321
+ .bmad-sb-iconbtn{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px;line-height:1}
2322
+ .bmad-sb-iconbtn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
2323
+ .bmad-sb-iconbtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
2324
+ .bmad-sb-sub{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dsw-alias-label-secondary)}
2325
+ .bmad-sb-num{font-variant-numeric:tabular-nums}
2326
+ /* The refresh cue: quiet, in the row that already reports the reading's age. */
2327
+ .bmad-sb-refreshing{display:inline-flex;align-items:center;gap:5px;color:var(--dsw-alias-label-secondary);font-weight:600}
2328
+ .bmad-sb-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;overflow:hidden;align-self:flex-start}
2329
+ .bmad-sb-seg>button{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;padding:3px 10px;cursor:pointer}
2330
+ .bmad-sb-seg>button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:600}
2331
+ .bmad-sb-seg>button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
2332
+ .bmad-sb-scroll{flex:1 1 auto;min-height:0;overflow:auto;padding:8px 10px 18px}
2333
+ .bmad-sb-lane{margin-bottom:10px}
2334
+ .bmad-sb-lanehead{display:flex;align-items:center;gap:7px;width:100%;padding:5px 6px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer;text-align:left}
2335
+ .bmad-sb-lanehead:hover{background:var(--dsw-alias-bg-layer-2)}
2336
+ .bmad-sb-lanehead:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
2337
+ .bmad-sb-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%}
2338
+ .bmad-sb-lanelabel{flex:1 1 auto;font-size:11px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}
2339
+ .bmad-sb-count{flex:0 0 auto;border-radius:999px;padding:0 7px;font-size:10px;line-height:15px;font-weight:700;font-variant-numeric:tabular-nums}
2340
+ .bmad-sb-chev{flex:0 0 auto;width:9px;font-size:9px;color:var(--dsw-alias-label-secondary)}
2341
+ .bmad-sb-cards{display:flex;flex-direction:column;gap:5px;padding:4px 0 0 2px}
2342
+ .bmad-sb-card{display:flex;flex-direction:column;gap:3px;width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit;text-align:left}
2343
+ button.bmad-sb-card{cursor:pointer}
2344
+ button.bmad-sb-card:hover{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2)}
2345
+ button.bmad-sb-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
2346
+ .bmad-sb-cardtop{display:flex;align-items:center;gap:6px}
2347
+ .bmad-sb-id{font-size:10px;font-weight:700;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
2348
+ .bmad-sb-tag{display:inline-flex;align-items:center;flex:0 0 auto;border-radius:999px;padding:0 7px;font-size:9px;line-height:15px;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums}
2349
+ .bmad-sb-tag--source{margin-left:auto;letter-spacing:.04em;text-transform:uppercase}
2350
+ .bmad-sb-tag[data-tone="neutral"]{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
2351
+ .bmad-sb-tag[data-tone="success"]{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary)}
2352
+ .bmad-sb-tag[data-tone="info"]{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
2353
+ /* A finding's tag, not a story's: solid where it matters, tinted where it does not. */
2354
+ .bmad-sb-tag[data-tone="error"]{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-label-primary-foreground);letter-spacing:.04em}
2355
+ .bmad-sb-tag[data-tone="warning"]{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 22%,transparent);color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 78%,var(--dsw-alias-label-primary))}
2356
+ .bmad-sb-cardtitle{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;font-size:12px}
2357
+ .bmad-sb-cardnote{font-size:10px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
2358
+ .bmad-sb-epicsec{margin-bottom:8px;padding:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
2359
+ .bmad-sb-epichead{display:flex;align-items:center;gap:7px;margin-bottom:6px}
2360
+ .bmad-sb-epichead .bmad-sb-lanehead{width:auto;padding:2px 4px}
2361
+ .bmad-sb-epicname{font-size:12px;font-weight:600;text-transform:capitalize}
2362
+ .bmad-sb-note{padding:14px 4px;color:var(--dsw-alias-label-secondary);font-size:12px}
2363
+ .bmad-sb-error{padding:12px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;color:var(--dsw-alias-label-primary);font-size:12px;display:flex;flex-direction:column;gap:8px;align-items:flex-start}
2364
+ .bmad-sb-empty{padding:12px;border:1px dashed var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);font-size:12px;display:flex;flex-direction:column;gap:8px;align-items:flex-start}
2365
+ .bmad-sb-emptytitle{font-weight:600}
2366
+ .bmad-sb-btn{appearance:none;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;padding:4px 10px;cursor:pointer}
2367
+ .bmad-sb-btn:hover{background:var(--dsw-alias-bg-layer-2)}
2368
+ .bmad-sb-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
2369
+ .bmad-sb-cardfoot .bmad-sb-btn{align-self:flex-start;border-color:transparent;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:600}
2370
+ .bmad-sb-cardfoot .bmad-sb-btn:hover{background:var(--dsw-alias-button-primary-hover)}
2371
+ /* While the write is in flight the button stays put and reads as working. */
2372
+ .bmad-sb-btn[data-state="busy"]{opacity:.72;cursor:progress}
2373
+ .bmad-sb-copywrap{display:inline-flex;flex-direction:row-reverse;align-items:center;gap:6px;min-width:0}
2374
+ .bmad-sb-spinner{flex:0 0 auto;width:9px;height:9px;margin-left:6px;border-radius:50%;
2375
+ border:1.5px solid color-mix(in srgb,currentColor 35%,transparent);border-top-color:currentColor;
2376
+ animation:bmad-sb-spin 620ms linear infinite}
2377
+ @keyframes bmad-sb-spin{to{transform:rotate(360deg)}}
2378
+ /* The outcome, beside the control rather than in place of its label. */
2379
+ .bmad-sb-tip{display:inline-flex;align-items:center;border-radius:999px;padding:0 8px;font-size:9px;
2380
+ line-height:16px;font-weight:700;white-space:nowrap;animation:bmad-sb-tip-in 140ms ease-out}
2381
+ .bmad-sb-tip[data-kind="copied"]{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 16%,transparent);color:var(--dsw-alias-state-success-primary)}
2382
+ .bmad-sb-tip[data-kind="failed"]{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 16%,transparent);color:var(--dsw-alias-state-error-primary)}
2383
+ @keyframes bmad-sb-tip-in{from{opacity:0;transform:translateX(-3px)}to{opacity:1;transform:none}}
2384
+ .bmad-sb-sectitle{display:flex;align-items:center;gap:6px;margin:14px 0 6px;font-size:11px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}
2385
+
2386
+ .bmad-sb-metrics{display:flex;flex-direction:column;gap:5px;padding:0 12px 8px}
2387
+ .bmad-sb-metric{display:flex;flex-direction:column;gap:3px;font-size:11px;line-height:1.2}
2388
+ .bmad-sb-metricrow{display:flex;align-items:center;gap:7px}
2389
+ .bmad-sb-metriclabel{flex:0 0 78px;color:var(--dsw-alias-label-secondary);text-transform:lowercase}
2390
+ .bmad-sb-metrictrack{flex:1 1 auto;display:flex;min-width:0;height:8px;border-radius:4px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
2391
+ .bmad-sb-metricfill{display:block;height:100%}
2392
+ .bmad-sb-metricfill[data-tone="success"]{background:var(--dsw-alias-state-success-primary)}
2393
+ .bmad-sb-metricfill[data-tone="info"]{background:var(--dsw-alias-state-business-primary)}
2394
+ /* The unknown cell is encoded three ways at once -- outline, unfilled body and a
2395
+ label -- because every neutral tone is already spoken for: grey means backlog
2396
+ on this board, and the track itself is already empty. A solid fill would
2397
+ collide with one of them under some theme; an outline plus a count cannot. */
2398
+ .bmad-sb-metricunknown{display:block;height:100%;box-sizing:border-box;background:transparent;border:1px dashed var(--dsw-alias-label-secondary);border-radius:2px}
2399
+ .bmad-sb-metricnum{flex:0 0 auto;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}
2400
+ .bmad-sb-metricpct{flex:0 0 34px;text-align:right;font-variant-numeric:tabular-nums}
2401
+ .bmad-sb-metricwarn{color:var(--dsw-alias-state-warn-primary)}
2402
+ .bmad-sb-metricnote{flex:0 0 auto;color:var(--dsw-alias-label-secondary)}
2403
+ /* The bar's own row, then anything the bar cannot draw. */
2404
+ .bmad-sb-metricfoot{display:flex;align-items:center;gap:6px;padding-left:2px}
2405
+ /* In the metric the button leads, so the bubble must not reverse there. */
2406
+ .bmad-sb-metricfoot .bmad-sb-copywrap{flex-direction:row}
2407
+ /* In the metric the button leads, so the bubble must not reverse there. */
2408
+ .bmad-sb-metricfoot .bmad-sb-copywrap{flex-direction:row}
2409
+ .bmad-sb-badge{display:inline-flex;align-items:center;border-radius:999px;padding:0 8px;font-size:9px;line-height:16px;font-weight:700;letter-spacing:.02em;white-space:nowrap}
2410
+ /* A warning tint was too quiet for a second-class requirement set: the badge
2411
+ reports a hole in the measurement, not a caveat on one. */
2412
+ .bmad-sb-badge--error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 14%,transparent);color:var(--dsw-alias-state-error-primary)}
2413
+ .bmad-sb-badge--warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 14%,transparent);color:var(--dsw-alias-state-warn-primary)}
2414
+ .bmad-sb-summary{display:flex;flex-wrap:wrap;gap:4px 10px;padding:0 12px 8px;font-size:10px;color:var(--dsw-alias-label-secondary)}
2415
+ .bmad-sb-summary--done{color:var(--dsw-alias-state-success-primary);font-weight:600}
2416
+ .bmad-sb-summary--unknown{color:var(--dsw-alias-state-warn-primary);font-weight:600}
2417
+ .bmad-sb-req{display:grid;grid-template-columns:14px 46px 1fr;gap:2px 7px;align-items:baseline;padding:4px 2px;border-bottom:1px solid var(--dsw-alias-border-l1)}
2418
+ .bmad-sb-req:last-child{border-bottom:0}
2419
+ .bmad-sb-reqmark{font-size:11px;line-height:1.2;text-align:center}
2420
+ .bmad-sb-req[data-state="done"] .bmad-sb-reqmark{color:var(--dsw-alias-state-success-primary)}
2421
+ .bmad-sb-req[data-state="partial"] .bmad-sb-reqmark{color:var(--dsw-alias-state-business-primary)}
2422
+ .bmad-sb-req[data-state="todo"] .bmad-sb-reqmark{color:var(--dsw-alias-label-secondary)}
2423
+ /* Nothing to read a state from: hollow, never a tick and never a cross. */
2424
+ .bmad-sb-req[data-state="unknown"] .bmad-sb-reqmark{color:var(--dsw-alias-state-warn-primary)}
2425
+ .bmad-sb-reqid{font-size:9px;font-weight:700;letter-spacing:.02em;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
2426
+ .bmad-sb-reqname{font-size:11px;line-height:1.35;color:var(--dsw-alias-label-primary)}
2427
+ .bmad-sb-reqnote{grid-column:3;font-size:9px;color:var(--dsw-alias-label-secondary);opacity:.8}
2428
+ .bmad-sb-next{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap;padding:0 12px 9px;font-size:11px}
2429
+ .bmad-sb-nextlabel{color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.04em;font-size:10px}
2430
+ .bmad-sb-nextskill{font-weight:600;color:var(--dsw-alias-state-business-primary)}
2431
+ .bmad-sb-nextkey{color:var(--dsw-alias-label-primary)}
2432
+ .bmad-sb-nextreason{color:var(--dsw-alias-label-secondary)}
2433
+ .bmad-sb-next .bmad-sb-btn{align-self:flex-start;border-color:transparent;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:600}
2434
+ .bmad-sb-next .bmad-sb-btn:hover{background:var(--dsw-alias-button-primary-hover)}
2435
+ .bmad-sb-next[data-kind="done"]{color:var(--dsw-alias-state-success-primary)}
2436
+ /* The action sits away from the reading, and against the card's right edge. */
2437
+ .bmad-sb-cardfoot{display:flex;justify-content:flex-end;margin-top:5px}
2438
+ /* Unverifiable must not read as a finding, nor as clean: same card, dashed edge. */
2439
+ /* Unverifiable must not read as a finding, nor as clean: same lane, dashed cards. */
2440
+ .bmad-sb-lane--unverifiable .bmad-sb-card{border-style:dashed}
2441
+ .bmad-sb-unver .bmad-sb-card{border-style:dashed}
2442
+
2443
+ /* --- Motion ---------------------------------------------------------------
2444
+ Two animations only, both short and both reducible to nothing. Height goes
2445
+ through grid-template-rows so a lane animates to a content height nobody has
2446
+ measured; visibility flips after the collapse, so a shut lane holds nothing
2447
+ focusable while staying mounted for the transition. */
2448
+ .bmad-sb-fold{overflow:hidden;transition:height 180ms cubic-bezier(.2,.7,.3,1)}
2449
+ .bmad-sb-fold[data-collapsed="true"]{visibility:hidden;transition:height 180ms cubic-bezier(.2,.7,.3,1),visibility 0s linear 180ms}
2450
+ .bmad-sb-foldinner{min-height:0}
2451
+ .bmad-sb-view{animation:bmad-sb-view-in 170ms cubic-bezier(.2,.7,.3,1)}
2452
+ @keyframes bmad-sb-view-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
2453
+ .bmad-sb-seg>button{transition:background-color 140ms ease,color 140ms ease}
2454
+ .bmad-sb-lanehead{transition:background-color 120ms ease}
2455
+ .bmad-sb-chev{transition:transform 180ms cubic-bezier(.2,.7,.3,1);transform-origin:50% 50%}
2456
+ .bmad-sb-lanehead[aria-expanded="true"] .bmad-sb-chev{transform:rotate(90deg)}
2457
+ @media (prefers-reduced-motion:reduce){
2458
+ .bmad-sb-fold,.bmad-sb-fold[data-collapsed="true"]{transition:none}
2459
+ .bmad-sb-view{animation:none}
2460
+ .bmad-sb-seg>button,.bmad-sb-lanehead,.bmad-sb-chev{transition:none}
2461
+ .bmad-sb-tip{animation:none}
2462
+ /* The spinner stops turning but still marks the button as working. */
2463
+ .bmad-sb-spinner{animation:none;border-top-color:currentColor}
2464
+ }
2465
+ `;
2466
+
2467
+ // --- Leaf components ---------------------------------------------------
2468
+
2469
+ /**
2470
+ * Guide artwork for the sprint board entry: a three-lane board holding an
2471
+ * uneven card in each lane. Drawn on the shared 36px canvas in the product's
2472
+ * fixed artwork palette -- artwork is the one place a literal colour is
2473
+ * correct, and the glyph is decorative, so it is hidden from assistive
2474
+ * technology and the guide supplies its own layout class.
2475
+ * @param props - square edge in px, and the layout class the guide passes.
2476
+ * @returns the ornamental SVG.
2477
+ */
2478
+ function GuideArtworkSprintBoard({ size = 36, className }) {
2479
+ return h(
2480
+ 'svg',
2481
+ {
2482
+ width: size,
2483
+ height: size,
2484
+ className,
2485
+ 'aria-hidden': 'true',
2486
+ viewBox: '0 0 36 36',
2487
+ fill: 'none',
2488
+ xmlns: 'http://www.w3.org/2000/svg',
2489
+ },
2490
+ h('path', {
2491
+ d: 'M10.5 9H25.5C26.8807 9 28 10.1193 28 11.5V24.5C28 25.8807 26.8807 27 25.5 27H10.5C9.11929 27 8 25.8807 8 24.5V11.5C8 10.1193 9.11929 9 10.5 9ZM14.6667 9V27M21.3333 9V27',
2492
+ stroke: '#A797FC',
2493
+ strokeWidth: '2',
2494
+ }),
2495
+ h('path', {
2496
+ d: 'M9.83 12.5H12.83V17H9.83ZM16.5 12.5H19.5V19H16.5ZM23.17 12.5H26.17V16H23.17Z',
2497
+ fill: '#8B7BF7',
2498
+ }),
2499
+ );
2500
+ }
2501
+
2502
+ /**
2503
+ * One story, action, or deferred card. A card with no file to open renders
2504
+ * as a plain box instead of a button, so it never lies about being clickable.
2505
+ * @param props - the card's data, its open action, and an optional link chip.
2506
+ * @returns the card element.
2507
+ */
2508
+ function Card({ id, tag, tagTone, storyTag, title, note, footer, onOpen, openLabel }) {
2509
+ const body = [
2510
+ h(
2511
+ 'span',
2512
+ { className: 'bmad-sb-cardtop', key: 'top' },
2513
+ id === '' ? null : h('span', { className: 'bmad-sb-id' }, id),
2514
+ storyTag === undefined
2515
+ ? null
2516
+ : h(
2517
+ 'span',
2518
+ {
2519
+ className: 'bmad-sb-tag',
2520
+ 'data-tone': storyTag.tone,
2521
+ title: storyTag.title,
2522
+ },
2523
+ storyTag.label,
2524
+ ),
2525
+ tag === ''
2526
+ ? null
2527
+ : h(
2528
+ 'span',
2529
+ {
2530
+ className: 'bmad-sb-tag bmad-sb-tag--source',
2531
+ 'data-tone': tagTone ?? 'neutral',
2532
+ },
2533
+ tag,
2534
+ ),
2535
+ ),
2536
+ h('span', { className: 'bmad-sb-cardtitle', key: 'title' }, title),
2537
+ note === '' || note === undefined
2538
+ ? null
2539
+ : h('span', { className: 'bmad-sb-cardnote', key: 'note' }, note),
2540
+ footer === undefined ? null : h('span', { className: 'bmad-sb-cardfoot', key: 'foot' }, footer),
2541
+ ];
2542
+
2543
+ if (typeof onOpen !== 'function') {
2544
+ return h('div', { className: 'bmad-sb-card' }, body);
2545
+ }
2546
+ return h(
2547
+ 'button',
2548
+ {
2549
+ type: 'button',
2550
+ className: 'bmad-sb-card',
2551
+ onClick: onOpen,
2552
+ title: openLabel,
2553
+ },
2554
+ body,
2555
+ );
2556
+ }
2557
+
2558
+ /**
2559
+ * A lane header that folds its cards.
2560
+ * @param props - lane identity, colour token, count, and fold state.
2561
+ * @returns the header button.
2562
+ */
2563
+ function LaneHeader({ label, token, count, countTitle, collapsed, onToggle }) {
2564
+ return h(
2565
+ 'button',
2566
+ {
2567
+ type: 'button',
2568
+ className: 'bmad-sb-lanehead',
2569
+ 'aria-expanded': !collapsed,
2570
+ onClick: onToggle,
2571
+ },
2572
+ h('span', {
2573
+ className: 'bmad-sb-dot',
2574
+ style: { background: `var(${token})` },
2575
+ 'aria-hidden': true,
2576
+ }),
2577
+ h('span', { className: 'bmad-sb-lanelabel' }, label),
2578
+ // The count wears the lane's own colour, so the header reads as one
2579
+ // thing: a green 12 beside Done, an amber 5 beside Gaps. The tint is
2580
+ // mixed from the token rather than declared, so a theme that redefines
2581
+ // the token moves the pill with it.
2582
+ h(
2583
+ 'span',
2584
+ {
2585
+ className: 'bmad-sb-count',
2586
+ title: countTitle,
2587
+ style: {
2588
+ color: `var(${token})`,
2589
+ background: `color-mix(in srgb, var(${token}) 12%, transparent)`,
2590
+ },
2591
+ },
2592
+ String(count),
2593
+ ),
2594
+ h(
2595
+ 'span',
2596
+ { className: 'bmad-sb-chev', 'aria-hidden': true },
2597
+ collapsed ? CHEVRON_CLOSED : CHEVRON_OPEN,
2598
+ ),
2599
+ );
2600
+ }
2601
+
2602
+ /**
2603
+ * One story card, in either view.
2604
+ * @param props - the story, the copy, and the file opener.
2605
+ * @returns the card element.
2606
+ */
2607
+ function StoryCard({ t, story, tag, makeOpener }) {
2608
+ let note = '';
2609
+ if (story.file === null) note = t('noSpec');
2610
+ else if (story.deferred > 0) note = t('deferredCount', { count: story.deferred });
2611
+ return h(Card, {
2612
+ id: `${story.epic}.${story.index}`,
2613
+ tag,
2614
+ title: story.title,
2615
+ note,
2616
+ onOpen: makeOpener(story.file),
2617
+ openLabel: t('openSpec'),
2618
+ });
2619
+ }
2620
+
2621
+ // --- Frame components --------------------------------------------------
2622
+
2623
+ /**
2624
+ * The tab's outer frame. The board owns the whole pane body, so the frame
2625
+ * also carries the component-local stylesheet.
2626
+ * @param props - the header element and the body element.
2627
+ * @returns the root element.
2628
+ */
2629
+ function BoardShell({ header, body }) {
2630
+ return h(
2631
+ 'div',
2632
+ { className: 'bmad-sb-root' },
2633
+ h('style', null, CSS),
2634
+ header,
2635
+ body,
2636
+ );
2637
+ }
2638
+
2639
+ /**
2640
+ * The header a board that has not loaded yet can draw.
2641
+ * @param props - the copy.
2642
+ * @returns the header element.
2643
+ */
2644
+ function TitleOnlyHeader({ t }) {
2645
+ return h(
2646
+ 'header',
2647
+ { className: 'bmad-sb-head' },
2648
+ h(
2649
+ 'div',
2650
+ { className: 'bmad-sb-titlerow' },
2651
+ h('span', { className: 'bmad-sb-title' }, t('title')),
2652
+ ),
2653
+ );
2654
+ }
2655
+
2656
+ /**
2657
+ * The header of a loaded board: identity, progress, and the view toggle.
2658
+ * @param props - the model, its completion percentage, and the controls.
2659
+ * @returns the header element.
2660
+ */
2661
+ /**
2662
+ * The four views this one tab switches between.
2663
+ *
2664
+ * Requirements and specs used to be tabs of their own in the sidebar, which
2665
+ * meant three entries in the launcher for one board and three separate
2666
+ * snapshots of the same files. They are readings of the same artefacts, so
2667
+ * they are views of the same tab.
2668
+ */
2669
+ const VIEWS = [
2670
+ { id: 'status', label: 'viewStatus' },
2671
+ { id: 'epic', label: 'viewEpic' },
2672
+ { id: 'requirements', label: 'viewRequirements' },
2673
+ { id: 'specs', label: 'viewSpecs' },
2674
+ ];
2675
+
2676
+ function BoardHeader({ t, model, bars, next, view, onView, onRefresh, refreshing }) {
2677
+ return h(
2678
+ 'header',
2679
+ { className: 'bmad-sb-head' },
2680
+ h(
2681
+ 'div',
2682
+ { className: 'bmad-sb-titlerow' },
2683
+ h('span', { className: 'bmad-sb-title' }, t('title')),
2684
+ h('span', { className: 'bmad-sb-project' }, model.project),
2685
+ h(
2686
+ 'button',
2687
+ {
2688
+ type: 'button',
2689
+ className: 'bmad-sb-iconbtn',
2690
+ onClick: onRefresh,
2691
+ title: t('refresh'),
2692
+ 'aria-label': t('refresh'),
2693
+ },
2694
+ '\u21BB',
2695
+ ),
2696
+ ),
2697
+ model.lastUpdated === '' && refreshing !== true
2698
+ ? null
2699
+ : h(
2700
+ 'div',
2701
+ { className: 'bmad-sb-sub' },
2702
+ model.lastUpdated === '' ? null : `${t('updated')} ${model.lastUpdated}`,
2703
+ // The reading on screen is the previous one while a refresh runs.
2704
+ // Saying so is the difference between stale and wrong.
2705
+ refreshing === true
2706
+ ? h(
2707
+ 'span',
2708
+ { className: 'bmad-sb-refreshing', role: 'status' },
2709
+ h('span', { className: 'bmad-sb-spinner', 'aria-hidden': true }),
2710
+ t('refreshing'),
2711
+ )
2712
+ : null,
2713
+ ),
2714
+ h(
2715
+ 'div',
2716
+ { className: 'bmad-sb-metrics' },
2717
+ h(Bar, {
2718
+ t,
2719
+ label: t('barStories'),
2720
+ done: bars.stories.done,
2721
+ total: bars.stories.total,
2722
+ unknown: bars.stories.unverifiable,
2723
+ tone: 'success',
2724
+ }),
2725
+ h(Bar, {
2726
+ t,
2727
+ label: t('barRequirements'),
2728
+ done: bars.requirements.done,
2729
+ total: bars.requirements.total,
2730
+ unknown: bars.requirements.unverifiable,
2731
+ noData: bars.requirements.noData === true,
2732
+ badge:
2733
+ bars.requirements.unmapped > 0
2734
+ ? t('unmappedNote', {
2735
+ count: bars.requirements.unmapped,
2736
+ declared: bars.requirements.declared,
2737
+ })
2738
+ : '',
2739
+ tone: 'info',
2740
+ }),
2741
+ ),
2742
+ h(NextLine, { t, next }),
2743
+ h(
2744
+ 'div',
2745
+ {
2746
+ className: 'bmad-sb-seg',
2747
+ role: 'group',
2748
+ 'aria-label': t('viewLabel'),
2749
+ },
2750
+ VIEWS.map((entry) =>
2751
+ h(
2752
+ 'button',
2753
+ {
2754
+ key: entry.id,
2755
+ type: 'button',
2756
+ 'aria-pressed': view === entry.id,
2757
+ onClick: () => onView(entry.id),
2758
+ },
2759
+ t(entry.label),
2760
+ ),
2761
+ ),
2762
+ ),
2763
+ );
2764
+ }
2765
+
2766
+
2767
+ /**
2768
+ * What a board that could not read its files shows instead. This is a real
2769
+ * failure -- the file was found and then would not read -- so it reports the
2770
+ * reason rather than pretending the workspace is empty.
2771
+ * @param props - the failure text and the retry action.
2772
+ * @returns the error element.
2773
+ */
2774
+ function LoadFailure({ t, message, onRetry }) {
2775
+ return h(
2776
+ 'div',
2777
+ { className: 'bmad-sb-scroll' },
2778
+ h(
2779
+ 'div',
2780
+ { className: 'bmad-sb-error', role: 'alert' },
2781
+ h('span', null, t('loadFailed')),
2782
+ h('span', { className: 'bmad-sb-cardnote' }, message),
2783
+ h(
2784
+ 'button',
2785
+ { type: 'button', className: 'bmad-sb-btn', onClick: onRetry },
2786
+ t('retry'),
2787
+ ),
2788
+ ),
2789
+ );
2790
+ }
2791
+
2792
+ /**
2793
+ * What a workspace with no sprint tracking file shows. Nothing went wrong,
2794
+ * so this explains what was looked for and where, instead of reporting a
2795
+ * failure the reader cannot act on.
2796
+ * @param props - the search shape and the retry action.
2797
+ * @returns the empty-state element.
2798
+ */
2799
+ function NoSprintFile({ t, searched, onRetry }) {
2800
+ return h(
2801
+ 'div',
2802
+ { className: 'bmad-sb-scroll' },
2803
+ h(
2804
+ 'div',
2805
+ { className: 'bmad-sb-empty', role: 'status' },
2806
+ h('span', { className: 'bmad-sb-emptytitle' }, t('noSprint.title')),
2807
+ h(
2808
+ 'span',
2809
+ { className: 'bmad-sb-cardnote' },
2810
+ t('noSprint.body', { names: SPRINT_FILENAMES.join(', ') }),
2811
+ ),
2812
+ h(
2813
+ 'span',
2814
+ { className: 'bmad-sb-cardnote' },
2815
+ t('noSprint.searched', {
2816
+ candidates: searched.candidates,
2817
+ depth: searched.depth,
2818
+ }),
2819
+ ),
2820
+ h(
2821
+ 'button',
2822
+ { type: 'button', className: 'bmad-sb-btn', onClick: onRetry },
2823
+ t('noSprint.retry'),
2824
+ ),
2825
+ ),
2826
+ );
2827
+ }
2828
+
2829
+ // --- Board views -------------------------------------------------------
2830
+
2831
+ /**
2832
+ * One status lane in the Status view.
2833
+ * @param props - the status, its stories, fold state, and click behaviour.
2834
+ * @returns the lane section.
2835
+ */
2836
+ function StatusLane({ t, status, stories, collapsed, onToggle, makeOpener }) {
2837
+ return h(
2838
+ 'section',
2839
+ { className: 'bmad-sb-lane', 'aria-label': t(`status.${status}`) },
2840
+ h(LaneHeader, {
2841
+ label: t(`status.${status}`),
2842
+ token: STATUS_TOKEN[status] ?? IDLE_TOKEN,
2843
+ count: stories.length,
2844
+ collapsed,
2845
+ onToggle,
2846
+ }),
2847
+ h(
2848
+ Fold,
2849
+ { collapsed },
2850
+ h(
2851
+ 'div',
2852
+ { className: 'bmad-sb-cards' },
2853
+ stories.map((story) =>
2854
+ h(StoryCard, {
2855
+ key: story.key,
2856
+ t,
2857
+ story,
2858
+ // The status view names the epic, so the badge carries the
2859
+ // epic's own state; the epic view names the story's status.
2860
+ tag: `E${story.epic}`,
2861
+ tagTone: toneOf(story.epicStatus),
2862
+ makeOpener,
2863
+ }),
2864
+ ),
2865
+ ),
2866
+ ),
2867
+ );
2868
+ }
2869
+
2870
+ /**
2871
+ * The Status view: one lane per status that has stories, in workflow order.
2872
+ * @param props - the model, the fold set, and the click behaviour.
2873
+ * @returns the lane sections.
2874
+ */
2875
+ function StatusLanes({ t, model, folded, onFlip, makeOpener, insertAfter, insert }) {
2876
+ const present = STATUS_ORDER.filter((status) =>
2877
+ model.stories.some((story) => story.status === status),
2878
+ );
2879
+
2880
+ const lanes = present.map((status) => {
2881
+ const closedByDefault = status === 'done';
2882
+ return h(StatusLane, {
2883
+ key: status,
2884
+ t,
2885
+ status,
2886
+ stories: model.stories.filter((story) => story.status === status),
2887
+ collapsed: isFolded(folded, status, closedByDefault),
2888
+ onToggle: foldHandler(onFlip, status, closedByDefault),
2889
+ makeOpener,
2890
+ });
2891
+ });
2892
+
2893
+ if (insert === undefined) return lanes;
2894
+ // What is finished is read before what is wrong with it, so the gaps sit
2895
+ // after the Done lane rather than above every lane. When nothing is done
2896
+ // yet there is no such lane, and the section closes the list instead.
2897
+ const at = present.indexOf(insertAfter);
2898
+ const cut = at === -1 ? lanes.length : at + 1;
2899
+ return [...lanes.slice(0, cut), insert, ...lanes.slice(cut)];
2900
+ }
2901
+
2902
+ /**
2903
+ * One epic swimlane in the Epic view.
2904
+ * @param props - the epic, its stories, fold state, and click behaviour.
2905
+ * @returns the epic section.
2906
+ */
2907
+ function EpicSection({ t, epic, stories, collapsed, onToggle, makeOpener }) {
2908
+ const percent =
2909
+ epic.total === 0 ? 0 : Math.round((epic.done / epic.total) * 100);
2910
+ return h(
2911
+ 'section',
2912
+ {
2913
+ className: 'bmad-sb-epicsec',
2914
+ 'aria-label': `${t('epicLabel')} ${epic.number}`,
2915
+ },
2916
+ h(
2917
+ 'div',
2918
+ { className: 'bmad-sb-epichead' },
2919
+ h(
2920
+ 'button',
2921
+ {
2922
+ type: 'button',
2923
+ className: 'bmad-sb-lanehead',
2924
+ 'aria-expanded': !collapsed,
2925
+ onClick: onToggle,
2926
+ },
2927
+ h('span', {
2928
+ className: 'bmad-sb-dot',
2929
+ style: {
2930
+ background: `var(${STATUS_TOKEN[epic.status] ?? IDLE_TOKEN})`,
2931
+ },
2932
+ 'aria-hidden': true,
2933
+ }),
2934
+ h(
2935
+ 'span',
2936
+ { className: 'bmad-sb-epicname' },
2937
+ `${t('epicLabel')} ${epic.number}`,
2938
+ ),
2939
+ h(
2940
+ 'span',
2941
+ { className: 'bmad-sb-count' },
2942
+ `${epic.done}/${epic.total}`,
2943
+ ),
2944
+ h(
2945
+ 'span',
2946
+ { className: 'bmad-sb-chev', 'aria-hidden': true },
2947
+ collapsed ? CHEVRON_CLOSED : CHEVRON_OPEN,
2948
+ ),
2949
+ ),
2950
+ ),
2951
+ h(
2952
+ 'div',
2953
+ { className: 'bmad-sb-sub' },
2954
+ h('span', null, t(`status.${epic.status}`) || epic.status),
2955
+ epic.retro === ''
2956
+ ? null
2957
+ : h('span', null, `${t('retroLabel')}: ${epic.retro}`),
2958
+ h(
2959
+ 'span',
2960
+ { className: 'bmad-sb-bar', 'aria-hidden': true },
2961
+ h('span', { style: { width: `${percent}%` } }),
2962
+ ),
2963
+ ),
2964
+ h(
2965
+ Fold,
2966
+ { collapsed },
2967
+ h(
2968
+ 'div',
2969
+ { className: 'bmad-sb-cards' },
2970
+ stories.map((story) =>
2971
+ h(StoryCard, {
2972
+ key: story.key,
2973
+ t,
2974
+ story,
2975
+ tag: t(`status.${story.status}`) || story.status,
2976
+ tagTone: toneOf(story.status),
2977
+ makeOpener,
2978
+ }),
2979
+ ),
2980
+ ),
2981
+ ),
2982
+ );
2983
+ }
2984
+
2985
+ /**
2986
+ * The Epic view: one swimlane per epic, with its own progress and retro state.
2987
+ * @param props - the model, the fold set, and the click behaviour.
2988
+ * @returns the epic sections.
2989
+ */
2990
+ function EpicSections({ t, model, folded, onFlip, makeOpener }) {
2991
+ return model.epics.map((epic) => {
2992
+ // A finished epic is the least likely to need its stories listed, so it
2993
+ // starts folded; an epic still in flight starts open.
2994
+ const closedByDefault = epic.status === 'done';
2995
+ return h(EpicSection, {
2996
+ key: epic.key,
2997
+ t,
2998
+ epic,
2999
+ stories: model.stories.filter((story) => story.epic === epic.number),
3000
+ collapsed: isFolded(folded, epic.number, closedByDefault),
3001
+ onToggle: foldHandler(onFlip, epic.number, closedByDefault),
3002
+ makeOpener,
3003
+ });
3004
+ });
3005
+ }
3006
+
3007
+ /**
3008
+ * The retrospective action items: the open ones, then a completed count.
3009
+ * @param props - the model and the click behaviour.
3010
+ * @returns the section's elements.
3011
+ */
3012
+ function ActionLane({ t, model, makeOpener, collapsed, onToggle }) {
3013
+ const cards = model.openActions.map((item) =>
3014
+ h(Card, {
3015
+ key: item.id,
3016
+ id: '',
3017
+ tag: item.owner,
3018
+ // An open item wants attention; one being worked is simply in flight.
3019
+ tagTone: toneOf(item.status),
3020
+ title: item.action,
3021
+ note: item.status === 'in-progress' ? t('status.in-progress') : '',
3022
+ onOpen: makeOpener(item.ref === '' ? null : item.ref),
3023
+ openLabel: t('openRetro'),
3024
+ // A retrospective commitment is work somebody agreed to do, so it
3025
+ // carries the same kind of action the gaps do. It is a request rather
3026
+ // than a command: the board knows what was promised, not how to keep it.
3027
+ footer: h(CopyButton, {
3028
+ t,
3029
+ label:
3030
+ item.status === 'in-progress'
3031
+ ? t('finishActionItem')
3032
+ : t('takeActionItem'),
3033
+ value:
3034
+ `/bmad-build -- ${item.action}\n\n` +
3035
+ `Owner: ${item.owner === '' ? 'unassigned' : item.owner}\n` +
3036
+ `Status: ${item.status}\n` +
3037
+ (item.ref === '' ? '' : `Source: ${item.ref}\n`) +
3038
+ '\nThis is an open action item from a retrospective. Take it on, or tell me ' +
3039
+ 'why it should be closed.',
3040
+ }),
3041
+ }),
3042
+ );
3043
+ // A lane, like every other list on the board: the count sits in the
3044
+ // header with the rest, and the settled total rides underneath.
3045
+ return h(CardLane, {
3046
+ label: t('actionsTitle'),
3047
+ token: cards.length === 0 ? IDLE_TOKEN : WARN_TOKEN,
3048
+ cards,
3049
+ collapsed,
3050
+ onToggle,
3051
+ empty: t('noOpenActions'),
3052
+ count: `${cards.length} / ${cards.length + model.doneActionCount}`,
3053
+ countTitle: t('actionsCount', {
3054
+ open: cards.length,
3055
+ done: model.doneActionCount,
3056
+ }),
3057
+ note: t('actionsDone', { count: model.doneActionCount }),
3058
+ });
3059
+ }
3060
+
3061
+ /**
3062
+ * The deferred-work ledger. An entry with no `source_spec` opens the ledger
3063
+ * itself, wherever the workspace keeps it.
3064
+ * @param props - the model, the ledger's path, and the click behaviour.
3065
+ * @returns the section's elements.
3066
+ */
3067
+ function DeferredLane({ t, model, ledgerPath, makeOpener, collapsed, onToggle }) {
3068
+ const cards = model.deferred.map((item, index) =>
3069
+ h(Card, {
3070
+ key: item.id === '' ? `deferred-${index}` : item.id,
3071
+ id: item.id,
3072
+ // An entry that names a story's spec carries that story's board id,
3073
+ // so the chip matches the id on the story's own card.
3074
+ storyTag:
3075
+ item.story === null
3076
+ ? undefined
3077
+ : {
3078
+ label: `\u21B3 ${item.story.epic}.${item.story.index}`,
3079
+ title: t('deferredFrom', {
3080
+ id: `${item.story.epic}.${item.story.index}`,
3081
+ }),
3082
+ // The reference takes the referenced story's own state, so the
3083
+ // chip says at a glance what the entry hangs off.
3084
+ tone: toneOf(item.story.status),
3085
+ },
3086
+ tag: item.source,
3087
+ tagTone: 'neutral',
3088
+ title: item.title,
3089
+ note: item.file === '' ? item.spec : item.file,
3090
+ onOpen: makeOpener(item.spec === '' ? ledgerPath : item.spec),
3091
+ openLabel: t('openSpec'),
3092
+ // A deferral is a decision somebody already made, so the board does not
3093
+ // assume it should be undone. It states what was shelved and asks.
3094
+ footer: h(CopyButton, {
3095
+ t,
3096
+ label: t('deferredAction'),
3097
+ // Ledger entries are sparse: some carry an id and a source, some
3098
+ // only a sentence. Every empty field is dropped rather than printed
3099
+ // as a blank, so the request reads the same either way.
3100
+ value: [
3101
+ `/bmad-build -- ${item.title}`,
3102
+ '',
3103
+ item.source === '' ? null : `Source: ${item.source}`,
3104
+ item.story === null
3105
+ ? null
3106
+ : `Deferred from: ${item.story.epic}.${item.story.index}`,
3107
+ item.spec === '' ? null : `Spec: ${item.spec}`,
3108
+ item.file === '' ? null : `Related file: ${item.file}`,
3109
+ '',
3110
+ 'This was deferred deliberately. Tell me whether to pick it up now, keep it',
3111
+ 'deferred, or drop it. If it should be picked up, implement it.',
3112
+ ]
3113
+ .filter((line) => line !== null)
3114
+ .join('\n')
3115
+ .replace(/\n{3,}/g, '\n\n'),
3116
+ }),
3117
+ }),
3118
+ );
3119
+ return h(CardLane, {
3120
+ label: t('deferredTitle'),
3121
+ // Deferred work is recorded and accepted, not urgent: the same neutral
3122
+ // the backlog wears, because neither is being worked right now.
3123
+ token: IDLE_TOKEN,
3124
+ cards,
3125
+ collapsed,
3126
+ onToggle,
3127
+ empty: t('noDeferred'),
3128
+ });
3129
+ }
3130
+
3131
+ // --- Interaction helpers -----------------------------------------------
3132
+
3133
+ /**
3134
+ * Build a card's open action.
3135
+ * @param actions - the tab's own navigation actions.
3136
+ * @param fileAddress - the injected workspace-path to address builder.
3137
+ * @param path - the file to open, or null when the card has none.
3138
+ * @returns the click handler, or nothing for a card with no file.
3139
+ */
3140
+ function openHandler(actions, fileAddress, path) {
3141
+ if (path === null || path === undefined) return;
3142
+ return () => actions.openResource(fileAddress(path));
3143
+ }
3144
+
3145
+ /**
3146
+ * Whether a fold is closed: the reader's own last choice, or the default the
3147
+ * board would show before they touch it. Holding overrides rather than a
3148
+ * closed set lets a later read change a default -- an epic that becomes done
3149
+ * folds itself -- while every fold the reader has touched keeps their choice.
3150
+ * @param folded - the override map for one view.
3151
+ * @param key - the lane or epic key.
3152
+ * @param fallback - the state before the reader touches this key.
3153
+ * @returns true when the body is hidden.
3154
+ */
3155
+ function isFolded(folded, key, fallback) {
3156
+ return folded.has(key) ? folded.get(key) : fallback;
3157
+ }
3158
+
3159
+ /**
3160
+ * Build a fold toggle for one key.
3161
+ * @param setter - the state setter holding that view's override map.
3162
+ * @param key - the lane or epic to flip.
3163
+ * @param fallback - the state the key shows before it is overridden.
3164
+ * @returns the click handler.
3165
+ */
3166
+ function foldHandler(setter, key, fallback) {
3167
+ return () => {
3168
+ setter((previous) => {
3169
+ const next = new Map(previous);
3170
+ next.set(key, !isFolded(previous, key, fallback));
3171
+ return next;
3172
+ });
3173
+ };
3174
+ }
3175
+
3176
+ /**
3177
+ * Find the tracking file in the Session's workspace and fold everything it
3178
+ * names into one model. The deferred ledger and the spec listing are
3179
+ * optional: a workspace without them still gets a board, just a thinner one.
3180
+ * A workspace with no tracking file at all is reported as missing rather
3181
+ * than as a failure, because nothing went wrong.
3182
+ * @param readers - the injected workspace readers.
3183
+ * @param sessionId - the Session whose workspace to read.
3184
+ * @param signal - cancellation for this read.
3185
+ * @returns the board model, or a missing marker naming what was searched.
3186
+ */
3187
+ /**
3188
+ * Run an async mapper over a list with a ceiling on how many run at once.
3189
+ *
3190
+ * The reads this board makes are round-trips to the Host's workspace face,
3191
+ * and they were issued one after another: thirty small files cost thirty
3192
+ * sequential waits. Firing all thirty at once would be faster still and
3193
+ * needlessly rude to the far side, so they go in waves.
3194
+ *
3195
+ * @param items - the list to map.
3196
+ * @param limit - how many run concurrently.
3197
+ * @param mapper - async function of one item.
3198
+ * @returns the results, in input order.
3199
+ */
3200
+ async function mapLimit(items, limit, mapper) {
3201
+ const results = new Array(items.length);
3202
+ let next = 0;
3203
+ const worker = async () => {
3204
+ while (next < items.length) {
3205
+ const index = next;
3206
+ next += 1;
3207
+ results[index] = await mapper(items[index], index);
3208
+ }
3209
+ };
3210
+ const workers = [];
3211
+ for (let i = 0; i < Math.min(limit, items.length); i += 1) workers.push(worker());
3212
+ await Promise.all(workers);
3213
+ return results;
3214
+ }
3215
+
3216
+ async function loadBoardModel(
3217
+ { readWorkspaceFile, listWorkspaceDir, stat },
3218
+ sessionId,
3219
+ signal,
3220
+ ) {
3221
+ const readers = { stat, list: listWorkspaceDir };
3222
+
3223
+ // BMAD declares where its modules write, and a project may move those
3224
+ // folders, so the declaration is read first. The candidate probe and the
3225
+ // bounded scan remain, but as the fallback for when config is absent.
3226
+ const configPath = await firstExistingFile(stat, ['_bmad/config.toml'], signal);
3227
+ const declarations =
3228
+ configPath === null
3229
+ ? { implementation: null, planning: null, knowledge: null }
3230
+ : artifactPaths(
3231
+ parseBmadConfig(await readWorkspaceFile(configPath, signal).catch(() => '')),
3232
+ );
3233
+
3234
+ const sprintPath = await locateSprintPath(readers, sessionId, signal);
3235
+ if (sprintPath === null) {
3236
+ return {
3237
+ missing: true,
3238
+ searched: {
3239
+ candidates: SPRINT_CANDIDATES.length * SPRINT_FILENAMES.length,
3240
+ depth: SCAN_MAX_DEPTH,
3241
+ },
3242
+ };
3243
+ }
3244
+
3245
+ const sprintText = await readWorkspaceFile(sprintPath, signal);
3246
+ const sprintDir = dirOf(sprintPath);
3247
+ const storyLocation = (parseSprintStatus(sprintText).meta.story_location ?? '').trim();
3248
+ const storyDir = storyLocation || sprintDir;
3249
+
3250
+ // The ledger sits with the story files in every layout seen so far, and
3251
+ // beside the tracking file in the rest; probe both rather than guess.
3252
+ const deferredPath = await firstExistingFile(
3253
+ stat,
3254
+ [under(storyDir, 'deferred-work.md'), under(sprintDir, 'deferred-work.md')],
3255
+ signal,
3256
+ );
3257
+ // Nothing below depends on anything else below until the model is built,
3258
+ // so the ledger, the listing, the plan and the UX runs go out together.
3259
+ const [deferredText, entries, epicsRead, planningEntries, uxListing] = await Promise.all([
3260
+ deferredPath === null
3261
+ ? Promise.resolve('')
3262
+ : readWorkspaceFile(deferredPath, signal).catch(() => ''),
3263
+ listWorkspaceDir(storyDir, signal).catch(() => []),
3264
+ declarations.planning === null
3265
+ ? Promise.resolve({ path: null, text: '' })
3266
+ : firstExistingFile(stat, [under(declarations.planning, 'epics.md')], signal).then(
3267
+ (path) =>
3268
+ path === null
3269
+ ? { path: null, text: '' }
3270
+ : readWorkspaceFile(path, signal)
3271
+ .catch(() => '')
3272
+ .then((text) => ({ path, text })),
3273
+ ),
3274
+ declarations.planning === null
3275
+ ? Promise.resolve([])
3276
+ : listWorkspaceDir(declarations.planning, signal).catch(() => []),
3277
+ declarations.planning === null
3278
+ ? Promise.resolve([])
3279
+ : listWorkspaceDir(under(declarations.planning, UX_DIR), signal).catch(() => []),
3280
+ ]);
3281
+
3282
+ const epicsPath = epicsRead.path;
3283
+ const epicsText = epicsRead.text;
3284
+ const uxRuns = parseUxRuns(uxListing);
3285
+ const model = buildModel(sprintText, deferredText, entries, sprintPath);
3286
+
3287
+ // The spine is reached through the plan's own declaration rather than a
3288
+ // guessed path: whatever epics.md lists as an architecture input is what
3289
+ // gets read, and a project that lists none simply has no such lane.
3290
+ const declaredInputs = parseDeclaredInputs(epicsText);
3291
+ const spinePath =
3292
+ declaredInputs.find((path) => /architecture/i.test(path) && /\.md$/i.test(path)) ?? null;
3293
+ const spineText =
3294
+ spinePath === null ? '' : await readWorkspaceFile(spinePath, signal).catch(() => '');
3295
+
3296
+ // The core cannot read files, so the shell reads every story file the
3297
+ // detectors may need and hands over the text. B3 only asks about finished
3298
+ // stories, but U2 asks whether any story cites a UX requirement, so all of
3299
+ // them are read.
3300
+ const storyText = new Map(
3301
+ await mapLimit(
3302
+ model.stories.filter((story) => story.file !== null),
3303
+ READ_CONCURRENCY,
3304
+ async (story) => {
3305
+ try {
3306
+ return [story.file, await readWorkspaceFile(story.file, signal)];
3307
+ } catch {
3308
+ return [story.file, null]; // present but unreadable, not absent
3309
+ }
3310
+ },
3311
+ ),
3312
+ );
3313
+ const readStory = (path) => (storyText.has(path) ? storyText.get(path) : undefined);
3314
+
3315
+ // Specs no story claims. The sprint file is silent about them, so the
3316
+ // directory listing is the only place they exist, and the file itself is
3317
+ // the only place their progress does.
3318
+ const claimed = new Set(
3319
+ model.stories
3320
+ .map((story) => story.file)
3321
+ .filter((file) => typeof file === 'string')
3322
+ .map((file) => file.split('/').pop()),
3323
+ );
3324
+ const unclaimed = entries
3325
+ .filter(
3326
+ (entry) =>
3327
+ entry.type === 'file' && /^spec-.*\.md$/.test(entry.name) && !claimed.has(entry.name),
3328
+ )
3329
+ .map((entry) => (storyDir === '' ? entry.name : `${storyDir}/${entry.name}`));
3330
+ const standalone = await mapLimit(unclaimed, READ_CONCURRENCY, async (file) => ({
3331
+ file,
3332
+ text: await readWorkspaceFile(file, signal).catch(() => ''),
3333
+ }));
3334
+
3335
+ return {
3336
+ analysis: analyse({
3337
+ sprintText,
3338
+ deferredText,
3339
+ entries,
3340
+ sprintPath,
3341
+ epicsText,
3342
+ uxRuns,
3343
+ spineText,
3344
+ standalone,
3345
+ planningEntries,
3346
+ storyTexts: [...storyText.values()].filter((t) => typeof t === 'string'),
3347
+ storyFiles: [...storyText.entries()].map(([file, text]) => ({ file, text })),
3348
+ readStory,
3349
+ }),
3350
+ deferredPath,
3351
+ coveragePath: epicsPath,
3352
+ declarations,
3353
+ };
3354
+ }
3355
+
3356
+ /**
3357
+ * The first path in a list that names an existing file.
3358
+ * @param stat - the injected workspace stat reader.
3359
+ * @param paths - candidate workspace-relative paths, in preference order.
3360
+ * @param signal - caller cancellation.
3361
+ * @returns the first existing path, or null when none is there.
3362
+ */
3363
+ async function firstExistingFile(stat, paths, signal) {
3364
+ for (const path of paths) {
3365
+ if (await fileExists(stat, path, signal)) return path;
3366
+ }
3367
+ return null;
3368
+ }
3369
+
3370
+ /**
3371
+ * The failure snapshot for one rejected read.
3372
+ * @param error - whatever the read threw.
3373
+ * @returns the error snapshot.
3374
+ */
3375
+ function failureSnapshot(error) {
3376
+ return {
3377
+ phase: 'error',
3378
+ message: error instanceof Error ? error.message : String(error),
3379
+ };
3380
+ }
3381
+
3382
+ /**
3383
+ * Read the sprint files once per revision, cancelling on unmount or when the
3384
+ * tab occurrence ends. The readers are bound once per occurrence, so the
3385
+ * revision counter and the tab's own lifetime are the only inputs that
3386
+ * re-run the read.
3387
+ * @param props - the injected readers and the tab's lifetime signal.
3388
+ * @returns the load snapshot: loading, ready with a model, or failed.
3389
+ */
3390
+ /**
3391
+ * One sparse bar. The denominator is the whole set, and an unverifiable part
3392
+ * is drawn as an outlined, unfilled cell inside the track rather than by
3393
+ * shrinking the total -- a denominator that shrinks when the board cannot see
3394
+ * is a board that improves its score by going blind.
3395
+ *
3396
+ * The unknown cell is encoded three ways at once (outline, fill, label)
3397
+ * because every neutral tone is already taken for something else: grey means
3398
+ * backlog here, and the track is already empty.
3399
+ * @param props - the bar's label, its counts, and the copy.
3400
+ * @returns the bar row.
3401
+ */
3402
+ function Bar({ t, label, done, total, unknown = 0, noData = false, badge = '', tone }) {
3403
+ // A bar with a badge is a block: the row, then the foot beneath it.
3404
+ const known = Math.max(0, total - unknown);
3405
+ const pct = total === 0 ? 0 : Math.round((done / total) * 100);
3406
+ const segments = [];
3407
+ if (done > 0) {
3408
+ segments.push(
3409
+ h('span', {
3410
+ key: 'done',
3411
+ className: 'bmad-sb-metricfill',
3412
+ 'data-tone': tone,
3413
+ style: { width: `${(done / Math.max(total, 1)) * 100}%` },
3414
+ }),
3415
+ );
3416
+ }
3417
+ if (unknown > 0) {
3418
+ segments.push(
3419
+ h('span', {
3420
+ key: 'unknown',
3421
+ className: 'bmad-sb-metricunknown',
3422
+ style: { width: `${(unknown / Math.max(total, 1)) * 100}%` },
3423
+ title: t('unknownHint', { count: unknown }),
3424
+ }),
3425
+ );
3426
+ }
3427
+ // With no denominator there is nothing to draw, and drawing an empty
3428
+ // track would say "clean" about a measurement nobody made.
3429
+ if (noData) {
3430
+ return h(
3431
+ 'div',
3432
+ { className: 'bmad-sb-metric' },
3433
+ h(
3434
+ 'div',
3435
+ { className: 'bmad-sb-metricrow' },
3436
+ h('span', { className: 'bmad-sb-metriclabel' }, label),
3437
+ h('span', {
3438
+ className: 'bmad-sb-metricunknown',
3439
+ style: { flex: '1 1 auto' },
3440
+ role: 'img',
3441
+ 'aria-label': t('barNoData', { label }),
3442
+ }),
3443
+ h('span', { className: 'bmad-sb-metricwarn' }, t('cannotMeasure')),
3444
+ ),
3445
+ );
3446
+ }
3447
+
3448
+ return h(
3449
+ 'div',
3450
+ { className: 'bmad-sb-metric' },
3451
+ h(
3452
+ 'div',
3453
+ { className: 'bmad-sb-metricrow' },
3454
+ h('span', { className: 'bmad-sb-metriclabel' }, label),
3455
+ h(
3456
+ 'span',
3457
+ {
3458
+ className: 'bmad-sb-metrictrack',
3459
+ role: 'img',
3460
+ 'aria-label':
3461
+ unknown > 0
3462
+ ? t('barLabelUnknown', { label, done, total, unknown })
3463
+ : t('barLabel', { label, done, total }),
3464
+ },
3465
+ segments,
3466
+ ),
3467
+ h(
3468
+ 'span',
3469
+ { className: 'bmad-sb-metricnum' },
3470
+ `${done} / ${total}`,
3471
+ unknown > 0 ? h('span', { className: 'bmad-sb-metricwarn' }, ` +${unknown}?`) : null,
3472
+ ),
3473
+ h('span', { className: 'bmad-sb-metricwarn bmad-sb-metricpct' }, `${pct}%`),
3474
+ known === 0 && total > 0
3475
+ ? h('span', { className: 'bmad-sb-metricwarn' }, t('nothingVerifiable'))
3476
+ : null,
3477
+ ),
3478
+ // The mapped count is a fraction of the inventory. It goes on its own row
3479
+ // as a badge rather than trailing the numbers as a note: at 23 of 40 it
3480
+ // is the larger half of the picture, and the bar alone cannot show it.
3481
+ badge === ''
3482
+ ? null
3483
+ : h(
3484
+ 'div',
3485
+ { className: 'bmad-sb-metricfoot' },
3486
+ // No action here: the card in the Could-not-check lane carries the
3487
+ // same one, and a second button beside the bar asks the same
3488
+ // question twice on one screen.
3489
+ h('span', { className: 'bmad-sb-badge bmad-sb-badge--error' }, badge),
3490
+ ),
3491
+ );
3492
+ }
3493
+
3494
+ /**
3495
+ * The framework's own recommendation, rendered rather than reinvented. One
3496
+ * answer with its reason: a board that offers three has not answered.
3497
+ * @param props - the recommendation and the copy.
3498
+ * @returns the next-action line, or null when there is nothing to do.
3499
+ */
3500
+ function NextLine({ t, next }) {
3501
+ if (next === null || next.skill === null) {
3502
+ return h('div', { className: 'bmad-sb-next', 'data-kind': 'done' }, t('allDone'));
3503
+ }
3504
+ return h(
3505
+ 'div',
3506
+ { className: 'bmad-sb-next', 'data-kind': 'action' },
3507
+ h('span', { className: 'bmad-sb-nextlabel' }, t('nextLabel')),
3508
+ h('span', { className: 'bmad-sb-nextskill' }, next.skill),
3509
+ h('span', { className: 'bmad-sb-nextkey' }, next.label || next.key),
3510
+ h('span', { className: 'bmad-sb-nextreason' }, next.reason),
3511
+ // The recommendation is the most actionable thing on the board, so it
3512
+ // carries an action too rather than sitting there as a sentence.
3513
+ next.action === null || next.action === undefined
3514
+ ? null
3515
+ : h(CopyButton, {
3516
+ t,
3517
+ label: next.action.label,
3518
+ value: next.action.payload,
3519
+ }),
3520
+ );
3521
+ }
3522
+
3523
+ /**
3524
+ * The gaps, then the things the board could not check. Unverifiable is a
3525
+ * separate list on purpose: it must never be filtered, dimmed, or collapsed
3526
+ * into the clean state, because a board showing nothing looks identical to a
3527
+ * board that cannot see.
3528
+ * @param props - the gaps, the unverifiable entries, and the copy.
3529
+ * @returns the gaps section, or null when there is nothing to report.
3530
+ */
3531
+ /**
3532
+ * A lane's body, folded open or shut.
3533
+ *
3534
+ * The cards stay mounted while collapsed, because an animation needs
3535
+ * something to animate. Height is always an explicit pixel value, measured
3536
+ * from the content and re-measured when the content resizes. That keeps the
3537
+ * transition a plain CSS interpolation between two lengths.
3538
+ *
3539
+ * Two approaches were tried and rejected. `grid-template-rows: 0fr -> 1fr`
3540
+ * is the usual recipe and does not survive this page: the scroll ancestor
3541
+ * carries a definite height, and a `1fr` row whose item clips resolves to
3542
+ * zero, so the lane stayed shut however the attribute was set. Collapsing
3543
+ * from `auto` needs the height pinned for one frame first, and that frame
3544
+ * came from `requestAnimationFrame`, which never fires in a background tab.
3545
+ *
3546
+ * @param props - whether it is shut, and the cards.
3547
+ * @returns the folding body.
3548
+ */
3549
+ function Fold({ collapsed, children }) {
3550
+ const inner = React.useRef(null);
3551
+ const [contentHeight, setContentHeight] = React.useState(0);
3552
+
3553
+ React.useEffect(() => {
3554
+ const node = inner.current;
3555
+ if (node === null) return undefined;
3556
+ const measure = () => setContentHeight(node.scrollHeight);
3557
+ measure();
3558
+ if (typeof ResizeObserver !== 'function') return undefined;
3559
+ const observer = new ResizeObserver(measure);
3560
+ observer.observe(node);
3561
+ return () => observer.disconnect();
3562
+ }, []);
3563
+
3564
+ return h(
3565
+ 'div',
3566
+ {
3567
+ className: 'bmad-sb-fold',
3568
+ 'data-collapsed': String(collapsed),
3569
+ style: { height: collapsed ? '0px' : `${contentHeight}px` },
3570
+ },
3571
+ h('div', { className: 'bmad-sb-foldinner', ref: inner }, children),
3572
+ );
3573
+ }
3574
+
3575
+ /**
3576
+ * Severity -> the tag tone that carries it.
3577
+ *
3578
+ * A finding is not a status, so it does not wear a status tag's quiet tint.
3579
+ * `high` and `critical` take the error colour filled solid: a done story
3580
+ * owing patches is a contradiction, and a pale pill in a muted theme reads as
3581
+ * decoration. `medium` stays a warning tint, and `low` stays neutral, so the
3582
+ * three levels are distinguishable at a glance rather than by reading.
3583
+ *
3584
+ * @param severity - the finding's severity.
3585
+ * @returns a tag tone.
3586
+ */
3587
+ const severityTone = (severity) =>
3588
+ severity === 'critical' || severity === 'high'
3589
+ ? 'error'
3590
+ : severity === 'medium'
3591
+ ? 'warning'
3592
+ : 'neutral';
3593
+
3594
+ /**
3595
+ * One lane of cards. `StatusLane` and `EpisodeSection` predate this shape;
3596
+ * a lane is a `section` carrying `bmad-sb-lane`, a `LaneHeader`, and the
3597
+ * cards below it when it is open.
3598
+ *
3599
+ * The gaps are lanes for the same reason they are `Card`s: a gap and a story
3600
+ * are two readings of one board, and a second component drifts from the first
3601
+ * the moment either changes.
3602
+ * @param props - the label, the dot's token, the cards, and the fold state.
3603
+ * @returns the lane.
3604
+ */
3605
+ function CardLane({ label, token, cards, collapsed, onToggle, modifier, note, empty, count, countTitle }) {
3606
+ return h(
3607
+ 'section',
3608
+ {
3609
+ className: modifier === undefined ? 'bmad-sb-lane' : `bmad-sb-lane ${modifier}`,
3610
+ 'aria-label': label,
3611
+ },
3612
+ h(LaneHeader, {
3613
+ label,
3614
+ token,
3615
+ // A lane usually counts what it holds. Retro action items are the
3616
+ // exception: the number that matters is how many are still open
3617
+ // against how many are settled, so the badge carries both.
3618
+ count: count === undefined ? cards.length : count,
3619
+ countTitle,
3620
+ collapsed,
3621
+ onToggle,
3622
+ }),
3623
+ h(
3624
+ Fold,
3625
+ { collapsed },
3626
+ cards.length === 0 && empty !== undefined
3627
+ ? h('div', { className: 'bmad-sb-note' }, empty)
3628
+ : h('div', { className: 'bmad-sb-cards' }, cards),
3629
+ ),
3630
+ collapsed === true || note === undefined || note === ''
3631
+ ? null
3632
+ : h('div', { className: 'bmad-sb-note' }, note),
3633
+ );
3634
+ }
3635
+
3636
+ /** The gap cards: the finding, its evidence, and the action in the footer. */
3637
+ const gapCards = (t, gaps) =>
3638
+ gaps.map((gap) =>
3639
+ h(Card, {
3640
+ key: `${gap.id}:${gap.subject}`,
3641
+ id: gap.id,
3642
+ tag: gap.title,
3643
+ tagTone: severityTone(gap.severity),
3644
+ title: gap.subject,
3645
+ note: gap.evidence,
3646
+ footer: h(CopyButton, {
3647
+ t,
3648
+ label: gap.action.label,
3649
+ value: gap.action.payload,
3650
+ }),
3651
+ }),
3652
+ );
3653
+
3654
+ /**
3655
+ * What the board could not check. The same lane and the same cards, dashed,
3656
+ * because this must never be mistaken for a finding nor for a clean result.
3657
+ */
3658
+ const unverifiableCards = (t, unverifiable) =>
3659
+ unverifiable.map((item) =>
3660
+ h(Card, {
3661
+ key: `${item.id}:${item.subject}`,
3662
+ id: item.id,
3663
+ title: item.subject,
3664
+ note: item.reason,
3665
+ footer: h(CopyButton, {
3666
+ t,
3667
+ label: t('askAnAgent'),
3668
+ value: item.action.payload,
3669
+ }),
3670
+ }),
3671
+ );
3672
+
3673
+ /**
3674
+ * Copy text, and report whether it worked.
3675
+ *
3676
+ * `navigator.clipboard.writeText` returns a promise, so a refusal is a
3677
+ * rejection rather than a throw -- wrapping the call in try/catch catches
3678
+ * nothing and the copy fails in silence. It is refused whenever the document
3679
+ * is not focused, which is common enough to matter. The fallback path uses a
3680
+ * throwaway textarea and `execCommand`, which is deprecated but still the
3681
+ * only route when the async API is blocked.
3682
+ *
3683
+ * @param value - the text to place on the clipboard.
3684
+ * @param report - called with true when the copy landed, false when it did not.
3685
+ */
3686
+ function copyText(value, report) {
3687
+ const done = (ok) => {
3688
+ try {
3689
+ report(ok);
3690
+ } catch {
3691
+ /* a reporter that throws is not a copy failure */
3692
+ }
3693
+ };
3694
+
3695
+ const fallback = () => {
3696
+ try {
3697
+ const scratch = document.createElement('textarea');
3698
+ scratch.value = value;
3699
+ scratch.setAttribute('readonly', '');
3700
+ scratch.style.position = 'fixed';
3701
+ scratch.style.top = '-1000px';
3702
+ scratch.style.opacity = '0';
3703
+ document.body.appendChild(scratch);
3704
+ scratch.select();
3705
+ const ok = document.execCommand('copy');
3706
+ document.body.removeChild(scratch);
3707
+ return ok;
3708
+ } catch {
3709
+ return false;
3710
+ }
3711
+ };
3712
+
3713
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
3714
+ navigator.clipboard.writeText(value).then(
3715
+ () => done(true),
3716
+ () => done(fallback()),
3717
+ );
3718
+ return;
3719
+ }
3720
+ done(fallback());
3721
+ }
3722
+
3723
+ /**
3724
+ * A button that copies its payload and says so.
3725
+ *
3726
+ * Every action this board offers is a string, so a copy with no visible
3727
+ * outcome is indistinguishable from a broken button -- which is exactly what
3728
+ * it was. The label carries the result for a moment, then returns.
3729
+ * @param props - the label, the payload, and the action kind.
3730
+ * @returns the copy button.
3731
+ */
3732
+ function CopyButton({ t, label, value }) {
3733
+ const [busy, setBusy] = React.useState(false);
3734
+ const [notice, setNotice] = React.useState(null);
3735
+
3736
+ return h(
3737
+ 'span',
3738
+ { className: 'bmad-sb-copywrap' },
3739
+ h(
3740
+ 'button',
3741
+ {
3742
+ type: 'button',
3743
+ className: 'bmad-sb-btn',
3744
+ // The label never changes. It used to be replaced by "Copied" and
3745
+ // back, which resized the button twice and read as the control
3746
+ // vanishing rather than reporting; the result belongs beside it.
3747
+ 'data-state': busy ? 'busy' : 'idle',
3748
+ 'aria-busy': busy ? 'true' : undefined,
3749
+ disabled: busy,
3750
+ // Every action here is the same act -- copy a prompt -- so the
3751
+ // visible text says that once instead of nine different sentences
3752
+ // that all did the same thing. What this particular one asks stays
3753
+ // available two ways: on hover, and as the accessible name, so a
3754
+ // screen reader does not hear nine identical buttons.
3755
+ 'aria-label': label,
3756
+ title: value,
3757
+ onClick: (event) => {
3758
+ // A card that can be opened is itself a button, and this one sits
3759
+ // inside it. Without this the click copies *and* opens the
3760
+ // document, so the action did two things and only one was asked.
3761
+ event.stopPropagation();
3762
+ if (busy) return;
3763
+ setBusy(true);
3764
+ setNotice(null);
3765
+ copyText(value, (ok) => {
3766
+ setBusy(false);
3767
+ setNotice(ok ? 'copied' : 'failed');
3768
+ window.setTimeout(() => setNotice(null), 2000);
3769
+ });
3770
+ },
3771
+ },
3772
+ t('askAgentPill'),
3773
+ busy ? h('span', { className: 'bmad-sb-spinner', 'aria-hidden': true }) : null,
3774
+ ),
3775
+ // `status` so the outcome is announced as well as shown.
3776
+ notice === null
3777
+ ? null
3778
+ : h(
3779
+ 'span',
3780
+ { className: 'bmad-sb-tip', 'data-kind': notice, role: 'status' },
3781
+ notice === 'copied' ? t('copied') : t('copyFailed'),
3782
+ ),
3783
+ );
3784
+ }
3785
+
3786
+ /**
3787
+ * The last good reading per Session, kept for the life of the page.
3788
+ *
3789
+ * A refresh re-reads around thirty files. Without this, every remount -- a
3790
+ * tab switch, a second look at the same board -- pays that cost again and
3791
+ * shows a spinner while it does, even though the answer rarely changed.
3792
+ */
3793
+ const SNAPSHOT_CACHE = new Map();
3794
+
3795
+ function useSprintSnapshot({
3796
+ readers,
3797
+ sessionId,
3798
+ tabSignal,
3799
+ revision,
3800
+ }) {
3801
+ // Start from what we already know, already marked as being refreshed.
3802
+ const [snapshot, setSnapshot] = React.useState(() => {
3803
+ const cached = SNAPSHOT_CACHE.get(sessionId);
3804
+ return cached === undefined ? { phase: 'loading' } : { ...cached, refreshing: true };
3805
+ });
3806
+
3807
+ React.useEffect(() => {
3808
+ const controller = new AbortController();
3809
+ const onTabGone = () => controller.abort();
3810
+ tabSignal.addEventListener('abort', onTabGone);
3811
+ let live = true;
3812
+
3813
+ const cached = SNAPSHOT_CACHE.get(sessionId);
3814
+ // With something to show, show it and say it is being refreshed. With
3815
+ // nothing, the spinner is the only honest thing on screen.
3816
+ setSnapshot(cached === undefined ? { phase: 'loading' } : { ...cached, refreshing: true });
3817
+
3818
+ loadBoardModel(readers, sessionId, controller.signal).then(
3819
+ (outcome) => {
3820
+ if (!live) return;
3821
+ if (outcome.missing === true) {
3822
+ SNAPSHOT_CACHE.delete(sessionId);
3823
+ setSnapshot({ phase: 'missing', searched: outcome.searched });
3824
+ return;
3825
+ }
3826
+ const next = {
3827
+ phase: 'ready',
3828
+ ...outcome.analysis,
3829
+ deferredPath: outcome.deferredPath,
3830
+ coveragePath: outcome.coveragePath,
3831
+ declarations: outcome.declarations,
3832
+ readAt: Date.now(),
3833
+ refreshing: false,
3834
+ };
3835
+ SNAPSHOT_CACHE.set(sessionId, next);
3836
+ setSnapshot(next);
3837
+ },
3838
+ (error) => {
3839
+ if (!live) return;
3840
+ const previous = SNAPSHOT_CACHE.get(sessionId);
3841
+ // A refresh that fails keeps the last good reading and says so. A
3842
+ // blank board would be a worse lie than a dated one, and a dated
3843
+ // one that does not admit it is dated is worse still.
3844
+ setSnapshot(
3845
+ previous === undefined
3846
+ ? failureSnapshot(error)
3847
+ : { ...previous, refreshing: false, refreshFailed: true },
3848
+ );
3849
+ },
3850
+ );
3851
+
3852
+ return () => {
3853
+ live = false;
3854
+ tabSignal.removeEventListener('abort', onTabGone);
3855
+ controller.abort();
3856
+ };
3857
+ }, [revision, tabSignal, readers, sessionId]);
3858
+
3859
+ return snapshot;
3860
+ }
3861
+
3862
+ // --- The tab body ------------------------------------------------------
3863
+
3864
+ /**
3865
+ * The board body: reads the three files, then renders the board.
3866
+ * @param props - framework-bound tab identity, `t`, and the injected readers.
3867
+ * @returns the tab's content.
3868
+ */
3869
+ /**
3870
+ * The colour a requirement lane's count wears, by what the lane contains.
3871
+ *
3872
+ * A lane of finished requirements is green, a lane with work left is blue,
3873
+ * one that has not started is neutral, and one with no evidence anywhere is
3874
+ * amber -- the same amber the summary gives its "no evidence" total, because
3875
+ * that is a finding rather than an absence.
3876
+ *
3877
+ * @param lane - the lane, with its rows and whether it is verifiable.
3878
+ * @returns a theme token name.
3879
+ */
3880
+ function requirementLaneToken(lane) {
3881
+ if (lane.verifiable !== true) return WARN_TOKEN;
3882
+ if (lane.rows.length === 0) return IDLE_TOKEN;
3883
+ const done = lane.rows.filter((row) => row.state === 'done').length;
3884
+ if (done === lane.rows.length) return '--dsw-alias-state-success-primary';
3885
+ return done === 0 ? IDLE_TOKEN : '--dsw-alias-state-business-primary';
3886
+ }
3887
+
3888
+ /** One requirement row: a state mark, the id, the name, and the note. */
3889
+ function RequirementRow({ row }) {
3890
+ const mark =
3891
+ row.state === 'done'
3892
+ ? '\u2713'
3893
+ : row.state === 'partial'
3894
+ ? '\u25D1'
3895
+ : row.state === 'todo'
3896
+ ? '\u2717'
3897
+ : '\u25CB';
3898
+ return h(
3899
+ 'div',
3900
+ { className: 'bmad-sb-req', 'data-state': row.state },
3901
+ h('span', { className: 'bmad-sb-reqmark', 'aria-hidden': true }, mark),
3902
+ h('span', { className: 'bmad-sb-reqid' }, row.id),
3903
+ h('span', { className: 'bmad-sb-reqname' }, row.name),
3904
+ h('span', { className: 'bmad-sb-reqnote' }, row.note),
3905
+ );
3906
+ }
3907
+
3908
+ /** Guide artwork for the specs tab: loose pages, one of them marked. */
3909
+ /**
3910
+ * The standalone-specs tab: the specs no tracking key claims.
3911
+ *
3912
+ * They are side-work -- refactors, style extractions, migrations -- and the
3913
+ * sprint file is silent about them, so nothing else on the board can show
3914
+ * them. Most are finished; the ones that are not have no story to hang on
3915
+ * and would otherwise be invisible.
3916
+ *
3917
+ * @param props - the same injection the other panes receive.
3918
+ * @returns the specs pane.
3919
+ */
3920
+ /**
3921
+ * The standalone-specs view: the specs no tracking key claims.
3922
+ *
3923
+ * A body, like the requirements view, for the same reason.
3924
+ *
3925
+ * @param props - the built view, the fold set, and the click behaviour.
3926
+ * @returns the specs body.
3927
+ */
3928
+ function StandaloneView({ t, standalone, folded, onFlip }) {
3929
+ const view = standalone ?? { lanes: [], totals: {} };
3930
+ const totals = view.totals ?? {};
3931
+
3932
+ return h(
3933
+ 'div',
3934
+ { className: 'bmad-sb-scroll' },
3935
+ h(
3936
+ 'div',
3937
+ { className: 'bmad-sb-view' },
3938
+ h(
3939
+ 'div',
3940
+ { className: 'bmad-sb-summary' },
3941
+ h('span', null, t('specDeclared', { count: totals.declared ?? 0 })),
3942
+ h('span', { className: 'bmad-sb-summary--done' }, t('specDone', { count: totals.done ?? 0 })),
3943
+ h('span', { className: 'bmad-sb-summary--unknown' }, t('specOpen', { count: totals.open ?? 0 })),
3944
+ ),
3945
+ (view.lanes ?? []).map((lane) => {
3946
+ const collapsed = isFolded(folded, lane.code, false);
3947
+ return h(
3948
+ 'section',
3949
+ { className: 'bmad-sb-lane', key: lane.code, 'aria-label': lane.label },
3950
+ h(LaneHeader, {
3951
+ label: `${lane.label} (${lane.rows.length})`,
3952
+ token: lane.code === 'OPEN' ? WARN_TOKEN : '--dsw-alias-state-success-primary',
3953
+ count: lane.rows.length,
3954
+ collapsed,
3955
+ onToggle: foldHandler(onFlip, lane.code, false),
3956
+ }),
3957
+ h(
3958
+ Fold,
3959
+ { collapsed },
3960
+ h(
3961
+ 'div',
3962
+ { className: 'bmad-sb-cards' },
3963
+ lane.rows.map((row) =>
3964
+ h(Card, {
3965
+ key: row.id,
3966
+ id: '',
3967
+ title: row.name,
3968
+ note: row.note,
3969
+ tag: lane.code === 'OPEN' ? t('specOpenTag') : t('specDoneTag'),
3970
+ tagTone: lane.code === 'OPEN' ? 'warning' : 'neutral',
3971
+ footer:
3972
+ lane.code !== 'OPEN'
3973
+ ? undefined
3974
+ : h(CopyButton, {
3975
+ t,
3976
+ label: t('specPickUp'),
3977
+ value:
3978
+ `/bmad-build -- ${row.name}\n\nSpec: ${row.file}\n${row.note}\n\n` +
3979
+ 'No story in the sprint file claims this spec. Tell me whether ' +
3980
+ 'to pick it up, fold it into a story, or drop it.',
3981
+ }),
3982
+ }),
3983
+ ),
3984
+ ),
3985
+ ),
3986
+ );
3987
+ }),
3988
+ ),
3989
+ );
3990
+ }
3991
+
3992
+ /**
3993
+ * The requirements tab: every declared requirement, grouped by class.
3994
+ *
3995
+ * Four classes are inventoried and only functional requirements carry a
3996
+ * coverage map. The three without one are shown as unverifiable rather than
3997
+ * as outstanding work, because the board cannot tell those apart and
3998
+ * guessing "not done" is the failure this board exists to catch.
3999
+ *
4000
+ * @param props - the same injection the board pane receives.
4001
+ * @returns the requirements pane.
4002
+ */
4003
+ /**
4004
+ * The requirements view: every declared requirement, grouped by class.
4005
+ *
4006
+ * A body, not a pane: it shares the board's header, its snapshot and its
4007
+ * fold state, so it renders inside the one tab rather than as a second one.
4008
+ *
4009
+ * @param props - the built view, the fold set, and the click behaviour.
4010
+ * @returns the requirements body.
4011
+ */
4012
+ function RequirementsView({ t, requirementView, folded, onFlip }) {
4013
+ const view = requirementView ?? { lanes: [], totals: {} };
4014
+ const totals = view.totals ?? {};
4015
+
4016
+ return h(
4017
+ 'div',
4018
+ { className: 'bmad-sb-scroll' },
4019
+ h(
4020
+ 'div',
4021
+ { className: 'bmad-sb-view' },
4022
+ h(
4023
+ 'div',
4024
+ { className: 'bmad-sb-summary' },
4025
+ h('span', null, t('reqDeclared', { count: totals.declared ?? 0 })),
4026
+ h('span', { className: 'bmad-sb-summary--done' }, t('reqDone', { count: totals.done ?? 0 })),
4027
+ h('span', null, t('reqPartial', { count: totals.partial ?? 0 })),
4028
+ h('span', null, t('reqTodo', { count: totals.todo ?? 0 })),
4029
+ h('span', { className: 'bmad-sb-summary--unknown' }, t('reqUnknown', { count: totals.unknown ?? 0 })),
4030
+ ),
4031
+ view.lanes.map((lane) => {
4032
+ const collapsed = isFolded(folded, lane.code, false);
4033
+ return h(
4034
+ 'section',
4035
+ { className: 'bmad-sb-lane', key: lane.code, 'aria-label': lane.label },
4036
+ h(LaneHeader, {
4037
+ label: `${lane.label} (${lane.rows.length})`,
4038
+ token: requirementLaneToken(lane),
4039
+ count: lane.rows.length,
4040
+ collapsed,
4041
+ onToggle: foldHandler(onFlip, lane.code, false),
4042
+ }),
4043
+ h(
4044
+ Fold,
4045
+ { collapsed },
4046
+ h(
4047
+ 'div',
4048
+ { className: 'bmad-sb-cards' },
4049
+ lane.rows.map((row) => h(RequirementRow, { key: row.id, row })),
4050
+ ),
4051
+ ),
4052
+ );
4053
+ }),
4054
+ ),
4055
+ );
4056
+ }
4057
+
4058
+ /**
4059
+ * The board body: locates the tracking file in the workspace, then renders
4060
+ * the board it describes.
4061
+ * @param props - framework-bound tab identity, `t`, and the injected readers.
4062
+ * @returns the tab's content.
4063
+ */
4064
+ function SprintBoard({
4065
+ t,
4066
+ useTabInfo,
4067
+ sessionId,
4068
+ readWorkspaceFile,
4069
+ listWorkspaceDir,
4070
+ statWorkspacePath,
4071
+ fileAddress,
4072
+ }) {
4073
+ const tab = useTabInfo();
4074
+ const actions = tab.tab.actions;
4075
+
4076
+ const [revision, setRevision] = React.useState(0);
4077
+ const [view, setView] = React.useState('status');
4078
+ const [foldedStatus, setFoldedStatus] = React.useState(() => new Map());
4079
+ const [foldedEpic, setFoldedEpic] = React.useState(() => new Map());
4080
+
4081
+ // One stable face for the loader, so the read effect keys on the Session
4082
+ // rather than on a fresh object each render.
4083
+ const readers = React.useMemo(
4084
+ () => ({
4085
+ readWorkspaceFile,
4086
+ listWorkspaceDir,
4087
+ stat: statWorkspacePath,
4088
+ }),
4089
+ [readWorkspaceFile, listWorkspaceDir, statWorkspacePath],
4090
+ );
4091
+
4092
+ const snapshot = useSprintSnapshot({
4093
+ readers,
4094
+ sessionId,
4095
+ tabSignal: tab.tab.signal,
4096
+ revision,
4097
+ });
4098
+
4099
+ const refresh = () => setRevision((value) => value + 1);
4100
+
4101
+ if (snapshot.phase !== 'ready') {
4102
+ return h(BoardShell, {
4103
+ header: h(TitleOnlyHeader, { t }),
4104
+ body:
4105
+ snapshot.phase === 'loading'
4106
+ ? h(
4107
+ 'div',
4108
+ { className: 'bmad-sb-note', role: 'status' },
4109
+ t('loading'),
4110
+ )
4111
+ : snapshot.phase === 'missing'
4112
+ ? h(NoSprintFile, {
4113
+ t,
4114
+ searched: snapshot.searched,
4115
+ onRetry: refresh,
4116
+ })
4117
+ : h(LoadFailure, {
4118
+ t,
4119
+ message: snapshot.message,
4120
+ onRetry: refresh,
4121
+ }),
4122
+ });
4123
+ }
4124
+
4125
+ const model = snapshot.model;
4126
+ const { bars, next, gaps, unverifiable } = snapshot;
4127
+ const makeOpener = (path) => openHandler(actions, fileAddress, path);
4128
+
4129
+ const gapLanes = [
4130
+ gaps.length === 0
4131
+ ? null
4132
+ : h(CardLane, {
4133
+ key: 'gaps',
4134
+ label: t('gapsLabel'),
4135
+ token: WARN_TOKEN,
4136
+ cards: gapCards(t, gaps),
4137
+ collapsed: isFolded(foldedStatus, 'gaps', false),
4138
+ onToggle: foldHandler(setFoldedStatus, 'gaps', false),
4139
+ }),
4140
+ unverifiable.length === 0
4141
+ ? null
4142
+ : h(CardLane, {
4143
+ key: 'unverifiable',
4144
+ label: t('unverLabel'),
4145
+ token: IDLE_TOKEN,
4146
+ modifier: 'bmad-sb-lane--unverifiable',
4147
+ cards: unverifiableCards(t, unverifiable),
4148
+ collapsed: isFolded(foldedStatus, 'unverifiable', false),
4149
+ onToggle: foldHandler(setFoldedStatus, 'unverifiable', false),
4150
+ }),
4151
+ ].filter(Boolean);
4152
+ // No wrapper: these are lanes, and they belong beside the status lanes
4153
+ // rather than nested one level inside a section of their own.
4154
+ const gapSection = gapLanes;
4155
+ const lanes =
4156
+ view === 'status'
4157
+ ? h(StatusLanes, {
4158
+ t,
4159
+ model,
4160
+ folded: foldedStatus,
4161
+ onFlip: setFoldedStatus,
4162
+ makeOpener,
4163
+ insertAfter: 'done',
4164
+ insert: gapSection,
4165
+ })
4166
+ : [
4167
+ h(EpicSections, {
4168
+ t,
4169
+ model,
4170
+ folded: foldedEpic,
4171
+ onFlip: setFoldedEpic,
4172
+ makeOpener,
4173
+ }),
4174
+ gapSection,
4175
+ ];
4176
+
4177
+
4178
+ // The two bars sit side by side and are never a toggle: 12 of 15 stories
4179
+ // against 11 of 17 requirements is a pair of numbers that disagree, and
4180
+ // the distance between them is the finding.
4181
+ return h(BoardShell, {
4182
+ header: h(BoardHeader, {
4183
+ t,
4184
+ model,
4185
+ bars,
4186
+ next,
4187
+ view,
4188
+ onView: setView,
4189
+ onRefresh: refresh,
4190
+ refreshing: snapshot.refreshing === true,
4191
+ }),
4192
+ body:
4193
+ view === 'requirements'
4194
+ ? h(RequirementsView, {
4195
+ t,
4196
+ requirementView: snapshot.requirementView,
4197
+ folded: foldedStatus,
4198
+ onFlip: setFoldedStatus,
4199
+ })
4200
+ : view === 'specs'
4201
+ ? h(StandaloneView, {
4202
+ t,
4203
+ standalone: snapshot.standalone,
4204
+ folded: foldedStatus,
4205
+ onFlip: setFoldedStatus,
4206
+ })
4207
+ : h(
4208
+ 'div',
4209
+ { className: 'bmad-sb-scroll' },
4210
+ // Keyed on the view: switching remounts the list, which replays the
4211
+ // settle animation instead of swapping content abruptly.
4212
+ h('div', { className: 'bmad-sb-view', key: view }, lanes),
4213
+ h(ActionLane, {
4214
+ t,
4215
+ model,
4216
+ makeOpener,
4217
+ collapsed: isFolded(foldedStatus, 'actions', false),
4218
+ onToggle: foldHandler(setFoldedStatus, 'actions', false),
4219
+ }),
4220
+ h(DeferredLane, {
4221
+ t,
4222
+ model,
4223
+ // Null when the workspace keeps no ledger; the card then has
4224
+ // nothing to open and renders static rather than dead.
4225
+ ledgerPath: snapshot.deferredPath,
4226
+ makeOpener,
4227
+ collapsed: isFolded(foldedStatus, 'deferred', false),
4228
+ onToggle: foldHandler(setFoldedStatus, 'deferred', false),
4229
+ }),
4230
+ ),
4231
+ });
4232
+ }
4233
+
4234
+ // --- Dictionaries ------------------------------------------------------
4235
+
4236
+ const en = {
4237
+ refreshing: 'refreshing\u2026',
4238
+ viewRequirements: 'Requirements',
4239
+ viewSpecs: 'Specs',
4240
+ askAgentPill: 'Ask agent \u00b7 prompt',
4241
+ specTitle: 'Standalone specs',
4242
+ 'specGuide.title': 'Standalone specs',
4243
+ 'specGuide.description': 'Specs no story in the sprint file claims',
4244
+ specDeclared: '{count} specs',
4245
+ specDone: '{count} finished',
4246
+ specOpen: '{count} still open',
4247
+ specOpenTag: 'not in the sprint file',
4248
+ specDoneTag: 'unclaimed',
4249
+ specPickUp: 'Ask an agent to pick it up',
4250
+ unmappedNote: '{count} of {declared} unmapped',
4251
+ actionsCount: '{open} open, {done} completed',
4252
+ specTitle: 'Standalone specs',
4253
+ 'specGuide.title': 'Standalone specs',
4254
+ 'specGuide.description': 'Specs no story in the sprint file claims',
4255
+ specDeclared: '{count} specs',
4256
+ specDone: '{count} finished',
4257
+ specOpen: '{count} still open',
4258
+ specOpenTag: 'not in the sprint file',
4259
+ specDoneTag: 'unclaimed',
4260
+ specPickUp: 'Ask an agent to pick it up',
4261
+ deferredAction: 'Ask whether to pick it up',
4262
+ takeActionItem: 'Ask an agent to take it',
4263
+ finishActionItem: 'Ask an agent to finish it',
4264
+ reqTitle: 'Requirements',
4265
+ 'reqGuide.title': 'Requirements',
4266
+ 'reqGuide.description':
4267
+ 'Every declared requirement, by class, with what the evidence supports',
4268
+ reqDeclared: '{count} declared',
4269
+ reqDone: '{count} done',
4270
+ reqPartial: '{count} partial',
4271
+ reqTodo: '{count} not started',
4272
+ reqUnknown: '{count} no evidence',
4273
+ unmappedNote: '{count} of {declared} unmapped',
4274
+ gapsLabel: 'Gaps',
4275
+ unverLabel: 'Could not check',
4276
+ copied: 'Copied',
4277
+ copyFailed: 'Copy failed -- select and copy manually',
4278
+ barNoData: '{label}: no data',
4279
+ cannotMeasure: 'cannot measure',
4280
+ barStories: 'stories',
4281
+ barRequirements: 'requirements',
4282
+ barLabel: '{label}: {done} of {total} complete',
4283
+ barLabelUnknown: '{label}: {done} of {total} complete, {unknown} could not be verified',
4284
+ unknownHint: '{count} could not be verified',
4285
+ nothingVerifiable: 'nothing verifiable',
4286
+ nextLabel: 'Next',
4287
+ allDone: 'Every story is done.',
4288
+ gapsTitle: 'Gaps ({count})',
4289
+ unverTitle: 'Could not check ({count})',
4290
+ askAnAgent: 'Ask an agent',
4291
+ title: 'BMAD Sprint board',
4292
+ 'guide.title': 'Sprint board',
4293
+ 'guide.description': 'BMAD sprint status as a board',
4294
+ refresh: 'Re-read the sprint files',
4295
+ retry: 'Retry',
4296
+ loading: 'Reading the sprint status\u2026',
4297
+ loadFailed: 'Could not read the sprint files.',
4298
+ 'noSprint.title': 'No sprint tracking file in this workspace',
4299
+ 'noSprint.body':
4300
+ 'The board looks for {names} anywhere in the Session workspace, checking the usual BMAD locations first.',
4301
+ 'noSprint.searched':
4302
+ 'Nothing matched {candidates} conventional locations, nor a scan {depth} directories deep.',
4303
+ 'noSprint.retry': 'Search again',
4304
+ updated: 'Updated',
4305
+ viewLabel: 'Group the board by',
4306
+ viewStatus: 'Status',
4307
+ viewEpic: 'Epic',
4308
+ epicLabel: 'Epic',
4309
+ retroLabel: 'Retro',
4310
+ openSpec: 'Open the spec file',
4311
+ openRetro: 'Open the retrospective',
4312
+ noSpec: 'No spec file yet',
4313
+ 'status.backlog': 'Backlog',
4314
+ 'status.ready-for-dev': 'Ready for dev',
4315
+ 'status.in-progress': 'In progress',
4316
+ 'status.review': 'In review',
4317
+ 'status.done': 'Done',
4318
+ 'status.optional': 'Optional',
4319
+ 'status.open': 'Open',
4320
+ actionsTitle: 'Retro action items',
4321
+ actionsDone: '{count} completed',
4322
+ noOpenActions: 'No open action items.',
4323
+ deferredTitle: 'Deferred work',
4324
+ noDeferred: 'No deferred work recorded.',
4325
+ deferredCount: '{count} deferred',
4326
+ deferredFrom: 'Deferred from story {id}',
4327
+ 'shortcut.noSession': 'Open a Session to show the sprint board',
4328
+ };
4329
+
4330
+ const zh = {
4331
+ refreshing: '\u6B63\u5728\u5237\u65B0\u2026',
4332
+ viewRequirements: '\u9700\u6C42',
4333
+ viewSpecs: '\u89C4\u683C',
4334
+ askAgentPill: '\u8BE2\u95EE\u667A\u80FD\u4F53 \u00B7 \u63D0\u793A\u8BCD',
4335
+ specTitle: '\u72EC\u7ACB\u89C4\u683C',
4336
+ 'specGuide.title': '\u72EC\u7ACB\u89C4\u683C',
4337
+ 'specGuide.description': '\u51B2\u523A\u6587\u4EF6\u4E2D\u6CA1\u6709\u4EFB\u4F55\u6545\u4E8B\u58F0\u660E\u7684\u89C4\u683C',
4338
+ specDeclared: '\u5171 {count} \u4EFD',
4339
+ specDone: '{count} \u5DF2\u5B8C\u6210',
4340
+ specOpen: '{count} \u4ECD\u672A\u5B8C\u6210',
4341
+ specOpenTag: '\u4E0D\u5728\u51B2\u523A\u6587\u4EF6\u4E2D',
4342
+ specDoneTag: '\u65E0\u4EBA\u8BA4\u9886',
4343
+ specPickUp: '\u8BA9\u667A\u80FD\u4F53\u63A5\u624B',
4344
+ unmappedNote: '{declared} \u9879\u4E2D {count} \u9879\u672A\u6620\u5C04',
4345
+ actionsCount: '{open} \u9879\u5F85\u529E\uFF0C{done} \u9879\u5DF2\u5B8C\u6210',
4346
+ specTitle: '\u72EC\u7ACB\u89C4\u683C',
4347
+ 'specGuide.title': '\u72EC\u7ACB\u89C4\u683C',
4348
+ 'specGuide.description': '\u51B2\u523A\u6587\u4EF6\u4E2D\u6CA1\u6709\u4EFB\u4F55\u6545\u4E8B\u58F0\u660E\u7684\u89C4\u683C',
4349
+ specDeclared: '\u5171 {count} \u4EFD',
4350
+ specDone: '{count} \u5DF2\u5B8C\u6210',
4351
+ specOpen: '{count} \u4ECD\u672A\u5B8C\u6210',
4352
+ specOpenTag: '\u4E0D\u5728\u51B2\u523A\u6587\u4EF6\u4E2D',
4353
+ specDoneTag: '\u65E0\u4EBA\u8BA4\u9886',
4354
+ specPickUp: '\u8BA9\u667A\u80FD\u4F53\u63A5\u624B',
4355
+ deferredAction: '\u8BE2\u95EE\u662F\u5426\u63A5\u624B',
4356
+ takeActionItem: '\u8BA9\u667A\u80FD\u4F53\u63A5\u624B',
4357
+ finishActionItem: '\u8BA9\u667A\u80FD\u4F53\u5B8C\u6210',
4358
+ reqTitle: '\u9700\u6C42',
4359
+ 'reqGuide.title': '\u9700\u6C42',
4360
+ 'reqGuide.description':
4361
+ '\u6309\u7C7B\u522B\u5217\u51FA\u6BCF\u4E00\u9879\u58F0\u660E\u7684\u9700\u6C42\uFF0C\u4EE5\u53CA\u8BC1\u636E\u652F\u6301\u7684\u72B6\u6001',
4362
+ reqDeclared: '\u5171 {count} \u9879',
4363
+ reqDone: '{count} \u5DF2\u5B8C\u6210',
4364
+ reqPartial: '{count} \u90E8\u5206',
4365
+ reqTodo: '{count} \u672A\u5F00\u59CB',
4366
+ reqUnknown: '{count} \u65E0\u8BC1\u636E',
4367
+ unmappedNote: '{declared} \u9879\u4E2D {count} \u9879\u672A\u6620\u5C04',
4368
+ gapsLabel: '\u95EE\u9898',
4369
+ unverLabel: '\u65E0\u6CD5\u6838\u5B9E',
4370
+ copied: '\u5DF2\u590D\u5236',
4371
+ copyFailed: '\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9\u5E76\u590D\u5236',
4372
+ barNoData: '{label}\uff1a\u65e0\u6570\u636e',
4373
+ cannotMeasure: '\u65e0\u6cd5\u6d4b\u91cf',
4374
+ barStories: '\u6545\u4e8b',
4375
+ barRequirements: '\u9700\u6c42',
4376
+ barLabel: '{label}\uff1a{total} \u9879\u4e2d\u5b8c\u6210 {done} \u9879',
4377
+ barLabelUnknown:
4378
+ '{label}\uff1a{total} \u9879\u4e2d\u5b8c\u6210 {done} \u9879\uff0c{unknown} \u9879\u65e0\u6cd5\u6838\u5b9e',
4379
+ unknownHint: '{count} \u9879\u65e0\u6cd5\u6838\u5b9e',
4380
+ nothingVerifiable: '\u65e0\u53ef\u6838\u5b9e\u9879',
4381
+ nextLabel: '\u4e0b\u4e00\u6b65',
4382
+ allDone: '\u6240\u6709\u6545\u4e8b\u5df2\u5b8c\u6210\u3002',
4383
+ gapsTitle: '\u95ee\u9898\uff08{count}\uff09',
4384
+ unverTitle: '\u65e0\u6cd5\u6838\u5b9e\uff08{count}\uff09',
4385
+ askAnAgent: '\u8be2\u95ee\u667e\u80fd\u4f53',
4386
+ title: 'BMAD \u51B2\u523A\u770B\u677F',
4387
+ 'guide.title': '\u51B2\u523A\u770B\u677F',
4388
+ 'guide.description':
4389
+ '\u4EE5\u770B\u677F\u5F62\u5F0F\u5C55\u793A BMAD \u51B2\u523A\u72B6\u6001',
4390
+ refresh: '\u91CD\u65B0\u8BFB\u53D6\u51B2\u523A\u6587\u4EF6',
4391
+ retry: '\u91CD\u8BD5',
4392
+ loading: '\u6B63\u5728\u8BFB\u53D6\u51B2\u523A\u72B6\u6001\u2026',
4393
+ loadFailed: '\u65E0\u6CD5\u8BFB\u53D6\u51B2\u523A\u6587\u4EF6\u3002',
4394
+ 'noSprint.title':
4395
+ '\u6B64\u5DE5\u4F5C\u533A\u4E2D\u6CA1\u6709\u51B2\u523A\u8DDF\u8E2A\u6587\u4EF6',
4396
+ 'noSprint.body':
4397
+ '\u770B\u677F\u4F1A\u5728\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u4E2D\u641C\u7D22 {names}\uFF0C\u5E76\u4F18\u5148\u68C0\u67E5\u5E38\u89C1\u7684 BMAD \u4F4D\u7F6E\u3002',
4398
+ 'noSprint.searched':
4399
+ '\u5DF2\u68C0\u67E5 {candidates} \u4E2A\u5E38\u89C1\u4F4D\u7F6E\uFF0C\u5E76\u626B\u63CF\u5230 {depth} \u5C42\u6DF1\u5EA6\uFF0C\u5747\u672A\u547D\u4E2D\u3002',
4400
+ 'noSprint.retry': '\u91CD\u65B0\u641C\u7D22',
4401
+ updated: '\u66F4\u65B0\u4E8E',
4402
+ viewLabel: '\u5206\u7EC4\u65B9\u5F0F',
4403
+ viewStatus: '\u72B6\u6001',
4404
+ viewEpic: '\u8BBE\u5B9A\u96C6',
4405
+ epicLabel: '\u8BBE\u5B9A\u96C6',
4406
+ retroLabel: '\u56DE\u987E',
4407
+ openSpec: '\u6253\u5F00\u89C4\u683C\u6587\u4EF6',
4408
+ openRetro: '\u6253\u5F00\u56DE\u987E\u6587\u4EF6',
4409
+ noSpec: '\u5C1A\u65E0\u89C4\u683C\u6587\u4EF6',
4410
+ 'status.backlog': '\u5F85\u529E',
4411
+ 'status.ready-for-dev': '\u53EF\u5F00\u53D1',
4412
+ 'status.in-progress': '\u8FDB\u884C\u4E2D',
4413
+ 'status.review': '\u5BA1\u67E5\u4E2D',
4414
+ 'status.done': '\u5DF2\u5B8C\u6210',
4415
+ 'status.optional': '\u53EF\u9009',
4416
+ 'status.open': '\u5F85\u529E',
4417
+ actionsTitle: '\u56DE\u987E\u884C\u52A8\u9879',
4418
+ actionsDone: '\u5DF2\u5B8C\u6210 {count} \u9879',
4419
+ noOpenActions: '\u6CA1\u6709\u5F85\u529E\u884C\u52A8\u9879\u3002',
4420
+ deferredTitle: '\u5EF6\u671F\u5DE5\u4F5C',
4421
+ noDeferred: '\u6CA1\u6709\u8BB0\u5F55\u5EF6\u671F\u5DE5\u4F5C\u3002',
4422
+ deferredCount: '\u5EF6\u671F {count} \u9879',
4423
+ deferredFrom: '\u6765\u81EA\u6545\u4E8B {id} \u7684\u5EF6\u671F\u9879',
4424
+ 'shortcut.noSession':
4425
+ '\u9700\u8981\u6253\u5F00\u4E00\u4E2A\u4F1A\u8BDD\u624D\u80FD\u663E\u793A\u51B2\u523A\u770B\u677F',
4426
+ };
4427
+
4428
+ return {
4429
+ // `remote.workspaceFiles` is its own inject key: the Remote face guards
4430
+ // each namespace, so naming only `remote` leaves the property unreachable.
4431
+ // `shortcuts` is deliberately absent: the keybinding is an optional extra,
4432
+ // so it is requested through `ctx.inject` and the tab still works without.
4433
+ inject: [
4434
+ 'slots',
4435
+ 'locale',
4436
+ 'sidebarRightTabs',
4437
+ 'sidebarRight',
4438
+ 'remote',
4439
+ 'remote.workspaceFiles',
4440
+ ],
4441
+
4442
+ /**
4443
+ * Register the tab type, its guide entry, its body, and its shortcut.
4444
+ * @param ctx - Client root context.
4445
+ */
4446
+ apply(ctx) {
4447
+ ctx.effect(
4448
+ () => ctx.locale.register(NS, { en, zh }),
4449
+ 'bmad-sprint-board: dictionaries',
4450
+ );
4451
+
4452
+ const t = ctx.locale.bind(NS);
4453
+
4454
+ ctx.inject(['shortcuts'], (ctx) => {
4455
+ ctx.effect(
4456
+ () =>
4457
+ ctx.shortcuts.register({
4458
+ id: BOARD_COMMAND,
4459
+ label: () => t('guide.title'),
4460
+ aliases: [
4461
+ 'sprint board',
4462
+ 'bmad sprint',
4463
+ 'sprint',
4464
+ 'board',
4465
+ 'kanban',
4466
+ ],
4467
+ defaults: {
4468
+ // Same family as the shipped tab commands (Files is primary+P,
4469
+ // Browser primary+T): one primary chord on the desktop, and a
4470
+ // browser-safe primary+alt chord on the web, where a bare
4471
+ // primary+letter is off limits. `S` for sprint: every other
4472
+ // letter in this shape is taken or reserved, and the Registry
4473
+ // refuses a default that collides on any declared profile.
4474
+ 'desktop:macos': { code: 'KeyS', modifiers: ['primary'] },
4475
+ 'desktop:windows': { code: 'KeyS', modifiers: ['primary'] },
4476
+ 'desktop:linux': { code: 'KeyS', modifiers: ['primary'] },
4477
+ 'web:macos': { code: 'KeyS', modifiers: ['primary', 'alt'] },
4478
+ 'web:windows': {
4479
+ code: 'KeyS',
4480
+ modifiers: ['primary', 'alt'],
4481
+ },
4482
+ },
4483
+ // Reachable while typing, like every other sidebar tab command.
4484
+ regions: ['page', 'editable', 'terminal'],
4485
+ modals: [],
4486
+ resolve: ({ target }) => {
4487
+ const pane = ctx.sidebarRight.commandTarget(target);
4488
+ if (pane === undefined) {
4489
+ return { status: 'blocked', reason: t('shortcut.noSession') };
4490
+ }
4491
+ return {
4492
+ status: 'handled',
4493
+ run: () => ctx.sidebarRight.openTabFromTarget(TAB_KIND, pane),
4494
+ };
4495
+ },
4496
+ }),
4497
+ 'bmad-sprint-board: shortcut',
4498
+ );
4499
+ });
4500
+
4501
+ ctx.effect(
4502
+ () =>
4503
+ ctx.sidebarRightTabs.register({
4504
+ id: PLUGIN_ID,
4505
+ kind: TAB_KIND,
4506
+ priority: 'extension',
4507
+ title: () => t('title'),
4508
+ guide: [
4509
+ {
4510
+ id: 'board',
4511
+ order: 30,
4512
+ title: () => t('guide.title'),
4513
+ description: () => t('guide.description'),
4514
+ icon: GuideArtworkSprintBoard,
4515
+ // Shows the resolved keycap on the guide capsule.
4516
+ commandId: BOARD_COMMAND,
4517
+ },
4518
+ ],
4519
+ }),
4520
+ 'bmad-sprint-board: tab type',
4521
+ );
4522
+
4523
+ // Both tabs read the same artefacts through the same readers, so the
4524
+ // injection is built once and registered under each tab's own key.
4525
+ const paneInject = (sessionId) => ({
4526
+ sessionId,
4527
+ sessionId,
4528
+ readWorkspaceFile: (path, signal) =>
4529
+ readWholeFile(
4530
+ ctx.remote.workspaceFiles,
4531
+ sessionId,
4532
+ path,
4533
+ signal,
4534
+ ),
4535
+ listWorkspaceDir: async (path, signal) => {
4536
+ const result = await ctx.remote.workspaceFiles.list(
4537
+ sessionId,
4538
+ path,
4539
+ signal,
4540
+ );
4541
+ if (!result.ok) throw result.error;
4542
+ return result.value.entries;
4543
+ },
4544
+ // Existence probes during the scan: a missing path is an answer,
4545
+ // not a failure, so this reports the miss instead of throwing.
4546
+ statWorkspacePath: async (path, signal) => {
4547
+ try {
4548
+ const result = await ctx.remote.workspaceFiles.stat(
4549
+ sessionId,
4550
+ path,
4551
+ signal,
4552
+ );
4553
+ return result.ok
4554
+ ? { ok: true }
4555
+ : { ok: false, code: result.error.code };
4556
+ } catch (error) {
4557
+ return { ok: false, code: String(error) };
4558
+ }
4559
+ },
4560
+ fileAddress: (path) => sessionFileAddress(sessionId, path),
4561
+ });
4562
+
4563
+ for (const [key, Component] of [
4564
+ [PLUGIN_ID, SprintBoard],
4565
+ ]) {
4566
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
4567
+ ctx.slots.register(
4568
+ { name: 'sidebar.right.pane.tab', key, locale: NS, inject: paneInject },
4569
+ Component,
4570
+ ),
4571
+ );
4572
+ }
4573
+
4574
+ },
4575
+ };
4576
+ },
4577
+ });