specsmd 0.0.0-dev.86 → 0.0.0-dev.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +15 -0
  2. package/bin/cli.js +15 -1
  3. package/flows/fire/agents/builder/agent.md +2 -2
  4. package/flows/fire/agents/builder/skills/code-review/SKILL.md +1 -1
  5. package/flows/fire/agents/builder/skills/run-execute/SKILL.md +16 -7
  6. package/flows/fire/agents/builder/skills/run-execute/scripts/complete-run.cjs +22 -3
  7. package/flows/fire/agents/builder/skills/run-execute/scripts/init-run.cjs +63 -20
  8. package/flows/fire/agents/builder/skills/run-execute/scripts/update-checkpoint.cjs +254 -0
  9. package/flows/fire/agents/builder/skills/run-execute/scripts/update-phase.cjs +17 -6
  10. package/flows/fire/agents/builder/skills/run-status/SKILL.md +1 -1
  11. package/flows/fire/agents/orchestrator/agent.md +1 -1
  12. package/flows/fire/agents/orchestrator/skills/status/SKILL.md +2 -2
  13. package/flows/fire/memory-bank.yaml +4 -4
  14. package/lib/dashboard/aidlc/parser.js +581 -0
  15. package/lib/dashboard/fire/model.js +382 -0
  16. package/lib/dashboard/fire/parser.js +470 -0
  17. package/lib/dashboard/flow-detect.js +86 -0
  18. package/lib/dashboard/git/changes.js +362 -0
  19. package/lib/dashboard/git/worktrees.js +248 -0
  20. package/lib/dashboard/index.js +709 -0
  21. package/lib/dashboard/runtime/watch-runtime.js +122 -0
  22. package/lib/dashboard/simple/parser.js +293 -0
  23. package/lib/dashboard/tui/app.js +1675 -0
  24. package/lib/dashboard/tui/components/error-banner.js +35 -0
  25. package/lib/dashboard/tui/components/header.js +60 -0
  26. package/lib/dashboard/tui/components/help-footer.js +15 -0
  27. package/lib/dashboard/tui/components/stats-strip.js +35 -0
  28. package/lib/dashboard/tui/file-entries.js +383 -0
  29. package/lib/dashboard/tui/flow-builders.js +991 -0
  30. package/lib/dashboard/tui/git-builders.js +218 -0
  31. package/lib/dashboard/tui/helpers.js +236 -0
  32. package/lib/dashboard/tui/overlays.js +242 -0
  33. package/lib/dashboard/tui/preview.js +220 -0
  34. package/lib/dashboard/tui/renderer.js +76 -0
  35. package/lib/dashboard/tui/row-builders.js +797 -0
  36. package/lib/dashboard/tui/sections.js +45 -0
  37. package/lib/dashboard/tui/store.js +44 -0
  38. package/lib/dashboard/tui/views/overview-view.js +61 -0
  39. package/lib/dashboard/tui/views/runs-view.js +93 -0
  40. package/lib/dashboard/tui/worktree-builders.js +229 -0
  41. package/lib/installers/CodexInstaller.js +72 -1
  42. package/package.json +7 -3
