specsmd 0.1.46 → 0.1.47

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.
@@ -1,10 +1,13 @@
1
+ const fs = require('fs');
1
2
  const path = require('path');
2
- const { detectFlow } = require('./flow-detect');
3
+ const yaml = require('js-yaml');
4
+ const { detectFlow, detectAvailableFlows } = require('./flow-detect');
3
5
  const { parseFireDashboard } = require('./fire/parser');
4
6
  const { parseAidlcDashboard } = require('./aidlc/parser');
5
7
  const { parseSimpleDashboard } = require('./simple/parser');
6
8
  const { formatDashboardText } = require('./tui/renderer');
7
9
  const { createDashboardApp } = require('./tui/app');
10
+ const { discoverGitWorktrees, pickWorktree, pathExistsAsDirectory } = require('./git/worktrees');
8
11
 
9
12
  function parseRefreshMs(raw) {
10
13
  const parsed = Number.parseInt(String(raw || '1000'), 10);
@@ -66,6 +69,8 @@ const FLOW_CONFIG = {
66
69
  }
67
70
  };
68
71
 
72
+ const MAX_WORKTREE_WATCH_ROOTS = 12;
73
+
69
74
  function resolveRootPathForFlow(workspacePath, flow) {
70
75
  const config = FLOW_CONFIG[flow];
71
76
  if (!config) {
@@ -74,6 +79,316 @@ function resolveRootPathForFlow(workspacePath, flow) {
74
79
  return path.join(workspacePath, config.markerDir);
75
80
  }
76
81
 
82
+ function readFileSafe(filePath) {
83
+ try {
84
+ return fs.readFileSync(filePath, 'utf8');
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function parseFrontmatter(content) {
91
+ const match = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
92
+ if (!match) {
93
+ return {};
94
+ }
95
+ try {
96
+ const parsed = yaml.load(match[1]);
97
+ return parsed && typeof parsed === 'object' ? parsed : {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ function listSubdirectories(dirPath) {
104
+ try {
105
+ return fs.readdirSync(dirPath, { withFileTypes: true })
106
+ .filter((entry) => entry.isDirectory())
107
+ .map((entry) => entry.name)
108
+ .sort((a, b) => a.localeCompare(b));
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ function listMarkdownFiles(dirPath) {
115
+ try {
116
+ return fs.readdirSync(dirPath, { withFileTypes: true })
117
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
118
+ .map((entry) => entry.name)
119
+ .sort((a, b) => a.localeCompare(b));
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ function normalizeAidlcStatus(rawStatus) {
126
+ if (typeof rawStatus !== 'string') {
127
+ return 'unknown';
128
+ }
129
+
130
+ const normalized = rawStatus.toLowerCase().trim().replace(/[\s_]+/g, '-');
131
+ if (['complete', 'completed', 'done', 'finished', 'closed', 'resolved'].includes(normalized)) {
132
+ return 'completed';
133
+ }
134
+ if (['blocked'].includes(normalized)) {
135
+ return 'blocked';
136
+ }
137
+ if (['in-progress', 'inprogress', 'active', 'started', 'wip', 'working', 'ready', 'construction'].includes(normalized)) {
138
+ return 'in_progress';
139
+ }
140
+ if (['draft', 'pending', 'planned', 'todo', 'new', 'queued'].includes(normalized)) {
141
+ return 'pending';
142
+ }
143
+ return 'unknown';
144
+ }
145
+
146
+ function normalizeFlowId(flow) {
147
+ return String(flow || '').toLowerCase().trim();
148
+ }
149
+
150
+ function normalizeFireRunWorkItem(raw) {
151
+ if (!raw || typeof raw !== 'object') {
152
+ return null;
153
+ }
154
+ const id = typeof raw.id === 'string' ? raw.id : '';
155
+ if (id === '') {
156
+ return null;
157
+ }
158
+ return {
159
+ id,
160
+ intentId: typeof raw.intent === 'string' ? raw.intent : (typeof raw.intentId === 'string' ? raw.intentId : ''),
161
+ mode: typeof raw.mode === 'string' ? raw.mode : 'confirm',
162
+ status: typeof raw.status === 'string' ? raw.status : 'pending',
163
+ currentPhase: typeof raw.current_phase === 'string' ? raw.current_phase : (typeof raw.currentPhase === 'string' ? raw.currentPhase : undefined),
164
+ checkpointState: typeof raw.checkpoint_state === 'string'
165
+ ? raw.checkpoint_state
166
+ : (typeof raw.checkpointState === 'string'
167
+ ? raw.checkpointState
168
+ : undefined),
169
+ currentCheckpoint: typeof raw.current_checkpoint === 'string'
170
+ ? raw.current_checkpoint
171
+ : (typeof raw.currentCheckpoint === 'string' ? raw.currentCheckpoint : undefined)
172
+ };
173
+ }
174
+
175
+ function probeFireWorktreeActivity(worktreePath) {
176
+ const rootPath = path.join(worktreePath, '.specs-fire');
177
+ const statePath = path.join(rootPath, 'state.yaml');
178
+ const stateContent = readFileSafe(statePath);
179
+ if (stateContent == null) {
180
+ return { activeRuns: [] };
181
+ }
182
+
183
+ let state;
184
+ try {
185
+ state = yaml.load(stateContent) || {};
186
+ } catch {
187
+ return { activeRuns: [] };
188
+ }
189
+
190
+ const activeRuns = Array.isArray(state?.runs?.active) ? state.runs.active : [];
191
+ return {
192
+ activeRuns: activeRuns.map((run) => {
193
+ const runId = typeof run?.id === 'string' ? run.id : '';
194
+ if (runId === '') {
195
+ return null;
196
+ }
197
+ const folderPath = path.join(rootPath, 'runs', runId);
198
+ const workItemsRaw = Array.isArray(run?.work_items)
199
+ ? run.work_items
200
+ : (Array.isArray(run?.workItems) ? run.workItems : []);
201
+ const workItems = workItemsRaw.map((item) => normalizeFireRunWorkItem(item)).filter(Boolean);
202
+ return {
203
+ id: runId,
204
+ scope: typeof run?.scope === 'string' ? run.scope : 'single',
205
+ currentItem: typeof run?.current_item === 'string'
206
+ ? run.current_item
207
+ : (typeof run?.currentItem === 'string' ? run.currentItem : ''),
208
+ workItems,
209
+ startedAt: typeof run?.started === 'string' ? run.started : '',
210
+ folderPath,
211
+ hasPlan: fs.existsSync(path.join(folderPath, 'plan.md')),
212
+ hasWalkthrough: fs.existsSync(path.join(folderPath, 'walkthrough.md')),
213
+ hasTestReport: fs.existsSync(path.join(folderPath, 'test-report.md'))
214
+ };
215
+ }).filter(Boolean)
216
+ };
217
+ }
218
+
219
+ function probeAidlcWorktreeActivity(worktreePath) {
220
+ const rootPath = path.join(worktreePath, 'memory-bank');
221
+ const boltsPath = path.join(rootPath, 'bolts');
222
+ const boltIds = listSubdirectories(boltsPath);
223
+ if (boltIds.length === 0) {
224
+ return { activeBolts: [] };
225
+ }
226
+
227
+ const activeBolts = boltIds.map((boltId) => {
228
+ const boltPath = path.join(boltsPath, boltId);
229
+ const boltFilePath = path.join(boltPath, 'bolt.md');
230
+ const content = readFileSafe(boltFilePath);
231
+ if (content == null) {
232
+ return null;
233
+ }
234
+
235
+ const frontmatter = parseFrontmatter(content);
236
+ const status = normalizeAidlcStatus(frontmatter.status);
237
+ if (status !== 'in_progress') {
238
+ return null;
239
+ }
240
+
241
+ const currentStage = typeof frontmatter.current_stage === 'string'
242
+ ? frontmatter.current_stage
243
+ : (typeof frontmatter.currentStage === 'string' ? frontmatter.currentStage : null);
244
+
245
+ return {
246
+ id: boltId,
247
+ intent: typeof frontmatter.intent === 'string' ? frontmatter.intent : '',
248
+ unit: typeof frontmatter.unit === 'string' ? frontmatter.unit : '',
249
+ type: typeof frontmatter.type === 'string' ? frontmatter.type : 'simple-construction-bolt',
250
+ status,
251
+ currentStage,
252
+ path: boltPath,
253
+ filePath: boltFilePath,
254
+ files: listMarkdownFiles(boltPath),
255
+ startedAt: typeof frontmatter.started === 'string' ? frontmatter.started : undefined
256
+ };
257
+ }).filter(Boolean);
258
+
259
+ return { activeBolts };
260
+ }
261
+
262
+ function probeSimpleWorktreeActivity(worktreePath) {
263
+ const rootPath = path.join(worktreePath, 'specs');
264
+ if (!pathExistsAsDirectory(rootPath)) {
265
+ return { activeSpecs: [] };
266
+ }
267
+
268
+ const specFolders = listSubdirectories(rootPath);
269
+ const activeSpecs = specFolders.map((name) => {
270
+ const specPath = path.join(rootPath, name);
271
+ const tasksPath = path.join(specPath, 'tasks.md');
272
+ if (!fs.existsSync(tasksPath)) {
273
+ return null;
274
+ }
275
+ return {
276
+ name,
277
+ path: specPath
278
+ };
279
+ }).filter(Boolean);
280
+
281
+ return { activeSpecs };
282
+ }
283
+
284
+ function probeWorktreeActivity(flow, worktreePath) {
285
+ const normalizedFlow = normalizeFlowId(flow);
286
+ if (normalizedFlow === 'aidlc') {
287
+ return probeAidlcWorktreeActivity(worktreePath);
288
+ }
289
+ if (normalizedFlow === 'simple') {
290
+ return probeSimpleWorktreeActivity(worktreePath);
291
+ }
292
+ return probeFireWorktreeActivity(worktreePath);
293
+ }
294
+
295
+ function extractActivityFromSnapshot(flow, snapshot) {
296
+ const normalizedFlow = normalizeFlowId(flow);
297
+ if (normalizedFlow === 'aidlc') {
298
+ return {
299
+ activeBolts: Array.isArray(snapshot?.activeBolts) ? snapshot.activeBolts : []
300
+ };
301
+ }
302
+ if (normalizedFlow === 'simple') {
303
+ return {
304
+ activeSpecs: Array.isArray(snapshot?.activeSpecs) ? snapshot.activeSpecs : []
305
+ };
306
+ }
307
+ return {
308
+ activeRuns: Array.isArray(snapshot?.activeRuns) ? snapshot.activeRuns : []
309
+ };
310
+ }
311
+
312
+ function getActivityCount(flow, activity) {
313
+ const normalizedFlow = normalizeFlowId(flow);
314
+ if (normalizedFlow === 'aidlc') {
315
+ return Array.isArray(activity?.activeBolts) ? activity.activeBolts.length : 0;
316
+ }
317
+ if (normalizedFlow === 'simple') {
318
+ return Array.isArray(activity?.activeSpecs) ? activity.activeSpecs.length : 0;
319
+ }
320
+ return Array.isArray(activity?.activeRuns) ? activity.activeRuns.length : 0;
321
+ }
322
+
323
+ function buildWorktreeEnvelope(flow, worktrees, selectedWorktreeId, cache, discovery) {
324
+ const normalizedFlow = normalizeFlowId(flow);
325
+ const items = (Array.isArray(worktrees) ? worktrees : []).map((worktree) => {
326
+ const availableFlows = detectAvailableFlows(worktree.path);
327
+ const flowAvailable = availableFlows.includes(normalizedFlow);
328
+ const cacheKey = `${normalizedFlow}:${worktree.id}`;
329
+ const cached = cache.get(cacheKey);
330
+ const status = flowAvailable
331
+ ? (cached?.status || 'loading')
332
+ : 'unavailable';
333
+ const activity = cached?.activity || null;
334
+ const activeCount = getActivityCount(normalizedFlow, activity);
335
+
336
+ return {
337
+ ...worktree,
338
+ isSelected: worktree.id === selectedWorktreeId,
339
+ flowAvailable,
340
+ status,
341
+ activeCount,
342
+ activity,
343
+ error: cached?.error || null
344
+ };
345
+ });
346
+
347
+ return {
348
+ flow: normalizedFlow,
349
+ source: discovery?.source || 'fallback',
350
+ isGitRepo: Boolean(discovery?.isGitRepo),
351
+ selectedWorktreeId,
352
+ hasPendingScans: items.some((item) => item.status === 'loading'),
353
+ error: discovery?.error,
354
+ items
355
+ };
356
+ }
357
+
358
+ function getWatchRootsForEnvelope(flow, envelope) {
359
+ const normalizedFlow = normalizeFlowId(flow);
360
+ const config = FLOW_CONFIG[normalizedFlow];
361
+ if (!config) {
362
+ return [];
363
+ }
364
+
365
+ const roots = [];
366
+ const items = Array.isArray(envelope?.items) ? envelope.items : [];
367
+ const selectedId = envelope?.selectedWorktreeId;
368
+ const selectedItem = items.find((item) => item.id === selectedId);
369
+
370
+ if (selectedItem) {
371
+ roots.push(resolveRootPathForFlow(selectedItem.path, normalizedFlow));
372
+ }
373
+
374
+ for (const item of items) {
375
+ if (item.id === selectedId) {
376
+ continue;
377
+ }
378
+ if (!item.flowAvailable) {
379
+ continue;
380
+ }
381
+ if (item.activeCount <= 0 && item.status !== 'loading') {
382
+ continue;
383
+ }
384
+ roots.push(resolveRootPathForFlow(item.path, normalizedFlow));
385
+ }
386
+
387
+ return Array.from(new Set(roots))
388
+ .filter((rootPath) => pathExistsAsDirectory(rootPath))
389
+ .slice(0, MAX_WORKTREE_WATCH_ROOTS);
390
+ }
391
+
77
392
  function formatStaticFlowText(flow, snapshot, error) {
78
393
  if (flow === 'fire') {
79
394
  return formatDashboardText({
@@ -141,21 +456,160 @@ async function runFlowDashboard(options, flow, availableFlows = []) {
141
456
 
142
457
  const watchEnabled = options.watch !== false;
143
458
  const refreshMs = parseRefreshMs(options.refreshMs);
144
- const parseSnapshotForFlow = async (flowId) => {
145
- const flowConfig = FLOW_CONFIG[flowId];
459
+ const explicitWorktreeSelection = typeof options.worktree === 'string' ? options.worktree.trim() : '';
460
+ let selectedWorktreeId = explicitWorktreeSelection || null;
461
+ let lastWorktreeEnvelope = null;
462
+ const activityCache = new Map();
463
+ const pendingProbes = new Map();
464
+
465
+ const parseSnapshotForFlow = async (flowId, context = {}) => {
466
+ const normalizedFlow = normalizeFlowId(flowId);
467
+ const flowConfig = FLOW_CONFIG[normalizedFlow];
146
468
  if (!flowConfig) {
147
469
  return {
148
470
  ok: false,
149
471
  error: {
150
472
  code: 'UNSUPPORTED_FLOW',
151
- message: `Flow \"${flowId}\" is not supported.`
473
+ message: `Flow \"${normalizedFlow}\" is not supported.`
474
+ }
475
+ };
476
+ }
477
+
478
+ const discovery = discoverGitWorktrees(workspacePath);
479
+ const worktrees = Array.isArray(discovery?.worktrees) ? discovery.worktrees : [];
480
+ const requestedWorktreeSelector = String(context.selectedWorktreeId || selectedWorktreeId || explicitWorktreeSelection || '').trim();
481
+ const selectedWorktree = pickWorktree(worktrees, requestedWorktreeSelector, workspacePath)
482
+ || pickWorktree(worktrees, workspacePath, workspacePath)
483
+ || worktrees[0];
484
+
485
+ selectedWorktreeId = selectedWorktree?.id || selectedWorktreeId;
486
+
487
+ const selectedResult = selectedWorktree
488
+ ? await flowConfig.parse(selectedWorktree.path)
489
+ : {
490
+ ok: false,
491
+ error: {
492
+ code: 'WORKTREE_NOT_FOUND',
493
+ message: 'No selectable worktree was found for dashboard parsing.'
494
+ }
495
+ };
496
+
497
+ if (selectedWorktree) {
498
+ const cacheKey = `${normalizedFlow}:${selectedWorktree.id}`;
499
+ if (selectedResult?.ok) {
500
+ activityCache.set(cacheKey, {
501
+ status: 'ready',
502
+ activity: extractActivityFromSnapshot(normalizedFlow, selectedResult.snapshot),
503
+ error: null,
504
+ updatedAt: Date.now()
505
+ });
506
+ } else {
507
+ activityCache.set(cacheKey, {
508
+ status: 'error',
509
+ activity: null,
510
+ error: selectedResult?.error || null,
511
+ updatedAt: Date.now()
512
+ });
513
+ }
514
+ }
515
+
516
+ for (const worktree of worktrees) {
517
+ if (!worktree || worktree.id === selectedWorktreeId) {
518
+ continue;
519
+ }
520
+
521
+ const availableFlowsForWorktree = detectAvailableFlows(worktree.path);
522
+ const flowAvailable = availableFlowsForWorktree.includes(normalizedFlow);
523
+ const cacheKey = `${normalizedFlow}:${worktree.id}`;
524
+
525
+ if (!flowAvailable) {
526
+ activityCache.set(cacheKey, {
527
+ status: 'unavailable',
528
+ activity: null,
529
+ error: null,
530
+ updatedAt: Date.now()
531
+ });
532
+ continue;
533
+ }
534
+
535
+ const cached = activityCache.get(cacheKey);
536
+ const shouldRefreshProbe = !cached || (Date.now() - (cached.updatedAt || 0)) > 2000;
537
+ if (!shouldRefreshProbe || pendingProbes.has(cacheKey)) {
538
+ continue;
539
+ }
540
+
541
+ activityCache.set(cacheKey, {
542
+ status: 'loading',
543
+ activity: cached?.activity || null,
544
+ error: null,
545
+ updatedAt: Date.now()
546
+ });
547
+
548
+ const probePromise = Promise.resolve()
549
+ .then(() => probeWorktreeActivity(normalizedFlow, worktree.path))
550
+ .then((activity) => {
551
+ activityCache.set(cacheKey, {
552
+ status: 'ready',
553
+ activity,
554
+ error: null,
555
+ updatedAt: Date.now()
556
+ });
557
+ })
558
+ .catch((probeError) => {
559
+ activityCache.set(cacheKey, {
560
+ status: 'error',
561
+ activity: null,
562
+ error: {
563
+ code: 'WORKTREE_PROBE_ERROR',
564
+ message: probeError?.message || String(probeError)
565
+ },
566
+ updatedAt: Date.now()
567
+ });
568
+ })
569
+ .finally(() => {
570
+ pendingProbes.delete(cacheKey);
571
+ });
572
+
573
+ pendingProbes.set(cacheKey, probePromise);
574
+ }
575
+
576
+ const envelope = buildWorktreeEnvelope(
577
+ normalizedFlow,
578
+ worktrees,
579
+ selectedWorktreeId,
580
+ activityCache,
581
+ discovery
582
+ );
583
+ lastWorktreeEnvelope = envelope;
584
+
585
+ if (!selectedResult?.ok) {
586
+ return {
587
+ ok: false,
588
+ error: {
589
+ ...(selectedResult?.error || {
590
+ code: 'PARSE_ERROR',
591
+ message: 'Unable to parse selected worktree snapshot.'
592
+ }),
593
+ details: selectedWorktree
594
+ ? `worktree: ${selectedWorktree.displayBranch} (${selectedWorktree.path})`
595
+ : undefined
152
596
  }
153
597
  };
154
598
  }
155
- return flowConfig.parse(workspacePath);
599
+
600
+ return {
601
+ ok: true,
602
+ snapshot: {
603
+ ...selectedResult.snapshot,
604
+ workspacePath: selectedWorktree?.path || selectedResult.snapshot?.workspacePath || workspacePath,
605
+ dashboardWorktrees: envelope
606
+ }
607
+ };
156
608
  };
157
609
 
158
- const initialResult = await parseSnapshotForFlow(flow);
610
+ const initialResult = await parseSnapshotForFlow(flow, {
611
+ selectedWorktreeId: selectedWorktreeId || explicitWorktreeSelection || workspacePath
612
+ });
159
613
  clearTerminalOutput();
160
614
 
161
615
  if (!watchEnabled) {
@@ -190,6 +644,16 @@ async function runFlowDashboard(options, flow, availableFlows = []) {
190
644
  flow,
191
645
  availableFlows: flowIds,
192
646
  resolveRootPathForFlow: (flowId) => resolveRootPathForFlow(workspacePath, flowId),
647
+ resolveRootPathsForFlow: (flowId) => {
648
+ if (!lastWorktreeEnvelope) {
649
+ return [resolveRootPathForFlow(workspacePath, flowId)];
650
+ }
651
+ const roots = getWatchRootsForEnvelope(flowId, lastWorktreeEnvelope);
652
+ if (roots.length > 0) {
653
+ return roots;
654
+ }
655
+ return [resolveRootPathForFlow(workspacePath, flowId)];
656
+ },
193
657
  refreshMs,
194
658
  watchEnabled,
195
659
  initialSnapshot: initialResult.ok ? initialResult.snapshot : null,
@@ -236,5 +700,7 @@ module.exports = {
236
700
  parseRefreshMs,
237
701
  formatStaticFlowText,
238
702
  clearTerminalOutput,
239
- createInkStdout
703
+ createInkStdout,
704
+ probeWorktreeActivity,
705
+ getWatchRootsForEnvelope
240
706
  };
@@ -32,13 +32,19 @@ function createDebouncedTrigger(callback, delayMs) {
32
32
  function createWatchRuntime(options) {
33
33
  const {
34
34
  rootPath,
35
+ rootPaths,
35
36
  onRefresh,
36
37
  onError,
37
38
  debounceMs = 250
38
39
  } = options;
39
40
 
40
- if (!rootPath || typeof rootPath !== 'string') {
41
- throw new Error('rootPath is required for watch runtime');
41
+ const roots = Array.from(new Set([
42
+ ...(Array.isArray(rootPaths) ? rootPaths : []),
43
+ ...(typeof rootPath === 'string' ? [rootPath] : [])
44
+ ].filter((value) => typeof value === 'string' && value.trim() !== '')));
45
+
46
+ if (roots.length === 0) {
47
+ throw new Error('rootPath or rootPaths is required for watch runtime');
42
48
  }
43
49
 
44
50
  if (typeof onRefresh !== 'function') {
@@ -51,12 +57,14 @@ function createWatchRuntime(options) {
51
57
  let started = false;
52
58
  const debounced = createDebouncedTrigger(onRefresh, debounceMs);
53
59
 
54
- const watchTargets = [
55
- path.join(rootPath, 'state.yaml'),
56
- path.join(rootPath, 'intents'),
57
- path.join(rootPath, 'runs'),
58
- path.join(rootPath, 'standards')
59
- ];
60
+ const watchTargets = roots.flatMap((baseRoot) => ([
61
+ path.join(baseRoot, 'state.yaml'),
62
+ path.join(baseRoot, 'intents'),
63
+ path.join(baseRoot, 'runs'),
64
+ path.join(baseRoot, 'standards'),
65
+ path.join(baseRoot, 'bolts'),
66
+ path.join(baseRoot, 'specs')
67
+ ]));
60
68
 
61
69
  function start() {
62
70
  if (started) {
@@ -103,7 +111,8 @@ function createWatchRuntime(options) {
103
111
  start,
104
112
  close,
105
113
  isActive: () => started,
106
- hasPendingRefresh: () => debounced.isPending()
114
+ hasPendingRefresh: () => debounced.isPending(),
115
+ getRoots: () => [...roots]
107
116
  };
108
117
  }
109
118