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/LICENSE +21 -0
- package/README.md +156 -0
- package/client.js +4577 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +12 -0
- package/index.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -0
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
|
+
});
|