@@ -0,0 +1,709 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+ const { detectFlow, detectAvailableFlows } = require('./flow-detect');
5
+ const { parseFireDashboard } = require('./fire/parser');
6
+ const { parseAidlcDashboard } = require('./aidlc/parser');
7
+ const { parseSimpleDashboard } = require('./simple/parser');
8
+ const { formatDashboardText } = require('./tui/renderer');
9
+ const { createDashboardApp } = require('./tui/app');
10
+ const { discoverGitWorktrees, pickWorktree, pathExistsAsDirectory } = require('./git/worktrees');
11
+ const { listGitChanges } = require('./git/changes');
12
+
13
+ function parseRefreshMs(raw) {
14
+ const parsed = Number.parseInt(String(raw || '1000'), 10);
15
+ if (Number.isNaN(parsed)) {
16
+ return 1000;
17
+ }
18
+
19
+ return Math.max(200, Math.min(parsed, 5000));
20
+ }
21
+
22
+ function clearTerminalOutput(stream = process.stdout) {
23
+ if (!stream || typeof stream.write !== 'function') {
24
+ return;
25
+ }
26
+
27
+ if (stream.isTTY === false) {
28
+ return;
29
+ }
30
+
31
+ if (typeof console.clear === 'function') {
32
+ console.clear();
33
+ }
34
+ // Avoid wiping scrollback; just clear the current visible frame.
35
+ stream.write('\u001B[H\u001B[J');
36
+ }
37
+
38
+ function createInkStdout(stream = process.stdout) {
39
+ if (!stream || typeof stream.write !== 'function') {
40
+ return stream;
41
+ }
42
+
43
+ return {
44
+ isTTY: true,
45
+ get columns() {
46
+ return stream.columns;
47
+ },
48
+ get rows() {
49
+ return stream.rows;
50
+ },
51
+ write: (...args) => stream.write(...args),
52
+ on: (...args) => (typeof stream.on === 'function' ? stream.on(...args) : undefined),
53
+ off: (...args) => (typeof stream.off === 'function' ? stream.off(...args) : undefined),
54
+ once: (...args) => (typeof stream.once === 'function' ? stream.once(...args) : undefined),
55
+ removeListener: (...args) => (typeof stream.removeListener === 'function' ? stream.removeListener(...args) : undefined)
56
+ };
57
+ }
58
+
59
+ const FLOW_CONFIG = {
60
+ fire: {
61
+ markerDir: '.specs-fire',
62
+ parse: parseFireDashboard
63
+ },
64
+ aidlc: {
65
+ markerDir: 'memory-bank',
66
+ parse: parseAidlcDashboard
67
+ },
68
+ simple: {
69
+ markerDir: 'specs',
70
+ parse: parseSimpleDashboard
71
+ }
72
+ };
73
+
74
+ const MAX_WORKTREE_WATCH_ROOTS = 12;
75
+
76
+ function resolveRootPathForFlow(workspacePath, flow) {
77
+ const config = FLOW_CONFIG[flow];
78
+ if (!config) {
79
+ return workspacePath;
80
+ }
81
+ return path.join(workspacePath, config.markerDir);
82
+ }
83
+
84
+ function readFileSafe(filePath) {
85
+ try {
86
+ return fs.readFileSync(filePath, 'utf8');
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function parseFrontmatter(content) {
93
+ const match = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
94
+ if (!match) {
95
+ return {};
96
+ }
97
+ try {
98
+ const parsed = yaml.load(match[1]);
99
+ return parsed && typeof parsed === 'object' ? parsed : {};
100
+ } catch {
101
+ return {};
102
+ }
103
+ }
104
+
105
+ function listSubdirectories(dirPath) {
106
+ try {
107
+ return fs.readdirSync(dirPath, { withFileTypes: true })
108
+ .filter((entry) => entry.isDirectory())
109
+ .map((entry) => entry.name)
110
+ .sort((a, b) => a.localeCompare(b));
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ function listMarkdownFiles(dirPath) {
117
+ try {
118
+ return fs.readdirSync(dirPath, { withFileTypes: true })
119
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
120
+ .map((entry) => entry.name)
121
+ .sort((a, b) => a.localeCompare(b));
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ function normalizeAidlcStatus(rawStatus) {
128
+ if (typeof rawStatus !== 'string') {
129
+ return 'unknown';
130
+ }
131
+
132
+ const normalized = rawStatus.toLowerCase().trim().replace(/[\s_]+/g, '-');
133
+ if (['complete', 'completed', 'done', 'finished', 'closed', 'resolved'].includes(normalized)) {
134
+ return 'completed';
135
+ }
136
+ if (['blocked'].includes(normalized)) {
137
+ return 'blocked';
138
+ }
139
+ if (['in-progress', 'inprogress', 'active', 'started', 'wip', 'working', 'ready', 'construction'].includes(normalized)) {
140
+ return 'in_progress';
141
+ }
142
+ if (['draft', 'pending', 'planned', 'todo', 'new', 'queued'].includes(normalized)) {
143
+ return 'pending';
144
+ }
145
+ return 'unknown';
146
+ }
147
+
148
+ function normalizeFlowId(flow) {
149
+ return String(flow || '').toLowerCase().trim();
150
+ }
151
+
152
+ function normalizeFireRunWorkItem(raw) {
153
+ if (!raw || typeof raw !== 'object') {
154
+ return null;
155
+ }
156
+ const id = typeof raw.id === 'string' ? raw.id : '';
157
+ if (id === '') {
158
+ return null;
159
+ }
160
+ return {
161
+ id,
162
+ intentId: typeof raw.intent === 'string' ? raw.intent : (typeof raw.intentId === 'string' ? raw.intentId : ''),
163
+ mode: typeof raw.mode === 'string' ? raw.mode : 'confirm',
164
+ status: typeof raw.status === 'string' ? raw.status : 'pending',
165
+ currentPhase: typeof raw.current_phase === 'string' ? raw.current_phase : (typeof raw.currentPhase === 'string' ? raw.currentPhase : undefined),
166
+ checkpointState: typeof raw.checkpoint_state === 'string'
167
+ ? raw.checkpoint_state
168
+ : (typeof raw.checkpointState === 'string'
169
+ ? raw.checkpointState
170
+ : undefined),
171
+ currentCheckpoint: typeof raw.current_checkpoint === 'string'
172
+ ? raw.current_checkpoint
173
+ : (typeof raw.currentCheckpoint === 'string' ? raw.currentCheckpoint : undefined)
174
+ };
175
+ }
176
+
177
+ function probeFireWorktreeActivity(worktreePath) {
178
+ const rootPath = path.join(worktreePath, '.specs-fire');
179
+ const statePath = path.join(rootPath, 'state.yaml');
180
+ const stateContent = readFileSafe(statePath);
181
+ if (stateContent == null) {
182
+ return { activeRuns: [] };
183
+ }
184
+
185
+ let state;
186
+ try {
187
+ state = yaml.load(stateContent) || {};
188
+ } catch {
189
+ return { activeRuns: [] };
190
+ }
191
+
192
+ const activeRuns = Array.isArray(state?.runs?.active) ? state.runs.active : [];
193
+ return {
194
+ activeRuns: activeRuns.map((run) => {
195
+ const runId = typeof run?.id === 'string' ? run.id : '';
196
+ if (runId === '') {
197
+ return null;
198
+ }
199
+ const folderPath = path.join(rootPath, 'runs', runId);
200
+ const workItemsRaw = Array.isArray(run?.work_items)
201
+ ? run.work_items
202
+ : (Array.isArray(run?.workItems) ? run.workItems : []);
203
+ const workItems = workItemsRaw.map((item) => normalizeFireRunWorkItem(item)).filter(Boolean);
204
+ return {
205
+ id: runId,
206
+ scope: typeof run?.scope === 'string' ? run.scope : 'single',
207
+ currentItem: typeof run?.current_item === 'string'
208
+ ? run.current_item
209
+ : (typeof run?.currentItem === 'string' ? run.currentItem : ''),
210
+ workItems,
211
+ startedAt: typeof run?.started === 'string' ? run.started : '',
212
+ folderPath,
213
+ hasPlan: fs.existsSync(path.join(folderPath, 'plan.md')),
214
+ hasWalkthrough: fs.existsSync(path.join(folderPath, 'walkthrough.md')),
215
+ hasTestReport: fs.existsSync(path.join(folderPath, 'test-report.md'))
216
+ };
217
+ }).filter(Boolean)
218
+ };
219
+ }
220
+
221
+ function probeAidlcWorktreeActivity(worktreePath) {
222
+ const rootPath = path.join(worktreePath, 'memory-bank');
223
+ const boltsPath = path.join(rootPath, 'bolts');
224
+ const boltIds = listSubdirectories(boltsPath);
225
+ if (boltIds.length === 0) {
226
+ return { activeBolts: [] };
227
+ }
228
+
229
+ const activeBolts = boltIds.map((boltId) => {
230
+ const boltPath = path.join(boltsPath, boltId);
231
+ const boltFilePath = path.join(boltPath, 'bolt.md');
232
+ const content = readFileSafe(boltFilePath);
233
+ if (content == null) {
234
+ return null;
235
+ }
236
+
237
+ const frontmatter = parseFrontmatter(content);
238
+ const status = normalizeAidlcStatus(frontmatter.status);
239
+ if (status !== 'in_progress') {
240
+ return null;
241
+ }
242
+
243
+ const currentStage = typeof frontmatter.current_stage === 'string'
244
+ ? frontmatter.current_stage
245
+ : (typeof frontmatter.currentStage === 'string' ? frontmatter.currentStage : null);
246
+
247
+ return {
248
+ id: boltId,
249
+ intent: typeof frontmatter.intent === 'string' ? frontmatter.intent : '',
250
+ unit: typeof frontmatter.unit === 'string' ? frontmatter.unit : '',
251
+ type: typeof frontmatter.type === 'string' ? frontmatter.type : 'simple-construction-bolt',
252
+ status,
253
+ currentStage,
254
+ path: boltPath,
255
+ filePath: boltFilePath,
256
+ files: listMarkdownFiles(boltPath),
257
+ startedAt: typeof frontmatter.started === 'string' ? frontmatter.started : undefined
258
+ };
259
+ }).filter(Boolean);
260
+
261
+ return { activeBolts };
262
+ }
263
+
264
+ function probeSimpleWorktreeActivity(worktreePath) {
265
+ const rootPath = path.join(worktreePath, 'specs');
266
+ if (!pathExistsAsDirectory(rootPath)) {
267
+ return { activeSpecs: [] };
268
+ }
269
+
270
+ const specFolders = listSubdirectories(rootPath);
271
+ const activeSpecs = specFolders.map((name) => {
272
+ const specPath = path.join(rootPath, name);
273
+ const tasksPath = path.join(specPath, 'tasks.md');
274
+ if (!fs.existsSync(tasksPath)) {
275
+ return null;
276
+ }
277
+ return {
278
+ name,
279
+ path: specPath
280
+ };
281
+ }).filter(Boolean);
282
+
283
+ return { activeSpecs };
284
+ }
285
+
286
+ function probeWorktreeActivity(flow, worktreePath) {
287
+ const normalizedFlow = normalizeFlowId(flow);
288
+ if (normalizedFlow === 'aidlc') {
289
+ return probeAidlcWorktreeActivity(worktreePath);
290
+ }
291
+ if (normalizedFlow === 'simple') {
292
+ return probeSimpleWorktreeActivity(worktreePath);
293
+ }
294
+ return probeFireWorktreeActivity(worktreePath);
295
+ }
296
+
297
+ function extractActivityFromSnapshot(flow, snapshot) {
298
+ const normalizedFlow = normalizeFlowId(flow);
299
+ if (normalizedFlow === 'aidlc') {
300
+ return {
301
+ activeBolts: Array.isArray(snapshot?.activeBolts) ? snapshot.activeBolts : []
302
+ };
303
+ }
304
+ if (normalizedFlow === 'simple') {
305
+ return {
306
+ activeSpecs: Array.isArray(snapshot?.activeSpecs) ? snapshot.activeSpecs : []
307
+ };
308
+ }
309
+ return {
310
+ activeRuns: Array.isArray(snapshot?.activeRuns) ? snapshot.activeRuns : []
311
+ };
312
+ }
313
+
314
+ function getActivityCount(flow, activity) {
315
+ const normalizedFlow = normalizeFlowId(flow);
316
+ if (normalizedFlow === 'aidlc') {
317
+ return Array.isArray(activity?.activeBolts) ? activity.activeBolts.length : 0;
318
+ }
319
+ if (normalizedFlow === 'simple') {
320
+ return Array.isArray(activity?.activeSpecs) ? activity.activeSpecs.length : 0;
321
+ }
322
+ return Array.isArray(activity?.activeRuns) ? activity.activeRuns.length : 0;
323
+ }
324
+
325
+ function buildWorktreeEnvelope(flow, worktrees, selectedWorktreeId, cache, discovery) {
326
+ const normalizedFlow = normalizeFlowId(flow);
327
+ const items = (Array.isArray(worktrees) ? worktrees : []).map((worktree) => {
328
+ const availableFlows = detectAvailableFlows(worktree.path);
329
+ const flowAvailable = availableFlows.includes(normalizedFlow);
330
+ const cacheKey = `${normalizedFlow}:${worktree.id}`;
331
+ const cached = cache.get(cacheKey);
332
+ const status = flowAvailable
333
+ ? (cached?.status || 'loading')
334
+ : 'unavailable';
335
+ const activity = cached?.activity || null;
336
+ const activeCount = getActivityCount(normalizedFlow, activity);
337
+
338
+ return {
339
+ ...worktree,
340
+ isSelected: worktree.id === selectedWorktreeId,
341
+ flowAvailable,
342
+ status,
343
+ activeCount,
344
+ activity,
345
+ error: cached?.error || null
346
+ };
347
+ });
348
+
349
+ return {
350
+ flow: normalizedFlow,
351
+ source: discovery?.source || 'fallback',
352
+ isGitRepo: Boolean(discovery?.isGitRepo),
353
+ selectedWorktreeId,
354
+ hasPendingScans: items.some((item) => item.status === 'loading'),
355
+ error: discovery?.error,
356
+ items
357
+ };
358
+ }
359
+
360
+ function getWatchRootsForEnvelope(flow, envelope) {
361
+ const normalizedFlow = normalizeFlowId(flow);
362
+ const config = FLOW_CONFIG[normalizedFlow];
363
+ if (!config) {
364
+ return [];
365
+ }
366
+
367
+ const roots = [];
368
+ const items = Array.isArray(envelope?.items) ? envelope.items : [];
369
+ const selectedId = envelope?.selectedWorktreeId;
370
+ const selectedItem = items.find((item) => item.id === selectedId);
371
+
372
+ if (selectedItem) {
373
+ roots.push(resolveRootPathForFlow(selectedItem.path, normalizedFlow));
374
+ }
375
+
376
+ for (const item of items) {
377
+ if (item.id === selectedId) {
378
+ continue;
379
+ }
380
+ if (!item.flowAvailable) {
381
+ continue;
382
+ }
383
+ if (item.activeCount <= 0 && item.status !== 'loading') {
384
+ continue;
385
+ }
386
+ roots.push(resolveRootPathForFlow(item.path, normalizedFlow));
387
+ }
388
+
389
+ return Array.from(new Set(roots))
390
+ .filter((rootPath) => pathExistsAsDirectory(rootPath))
391
+ .slice(0, MAX_WORKTREE_WATCH_ROOTS);
392
+ }
393
+
394
+ function formatStaticFlowText(flow, snapshot, error) {
395
+ if (flow === 'fire') {
396
+ return formatDashboardText({
397
+ snapshot,
398
+ error,
399
+ flow,
400
+ workspacePath: snapshot?.workspacePath || process.cwd(),
401
+ view: 'runs',
402
+ runFilter: 'all',
403
+ watchEnabled: false,
404
+ watchStatus: 'off',
405
+ showHelp: true,
406
+ lastRefreshAt: new Date().toISOString(),
407
+ width: process.stdout.columns || 120
408
+ });
409
+ }
410
+
411
+ if (error) {
412
+ return `[${error.code || 'ERROR'}] ${error.message || 'Dashboard error'}`;
413
+ }
414
+
415
+ if (flow === 'aidlc') {
416
+ const stats = snapshot?.stats || {};
417
+ const active = snapshot?.activeBolts?.[0] || null;
418
+ const lines = [
419
+ `specsmd dashboard | AIDLC | ${snapshot?.project?.name || 'unknown project'}`,
420
+ `intents ${stats.completedIntents || 0}/${stats.totalIntents || 0} | stories ${stats.completedStories || 0}/${stats.totalStories || 0} | bolts ${stats.activeBoltsCount || 0} active / ${stats.completedBolts || 0} done`,
421
+ active
422
+ ? `current bolt: ${active.id} (${active.currentStage || 'unknown stage'}) in ${active.intent || 'unknown intent'}`
423
+ : 'current bolt: none'
424
+ ];
425
+ return lines.join('\n');
426
+ }
427
+
428
+ if (flow === 'simple') {
429
+ const stats = snapshot?.stats || {};
430
+ const active = snapshot?.activeSpecs?.[0] || null;
431
+ const lines = [
432
+ `specsmd dashboard | SIMPLE | ${snapshot?.project?.name || 'unknown project'}`,
433
+ `specs ${stats.completedSpecs || 0}/${stats.totalSpecs || 0} complete | tasks ${stats.completedTasks || 0}/${stats.totalTasks || 0} complete`,
434
+ active
435
+ ? `current spec: ${active.name} (${active.state}) ${active.tasksCompleted}/${active.tasksTotal} tasks`
436
+ : 'current spec: none'
437
+ ];
438
+ return lines.join('\n');
439
+ }
440
+
441
+ return 'Unsupported flow.';
442
+ }
443
+
444
+ async function runFlowDashboard(options, flow, availableFlows = []) {
445
+ const workspacePath = path.resolve(options.path || process.cwd());
446
+ const config = FLOW_CONFIG[flow];
447
+
448
+ if (!config) {
449
+ console.error(`Flow \"${flow}\" dashboard is not available yet.`);
450
+ process.exitCode = 1;
451
+ return;
452
+ }
453
+
454
+ const flowIds = Array.from(new Set([
455
+ String(flow || '').toLowerCase(),
456
+ ...(Array.isArray(availableFlows) ? availableFlows.map((value) => String(value || '').toLowerCase()) : [])
457
+ ].filter(Boolean)));
458
+
459
+ const watchEnabled = options.watch !== false;
460
+ const refreshMs = parseRefreshMs(options.refreshMs);
461
+ const explicitWorktreeSelection = typeof options.worktree === 'string' ? options.worktree.trim() : '';
462
+ let selectedWorktreeId = explicitWorktreeSelection || null;
463
+ let lastWorktreeEnvelope = null;
464
+ const activityCache = new Map();
465
+ const pendingProbes = new Map();
466
+
467
+ const parseSnapshotForFlow = async (flowId, context = {}) => {
468
+ const normalizedFlow = normalizeFlowId(flowId);
469
+ const flowConfig = FLOW_CONFIG[normalizedFlow];
470
+ if (!flowConfig) {
471
+ return {
472
+ ok: false,
473
+ error: {
474
+ code: 'UNSUPPORTED_FLOW',
475
+ message: `Flow \"${normalizedFlow}\" is not supported.`
476
+ }
477
+ };
478
+ }
479
+
480
+ const discovery = discoverGitWorktrees(workspacePath);
481
+ const worktrees = Array.isArray(discovery?.worktrees) ? discovery.worktrees : [];
482
+ const requestedWorktreeSelector = String(context.selectedWorktreeId || selectedWorktreeId || explicitWorktreeSelection || '').trim();
483
+ const selectedWorktree = pickWorktree(worktrees, requestedWorktreeSelector, workspacePath)
484
+ || pickWorktree(worktrees, workspacePath, workspacePath)
485
+ || worktrees[0];
486
+
487
+ selectedWorktreeId = selectedWorktree?.id || selectedWorktreeId;
488
+
489
+ const selectedResult = selectedWorktree
490
+ ? await flowConfig.parse(selectedWorktree.path)
491
+ : {
492
+ ok: false,
493
+ error: {
494
+ code: 'WORKTREE_NOT_FOUND',
495
+ message: 'No selectable worktree was found for dashboard parsing.'
496
+ }
497
+ };
498
+
499
+ if (selectedWorktree) {
500
+ const cacheKey = `${normalizedFlow}:${selectedWorktree.id}`;
501
+ if (selectedResult?.ok) {
502
+ activityCache.set(cacheKey, {
503
+ status: 'ready',
504
+ activity: extractActivityFromSnapshot(normalizedFlow, selectedResult.snapshot),
505
+ error: null,
506
+ updatedAt: Date.now()
507
+ });
508
+ } else {
509
+ activityCache.set(cacheKey, {
510
+ status: 'error',
511
+ activity: null,
512
+ error: selectedResult?.error || null,
513
+ updatedAt: Date.now()
514
+ });
515
+ }
516
+ }
517
+
518
+ for (const worktree of worktrees) {
519
+ if (!worktree || worktree.id === selectedWorktreeId) {
520
+ continue;
521
+ }
522
+
523
+ const availableFlowsForWorktree = detectAvailableFlows(worktree.path);
524
+ const flowAvailable = availableFlowsForWorktree.includes(normalizedFlow);
525
+ const cacheKey = `${normalizedFlow}:${worktree.id}`;
526
+
527
+ if (!flowAvailable) {
528
+ activityCache.set(cacheKey, {
529
+ status: 'unavailable',
530
+ activity: null,
531
+ error: null,
532
+ updatedAt: Date.now()
533
+ });
534
+ continue;
535
+ }
536
+
537
+ const cached = activityCache.get(cacheKey);
538
+ const shouldRefreshProbe = !cached || (Date.now() - (cached.updatedAt || 0)) > 2000;
539
+ if (!shouldRefreshProbe || pendingProbes.has(cacheKey)) {
540
+ continue;
541
+ }
542
+
543
+ activityCache.set(cacheKey, {
544
+ status: 'loading',
545
+ activity: cached?.activity || null,
546
+ error: null,
547
+ updatedAt: Date.now()
548
+ });
549
+
550
+ const probePromise = Promise.resolve()
551
+ .then(() => probeWorktreeActivity(normalizedFlow, worktree.path))
552
+ .then((activity) => {
553
+ activityCache.set(cacheKey, {
554
+ status: 'ready',
555
+ activity,
556
+ error: null,
557
+ updatedAt: Date.now()
558
+ });
559
+ })
560
+ .catch((probeError) => {
561
+ activityCache.set(cacheKey, {
562
+ status: 'error',
563
+ activity: null,
564
+ error: {
565
+ code: 'WORKTREE_PROBE_ERROR',
566
+ message: probeError?.message || String(probeError)
567
+ },
568
+ updatedAt: Date.now()
569
+ });
570
+ })
571
+ .finally(() => {
572
+ pendingProbes.delete(cacheKey);
573
+ });
574
+
575
+ pendingProbes.set(cacheKey, probePromise);
576
+ }
577
+
578
+ const envelope = buildWorktreeEnvelope(
579
+ normalizedFlow,
580
+ worktrees,
581
+ selectedWorktreeId,
582
+ activityCache,
583
+ discovery
584
+ );
585
+ lastWorktreeEnvelope = envelope;
586
+
587
+ if (!selectedResult?.ok) {
588
+ return {
589
+ ok: false,
590
+ error: {
591
+ ...(selectedResult?.error || {
592
+ code: 'PARSE_ERROR',
593
+ message: 'Unable to parse selected worktree snapshot.'
594
+ }),
595
+ details: selectedWorktree
596
+ ? `worktree: ${selectedWorktree.displayBranch} (${selectedWorktree.path})`
597
+ : undefined
598
+ }
599
+ };
600
+ }
601
+
602
+ return {
603
+ ok: true,
604
+ snapshot: {
605
+ ...selectedResult.snapshot,
606
+ workspacePath: selectedWorktree?.path || selectedResult.snapshot?.workspacePath || workspacePath,
607
+ dashboardWorktrees: envelope,
608
+ gitChanges: listGitChanges(selectedWorktree?.path || workspacePath)
609
+ }
610
+ };
611
+ };
612
+
613
+ const initialResult = await parseSnapshotForFlow(flow, {
614
+ selectedWorktreeId: selectedWorktreeId || explicitWorktreeSelection || workspacePath
615
+ });
616
+ clearTerminalOutput();
617
+
618
+ if (!watchEnabled) {
619
+ const output = formatStaticFlowText(
620
+ flow,
621
+ initialResult.ok ? initialResult.snapshot : null,
622
+ initialResult.ok ? null : initialResult.error
623
+ );
624
+ console.log(output);
625
+ if (!initialResult.ok) {
626
+ process.exitCode = 1;
627
+ }
628
+ return;
629
+ }
630
+
631
+ const ink = await import('ink');
632
+ let inkUi = null;
633
+ try {
634
+ inkUi = await import('@inkjs/ui');
635
+ } catch {
636
+ inkUi = null;
637
+ }
638
+ const reactNamespace = await import('react');
639
+ const React = reactNamespace.default || reactNamespace;
640
+
641
+ const App = createDashboardApp({
642
+ React,
643
+ ink,
644
+ inkUi,
645
+ parseSnapshotForFlow,
646
+ workspacePath,
647
+ flow,
648
+ availableFlows: flowIds,
649
+ resolveRootPathForFlow: (flowId) => resolveRootPathForFlow(workspacePath, flowId),
650
+ resolveRootPathsForFlow: (flowId) => {
651
+ if (!lastWorktreeEnvelope) {
652
+ return [resolveRootPathForFlow(workspacePath, flowId)];
653
+ }
654
+ const roots = getWatchRootsForEnvelope(flowId, lastWorktreeEnvelope);
655
+ if (roots.length > 0) {
656
+ return roots;
657
+ }
658
+ return [resolveRootPathForFlow(workspacePath, flowId)];
659
+ },
660
+ refreshMs,
661
+ watchEnabled,
662
+ initialSnapshot: initialResult.ok ? initialResult.snapshot : null,
663
+ initialError: initialResult.ok ? null : initialResult.error
664
+ });
665
+
666
+ const { waitUntilExit } = ink.render(React.createElement(App), {
667
+ exitOnCtrlC: true,
668
+ stdout: createInkStdout(process.stdout),
669
+ stdin: process.stdin
670
+ });
671
+
672
+ await waitUntilExit();
673
+ }
674
+
675
+ async function run(options = {}) {
676
+ const workspacePath = path.resolve(options.path || process.cwd());
677
+
678
+ let detection;
679
+ try {
680
+ detection = detectFlow(workspacePath, options.flow);
681
+ } catch (error) {
682
+ console.error(error.message);
683
+ process.exitCode = 1;
684
+ return;
685
+ }
686
+
687
+ if (!detection.flow) {
688
+ console.error('No supported flow detected. Expected one of: .specs-fire, memory-bank, specs');
689
+ process.exitCode = 1;
690
+ return;
691
+ }
692
+
693
+ if (detection.warning) {
694
+ console.warn(`Warning: ${detection.warning}`);
695
+ }
696
+
697
+ await runFlowDashboard(options, detection.flow, detection.availableFlows);
698
+ }
699
+
700
+ module.exports = {
701
+ run,
702
+ runFlowDashboard,
703
+ parseRefreshMs,
704
+ formatStaticFlowText,
705
+ clearTerminalOutput,
706
+ createInkStdout,
707
+ probeWorktreeActivity,
708
+ getWatchRootsForEnvelope
709
+ };