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,797 @@
1
+ const path = require('path');
2
+ const { spawnSync } = require('child_process');
3
+ const { truncate, normalizePanelLine, clampIndex, fileExists } = require('./helpers');
4
+ const { sanitizeRenderLine } = require('./overlays');
5
+ const {
6
+ getEffectiveFlow,
7
+ getCurrentRun,
8
+ getCurrentFireWorkItem,
9
+ getCurrentBolt,
10
+ getCurrentSpec,
11
+ getCurrentPhaseLabel,
12
+ buildPhaseTrack,
13
+ isFireRunAwaitingApproval,
14
+ isAidlcBoltAwaitingApproval
15
+ } = require('./flow-builders');
16
+ const {
17
+ collectFireRunFiles,
18
+ collectAidlcBoltFiles,
19
+ collectSimpleSpecFiles,
20
+ collectAidlcIntentContextFiles,
21
+ filterExistingFiles,
22
+ getRunFileEntries,
23
+ buildIntentScopedLabel,
24
+ findIntentIdForWorkItem,
25
+ resolveFireWorkItemPath,
26
+ formatScope,
27
+ getNoFileMessage,
28
+ getNoPendingMessage,
29
+ getNoCompletedMessage,
30
+ getNoCurrentMessage
31
+ } = require('./file-entries');
32
+
33
+ function buildFireCurrentRunGroups(snapshot) {
34
+ const run = getCurrentRun(snapshot);
35
+ if (!run) {
36
+ return [];
37
+ }
38
+
39
+ const workItems = Array.isArray(run.workItems) ? run.workItems : [];
40
+ const completed = workItems.filter((item) => item.status === 'completed').length;
41
+ const currentWorkItem = getCurrentFireWorkItem(run);
42
+ const awaitingApproval = isFireRunAwaitingApproval(run, currentWorkItem);
43
+
44
+ const currentPhase = getCurrentPhaseLabel(run, currentWorkItem);
45
+ const phaseTrack = buildPhaseTrack(currentPhase);
46
+ const mode = String(currentWorkItem?.mode || 'confirm').toUpperCase();
47
+ const status = currentWorkItem?.status || 'pending';
48
+ const statusTag = status === 'in_progress' ? 'current' : status;
49
+
50
+ const runIntentId = typeof run?.intent === 'string' ? run.intent : '';
51
+ const currentWorkItemFiles = workItems.map((item, index) => {
52
+ const itemId = typeof item?.id === 'string' && item.id !== '' ? item.id : `work-item-${index + 1}`;
53
+ const intentId = typeof item?.intent === 'string' && item.intent !== ''
54
+ ? item.intent
55
+ : (runIntentId || findIntentIdForWorkItem(snapshot, itemId));
56
+ const filePath = resolveFireWorkItemPath(snapshot, intentId, itemId, item?.filePath);
57
+ if (!filePath) {
58
+ return null;
59
+ }
60
+
61
+ const itemMode = String(item?.mode || 'confirm').toUpperCase();
62
+ const itemStatus = item?.status || 'pending';
63
+ const isCurrent = Boolean(currentWorkItem?.id) && itemId === currentWorkItem.id;
64
+ const itemScope = isCurrent
65
+ ? 'active'
66
+ : (itemStatus === 'completed' ? 'completed' : 'upcoming');
67
+ const itemStatusTag = isCurrent ? 'current' : itemStatus;
68
+ const labelPath = buildIntentScopedLabel(snapshot, intentId, filePath, `${itemId}.md`);
69
+
70
+ return {
71
+ label: `${labelPath} [${itemMode}] [${itemStatusTag}]`,
72
+ path: filePath,
73
+ scope: itemScope
74
+ };
75
+ }).filter(Boolean);
76
+
77
+ const currentRunFiles = collectFireRunFiles(run).map((fileEntry) => ({
78
+ ...fileEntry,
79
+ label: path.basename(fileEntry.path || fileEntry.label || ''),
80
+ scope: 'active'
81
+ }));
82
+
83
+ return [
84
+ {
85
+ key: `current:run:${run.id}:summary`,
86
+ label: `${run.id} [${run.scope}] ${completed}/${workItems.length} items${awaitingApproval ? ' [APPROVAL]' : ''}`,
87
+ files: []
88
+ },
89
+ {
90
+ key: `current:run:${run.id}:work-items`,
91
+ label: `WORK ITEMS (${currentWorkItemFiles.length})`,
92
+ files: filterExistingFiles(currentWorkItemFiles)
93
+ },
94
+ {
95
+ key: `current:run:${run.id}:run-files`,
96
+ label: 'RUN FILES',
97
+ files: filterExistingFiles(currentRunFiles)
98
+ }
99
+ ];
100
+ }
101
+
102
+ function buildCurrentGroups(snapshot, flow) {
103
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
104
+
105
+ if (effectiveFlow === 'aidlc') {
106
+ const bolt = getCurrentBolt(snapshot);
107
+ if (!bolt) {
108
+ return [];
109
+ }
110
+ const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
111
+ const completedStages = stages.filter((stage) => stage.status === 'completed').length;
112
+ const awaitingApproval = isAidlcBoltAwaitingApproval(bolt);
113
+ return [{
114
+ key: `current:bolt:${bolt.id}`,
115
+ label: `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages${awaitingApproval ? ' [APPROVAL]' : ''}`,
116
+ files: filterExistingFiles([
117
+ ...collectAidlcBoltFiles(bolt),
118
+ ...collectAidlcIntentContextFiles(snapshot, bolt.intent)
119
+ ])
120
+ }];
121
+ }
122
+
123
+ if (effectiveFlow === 'simple') {
124
+ const spec = getCurrentSpec(snapshot);
125
+ if (!spec) {
126
+ return [];
127
+ }
128
+ return [{
129
+ key: `current:spec:${spec.name}`,
130
+ label: `${spec.name} [${spec.state}] ${spec.tasksCompleted}/${spec.tasksTotal} tasks`,
131
+ files: filterExistingFiles(collectSimpleSpecFiles(spec))
132
+ }];
133
+ }
134
+
135
+ return buildFireCurrentRunGroups(snapshot);
136
+ }
137
+
138
+ function buildRunFileGroups(fileEntries) {
139
+ const order = ['active', 'upcoming', 'completed', 'intent', 'other'];
140
+ const buckets = new Map(order.map((scope) => [scope, []]));
141
+
142
+ for (const fileEntry of Array.isArray(fileEntries) ? fileEntries : []) {
143
+ const scope = order.includes(fileEntry?.scope) ? fileEntry.scope : 'other';
144
+ buckets.get(scope).push(fileEntry);
145
+ }
146
+
147
+ const groups = [];
148
+ for (const scope of order) {
149
+ const files = buckets.get(scope) || [];
150
+ if (files.length === 0) {
151
+ continue;
152
+ }
153
+ groups.push({
154
+ key: `run-files:scope:${scope}`,
155
+ label: `${formatScope(scope)} files (${files.length})`,
156
+ files: filterExistingFiles(files)
157
+ });
158
+ }
159
+ return groups;
160
+ }
161
+
162
+ function getFileEntityLabel(fileEntry, fallbackIndex = 0) {
163
+ const rawLabel = typeof fileEntry?.label === 'string' ? fileEntry.label : '';
164
+ if (rawLabel.includes('/')) {
165
+ return rawLabel.split('/')[0] || `item-${fallbackIndex + 1}`;
166
+ }
167
+
168
+ const filePath = typeof fileEntry?.path === 'string' ? fileEntry.path : '';
169
+ if (filePath !== '') {
170
+ const parentDir = path.basename(path.dirname(filePath));
171
+ if (parentDir && parentDir !== '.' && parentDir !== path.sep) {
172
+ return parentDir;
173
+ }
174
+
175
+ const baseName = path.basename(filePath);
176
+ if (baseName) {
177
+ return baseName;
178
+ }
179
+ }
180
+
181
+ return `item-${fallbackIndex + 1}`;
182
+ }
183
+
184
+ function buildRunFileEntityGroups(snapshot, flow, options = {}) {
185
+ const order = ['active', 'upcoming', 'completed', 'intent', 'other'];
186
+ const rankByScope = new Map(order.map((scope, index) => [scope, index]));
187
+ const entries = filterExistingFiles(getRunFileEntries(snapshot, flow, options));
188
+ const groupsByEntity = new Map();
189
+
190
+ for (let index = 0; index < entries.length; index += 1) {
191
+ const fileEntry = entries[index];
192
+ const entity = getFileEntityLabel(fileEntry, index);
193
+ const scope = order.includes(fileEntry?.scope) ? fileEntry.scope : 'other';
194
+ const scopeRank = rankByScope.get(scope) ?? rankByScope.get('other');
195
+
196
+ if (!groupsByEntity.has(entity)) {
197
+ groupsByEntity.set(entity, {
198
+ entity,
199
+ files: [],
200
+ scope,
201
+ scopeRank
202
+ });
203
+ }
204
+
205
+ const group = groupsByEntity.get(entity);
206
+ group.files.push(fileEntry);
207
+
208
+ if (scopeRank < group.scopeRank) {
209
+ group.scopeRank = scopeRank;
210
+ group.scope = scope;
211
+ }
212
+ }
213
+
214
+ return Array.from(groupsByEntity.values())
215
+ .sort((a, b) => {
216
+ if (a.scopeRank !== b.scopeRank) {
217
+ return a.scopeRank - b.scopeRank;
218
+ }
219
+ return String(a.entity).localeCompare(String(b.entity));
220
+ })
221
+ .map((group) => ({
222
+ key: `run-files:entity:${group.entity}`,
223
+ label: `${group.entity} [${formatScope(group.scope)}] (${group.files.length})`,
224
+ files: filterExistingFiles(group.files)
225
+ }))
226
+ .filter((group) => group.files.length > 0);
227
+ }
228
+
229
+ function normalizeInfoLine(line) {
230
+ const normalized = normalizePanelLine(line);
231
+ return {
232
+ label: normalized.text,
233
+ color: normalized.color,
234
+ bold: normalized.bold
235
+ };
236
+ }
237
+
238
+ function toInfoRows(lines, keyPrefix, emptyLabel = 'No data') {
239
+ const safe = Array.isArray(lines) ? lines : [];
240
+ if (safe.length === 0) {
241
+ return [{
242
+ kind: 'info',
243
+ key: `${keyPrefix}:empty`,
244
+ label: emptyLabel,
245
+ selectable: false
246
+ }];
247
+ }
248
+
249
+ return safe.map((line, index) => {
250
+ const normalized = normalizeInfoLine(line);
251
+ return {
252
+ kind: 'info',
253
+ key: `${keyPrefix}:${index}`,
254
+ label: normalized.label,
255
+ color: normalized.color,
256
+ bold: normalized.bold,
257
+ selectable: true
258
+ };
259
+ });
260
+ }
261
+
262
+ function toLoadingRows(label, keyPrefix = 'loading') {
263
+ return [{
264
+ kind: 'loading',
265
+ key: `${keyPrefix}:row`,
266
+ label: typeof label === 'string' && label !== '' ? label : 'Loading...',
267
+ selectable: false
268
+ }];
269
+ }
270
+
271
+ function buildOverviewIntentGroups(snapshot, flow, filter = 'next') {
272
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
273
+ const normalizedFilter = filter === 'completed' ? 'completed' : 'next';
274
+ const isIncluded = (status) => {
275
+ if (normalizedFilter === 'completed') {
276
+ return status === 'completed';
277
+ }
278
+ return status !== 'completed';
279
+ };
280
+
281
+ if (effectiveFlow === 'aidlc') {
282
+ const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
283
+ return intents
284
+ .filter((intent) => isIncluded(intent?.status || 'pending'))
285
+ .map((intent, index) => ({
286
+ key: `overview:intent:${intent?.id || index}`,
287
+ label: `${intent?.id || 'unknown'}: ${intent?.status || 'pending'} (${intent?.completedStories || 0}/${intent?.storyCount || 0} stories, ${intent?.completedUnits || 0}/${intent?.unitCount || 0} units)`,
288
+ files: filterExistingFiles(collectAidlcIntentContextFiles(snapshot, intent?.id))
289
+ }));
290
+ }
291
+
292
+ if (effectiveFlow === 'simple') {
293
+ const specs = Array.isArray(snapshot?.specs) ? snapshot.specs : [];
294
+ return specs
295
+ .filter((spec) => isIncluded(spec?.state || 'pending'))
296
+ .map((spec, index) => ({
297
+ key: `overview:spec:${spec?.name || index}`,
298
+ label: `${spec?.name || 'unknown'}: ${spec?.state || 'pending'} (${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0} tasks)`,
299
+ files: filterExistingFiles(collectSimpleSpecFiles(spec))
300
+ }));
301
+ }
302
+
303
+ const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
304
+ return intents
305
+ .filter((intent) => isIncluded(intent?.status || 'pending'))
306
+ .map((intent, index) => {
307
+ const workItems = Array.isArray(intent?.workItems) ? intent.workItems : [];
308
+ const done = workItems.filter((item) => item.status === 'completed').length;
309
+ const files = [{
310
+ label: buildIntentScopedLabel(snapshot, intent?.id, intent?.filePath, 'brief.md'),
311
+ path: intent?.filePath,
312
+ scope: 'intent'
313
+ }, ...workItems.map((item) => ({
314
+ label: buildIntentScopedLabel(
315
+ snapshot,
316
+ intent?.id,
317
+ item?.filePath,
318
+ `${item?.id || 'work-item'}.md`
319
+ ),
320
+ path: item?.filePath,
321
+ scope: item?.status === 'completed' ? 'completed' : 'upcoming'
322
+ }))];
323
+ return {
324
+ key: `overview:intent:${intent?.id || index}`,
325
+ label: `${intent?.id || 'unknown'}: ${intent?.status || 'pending'} (${done}/${workItems.length} work items)`,
326
+ files: filterExistingFiles(files)
327
+ };
328
+ });
329
+ }
330
+
331
+ function buildStandardsRows(snapshot, flow) {
332
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
333
+ if (effectiveFlow === 'simple') {
334
+ return [{
335
+ kind: 'info',
336
+ key: 'standards:empty:simple',
337
+ label: 'No standards for SIMPLE flow',
338
+ selectable: false
339
+ }];
340
+ }
341
+
342
+ const standards = Array.isArray(snapshot?.standards) ? snapshot.standards : [];
343
+ const files = filterExistingFiles(standards.map((standard, index) => ({
344
+ label: `${standard?.name || standard?.type || `standard-${index}`}.md`,
345
+ path: standard?.filePath,
346
+ scope: 'file'
347
+ })));
348
+
349
+ if (files.length === 0) {
350
+ return [{
351
+ kind: 'info',
352
+ key: 'standards:empty',
353
+ label: 'No standards found',
354
+ selectable: false
355
+ }];
356
+ }
357
+
358
+ return files.map((file, index) => ({
359
+ kind: 'file',
360
+ key: `standards:file:${file.path}:${index}`,
361
+ label: file.label,
362
+ path: file.path,
363
+ scope: 'file',
364
+ selectable: true
365
+ }));
366
+ }
367
+
368
+ function buildProjectGroups(snapshot, flow) {
369
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
370
+ const files = [];
371
+
372
+ if (effectiveFlow === 'aidlc') {
373
+ files.push({
374
+ label: 'memory-bank/project.yaml',
375
+ path: path.join(snapshot?.rootPath || '', 'project.yaml'),
376
+ scope: 'file'
377
+ });
378
+ } else if (effectiveFlow === 'simple') {
379
+ files.push({
380
+ label: 'package.json',
381
+ path: path.join(snapshot?.workspacePath || '', 'package.json'),
382
+ scope: 'file'
383
+ });
384
+ } else {
385
+ files.push({
386
+ label: '.specs-fire/state.yaml',
387
+ path: path.join(snapshot?.rootPath || '', 'state.yaml'),
388
+ scope: 'file'
389
+ });
390
+ }
391
+
392
+ const projectName = snapshot?.project?.name || 'unknown-project';
393
+ return [{
394
+ key: `project:${projectName}`,
395
+ label: `project ${projectName}`,
396
+ files: filterExistingFiles(files)
397
+ }];
398
+ }
399
+
400
+ function buildPendingGroups(snapshot, flow) {
401
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
402
+
403
+ if (effectiveFlow === 'aidlc') {
404
+ const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
405
+ return pendingBolts.map((bolt, index) => {
406
+ const deps = Array.isArray(bolt?.blockedBy) && bolt.blockedBy.length > 0
407
+ ? ` blocked_by:${bolt.blockedBy.join(',')}`
408
+ : '';
409
+ const location = `${bolt?.intent || 'unknown'}/${bolt?.unit || 'unknown'}`;
410
+ const boltFiles = collectAidlcBoltFiles(bolt);
411
+ const intentFiles = collectAidlcIntentContextFiles(snapshot, bolt?.intent);
412
+ return {
413
+ key: `pending:bolt:${bolt?.id || index}`,
414
+ label: `${bolt?.id || 'unknown'} (${bolt?.status || 'pending'}) in ${location}${deps}`,
415
+ files: filterExistingFiles([...boltFiles, ...intentFiles])
416
+ };
417
+ });
418
+ }
419
+
420
+ if (effectiveFlow === 'simple') {
421
+ const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
422
+ return pendingSpecs.map((spec, index) => ({
423
+ key: `pending:spec:${spec?.name || index}`,
424
+ label: `${spec?.name || 'unknown'} (${spec?.state || 'pending'}) ${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0} tasks`,
425
+ files: filterExistingFiles(collectSimpleSpecFiles(spec))
426
+ }));
427
+ }
428
+
429
+ const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
430
+ return pendingItems.map((item, index) => {
431
+ const deps = Array.isArray(item?.dependencies) && item.dependencies.length > 0
432
+ ? ` deps:${item.dependencies.join(',')}`
433
+ : '';
434
+ const intentTitle = item?.intentTitle || item?.intentId || 'unknown-intent';
435
+ const files = [];
436
+
437
+ if (item?.filePath) {
438
+ files.push({
439
+ label: buildIntentScopedLabel(
440
+ snapshot,
441
+ item?.intentId,
442
+ item?.filePath,
443
+ `${item?.id || 'work-item'}.md`
444
+ ),
445
+ path: item.filePath,
446
+ scope: 'upcoming'
447
+ });
448
+ }
449
+ if (item?.intentId) {
450
+ files.push({
451
+ label: buildIntentScopedLabel(
452
+ snapshot,
453
+ item.intentId,
454
+ path.join(snapshot?.rootPath || '', 'intents', item.intentId, 'brief.md'),
455
+ 'brief.md'
456
+ ),
457
+ path: path.join(snapshot?.rootPath || '', 'intents', item.intentId, 'brief.md'),
458
+ scope: 'intent'
459
+ });
460
+ }
461
+
462
+ return {
463
+ key: `pending:item:${item?.intentId || 'intent'}:${item?.id || index}`,
464
+ label: `${item?.id || 'work-item'} (${item?.mode || 'confirm'}/${item?.complexity || 'medium'}) in ${intentTitle}${deps}`,
465
+ files: filterExistingFiles(files)
466
+ };
467
+ });
468
+ }
469
+
470
+ function buildCompletedGroups(snapshot, flow) {
471
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
472
+
473
+ if (effectiveFlow === 'aidlc') {
474
+ const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
475
+ return completedBolts.map((bolt, index) => {
476
+ const boltFiles = collectAidlcBoltFiles(bolt);
477
+ const intentFiles = collectAidlcIntentContextFiles(snapshot, bolt?.intent);
478
+ return {
479
+ key: `completed:bolt:${bolt?.id || index}`,
480
+ label: `${bolt?.id || 'unknown'} [${bolt?.type || 'bolt'}] done at ${bolt?.completedAt || 'unknown'}`,
481
+ files: filterExistingFiles([...boltFiles, ...intentFiles])
482
+ };
483
+ });
484
+ }
485
+
486
+ if (effectiveFlow === 'simple') {
487
+ const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
488
+ return completedSpecs.map((spec, index) => ({
489
+ key: `completed:spec:${spec?.name || index}`,
490
+ label: `${spec?.name || 'unknown'} done at ${spec?.updatedAt || 'unknown'} (${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0})`,
491
+ files: filterExistingFiles(collectSimpleSpecFiles(spec))
492
+ }));
493
+ }
494
+
495
+ const groups = [];
496
+ const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
497
+ for (let index = 0; index < completedRuns.length; index += 1) {
498
+ const run = completedRuns[index];
499
+ const workItems = Array.isArray(run?.workItems) ? run.workItems : [];
500
+ const completed = workItems.filter((item) => item.status === 'completed').length;
501
+ groups.push({
502
+ key: `completed:run:${run?.id || index}`,
503
+ label: `${run?.id || 'run'} [${run?.scope || 'single'}] ${completed}/${workItems.length} done at ${run?.completedAt || 'unknown'}`,
504
+ files: filterExistingFiles(collectFireRunFiles(run).map((file) => ({ ...file, scope: 'completed' })))
505
+ });
506
+ }
507
+
508
+ const completedIntents = Array.isArray(snapshot?.intents)
509
+ ? snapshot.intents.filter((intent) => intent?.status === 'completed')
510
+ : [];
511
+ for (let index = 0; index < completedIntents.length; index += 1) {
512
+ const intent = completedIntents[index];
513
+ groups.push({
514
+ key: `completed:intent:${intent?.id || index}`,
515
+ label: `intent ${intent?.id || 'unknown'} [completed]`,
516
+ files: filterExistingFiles([{
517
+ label: buildIntentScopedLabel(
518
+ snapshot,
519
+ intent?.id,
520
+ path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
521
+ 'brief.md'
522
+ ),
523
+ path: path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
524
+ scope: 'intent'
525
+ }])
526
+ });
527
+ }
528
+
529
+ return groups;
530
+ }
531
+
532
+ function toExpandableRows(groups, emptyLabel, expandedGroups) {
533
+ if (!Array.isArray(groups) || groups.length === 0) {
534
+ return [{
535
+ kind: 'info',
536
+ key: 'section:empty',
537
+ label: emptyLabel,
538
+ selectable: false
539
+ }];
540
+ }
541
+
542
+ const rows = [];
543
+
544
+ for (const group of groups) {
545
+ const files = filterExistingFiles(group?.files);
546
+ const expandable = files.length > 0;
547
+ const expanded = expandable && Boolean(expandedGroups?.[group.key]);
548
+
549
+ rows.push({
550
+ kind: 'group',
551
+ key: group.key,
552
+ label: group.label,
553
+ expandable,
554
+ expanded,
555
+ selectable: true
556
+ });
557
+
558
+ if (expanded) {
559
+ for (let index = 0; index < files.length; index += 1) {
560
+ const file = files[index];
561
+ rows.push({
562
+ kind: file.previewType === 'git-diff' ? 'git-file' : 'file',
563
+ key: `${group.key}:file:${file.path}:${index}`,
564
+ label: file.label,
565
+ path: file.path,
566
+ scope: file.scope || 'file',
567
+ selectable: true,
568
+ previewType: file.previewType,
569
+ repoRoot: file.repoRoot,
570
+ relativePath: file.relativePath,
571
+ bucket: file.bucket
572
+ });
573
+ }
574
+ }
575
+ }
576
+
577
+ return rows;
578
+ }
579
+
580
+ function buildInteractiveRowsLines(rows, selectedIndex, icons, width, isFocusedSection) {
581
+ if (!Array.isArray(rows) || rows.length === 0) {
582
+ return [{ text: '', color: undefined, bold: false, selected: false }];
583
+ }
584
+
585
+ const clampedIndex = clampIndex(selectedIndex, rows.length);
586
+
587
+ return rows.map((row, index) => {
588
+ const selectable = row?.selectable !== false;
589
+ const isSelected = selectable && index === clampedIndex;
590
+ const cursor = isSelected
591
+ ? (isFocusedSection ? (icons.activeFile || '>') : '•')
592
+ : ' ';
593
+
594
+ if (row.kind === 'group') {
595
+ const marker = row.expandable
596
+ ? (row.expanded ? (icons.groupExpanded || 'v') : (icons.groupCollapsed || '>'))
597
+ : '-';
598
+ const safeLabel = sanitizeRenderLine(row.label || '');
599
+ return {
600
+ text: truncate(`${cursor} ${marker} ${safeLabel}`, width),
601
+ color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : undefined,
602
+ bold: isSelected,
603
+ selected: isSelected
604
+ };
605
+ }
606
+
607
+ if (row.kind === 'file' || row.kind === 'git-file' || row.kind === 'git-commit') {
608
+ const scope = row.scope ? `[${formatScope(row.scope)}] ` : '';
609
+ const safeLabel = sanitizeRenderLine(row.label || '');
610
+ return {
611
+ text: truncate(`${cursor} ${icons.runFile} ${scope}${safeLabel}`, width),
612
+ color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : 'gray',
613
+ bold: isSelected,
614
+ selected: isSelected
615
+ };
616
+ }
617
+
618
+ if (row.kind === 'loading') {
619
+ return {
620
+ text: truncate(row.label || 'Loading...', width),
621
+ color: 'cyan',
622
+ bold: false,
623
+ selected: false,
624
+ loading: true
625
+ };
626
+ }
627
+
628
+ return {
629
+ text: truncate(`${isSelected ? `${cursor} ` : ' '}${sanitizeRenderLine(row.label || '')}`, width),
630
+ color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : (row.color || 'gray'),
631
+ bold: isSelected || Boolean(row.bold),
632
+ selected: isSelected
633
+ };
634
+ });
635
+ }
636
+
637
+ function getSelectedRow(rows, selectedIndex) {
638
+ if (!Array.isArray(rows) || rows.length === 0) {
639
+ return null;
640
+ }
641
+ return rows[clampIndex(selectedIndex, rows.length)] || null;
642
+ }
643
+
644
+ function rowToFileEntry(row) {
645
+ if (!row) {
646
+ return null;
647
+ }
648
+
649
+ if (row.kind === 'git-commit') {
650
+ const commitHash = typeof row.commitHash === 'string' ? row.commitHash : '';
651
+ if (commitHash === '') {
652
+ return null;
653
+ }
654
+ return {
655
+ label: row.label || commitHash,
656
+ path: commitHash,
657
+ scope: 'commit',
658
+ previewType: row.previewType || 'git-commit-diff',
659
+ repoRoot: row.repoRoot,
660
+ commitHash
661
+ };
662
+ }
663
+
664
+ if ((row.kind !== 'file' && row.kind !== 'git-file') || typeof row.path !== 'string') {
665
+ return null;
666
+ }
667
+ return {
668
+ label: row.label || path.basename(row.path),
669
+ path: row.path,
670
+ scope: row.scope || 'file',
671
+ previewType: row.previewType,
672
+ repoRoot: row.repoRoot,
673
+ relativePath: row.relativePath,
674
+ bucket: row.bucket
675
+ };
676
+ }
677
+
678
+ function firstFileEntryFromRows(rows) {
679
+ if (!Array.isArray(rows) || rows.length === 0) {
680
+ return null;
681
+ }
682
+
683
+ for (const row of rows) {
684
+ const entry = rowToFileEntry(row);
685
+ if (entry) {
686
+ return entry;
687
+ }
688
+ }
689
+
690
+ return null;
691
+ }
692
+
693
+ function rowToWorktreeId(row) {
694
+ if (!row || typeof row.key !== 'string') {
695
+ return null;
696
+ }
697
+
698
+ const prefix = 'worktree:item:';
699
+ if (!row.key.startsWith(prefix)) {
700
+ return null;
701
+ }
702
+
703
+ const worktreeId = row.key.slice(prefix.length).trim();
704
+ return worktreeId === '' ? null : worktreeId;
705
+ }
706
+
707
+ function moveRowSelection(rows, currentIndex, direction) {
708
+ if (!Array.isArray(rows) || rows.length === 0) {
709
+ return 0;
710
+ }
711
+
712
+ const clamped = clampIndex(currentIndex, rows.length);
713
+ const step = direction >= 0 ? 1 : -1;
714
+ let next = clamped + step;
715
+
716
+ while (next >= 0 && next < rows.length) {
717
+ if (rows[next]?.selectable !== false) {
718
+ return next;
719
+ }
720
+ next += step;
721
+ }
722
+
723
+ return clamped;
724
+ }
725
+
726
+ function openFileWithDefaultApp(filePath) {
727
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
728
+ return {
729
+ ok: false,
730
+ message: 'No file selected to open.'
731
+ };
732
+ }
733
+
734
+ if (!fileExists(filePath)) {
735
+ return {
736
+ ok: false,
737
+ message: `File not found: ${filePath}`
738
+ };
739
+ }
740
+
741
+ let command = null;
742
+ let args = [];
743
+
744
+ if (process.platform === 'darwin') {
745
+ command = 'open';
746
+ args = [filePath];
747
+ } else if (process.platform === 'win32') {
748
+ command = 'cmd';
749
+ args = ['/c', 'start', '', filePath];
750
+ } else {
751
+ command = 'xdg-open';
752
+ args = [filePath];
753
+ }
754
+
755
+ const result = spawnSync(command, args, { stdio: 'ignore' });
756
+ if (result.error) {
757
+ return {
758
+ ok: false,
759
+ message: `Unable to open file: ${result.error.message}`
760
+ };
761
+ }
762
+ if (typeof result.status === 'number' && result.status !== 0) {
763
+ return {
764
+ ok: false,
765
+ message: `Open command failed with exit code ${result.status}.`
766
+ };
767
+ }
768
+
769
+ return {
770
+ ok: true,
771
+ message: `Opened ${filePath}`
772
+ };
773
+ }
774
+
775
+ module.exports = {
776
+ buildFireCurrentRunGroups,
777
+ buildCurrentGroups,
778
+ buildRunFileGroups,
779
+ getFileEntityLabel,
780
+ buildRunFileEntityGroups,
781
+ normalizeInfoLine,
782
+ toInfoRows,
783
+ toLoadingRows,
784
+ buildOverviewIntentGroups,
785
+ buildStandardsRows,
786
+ buildProjectGroups,
787
+ buildPendingGroups,
788
+ buildCompletedGroups,
789
+ toExpandableRows,
790
+ buildInteractiveRowsLines,
791
+ getSelectedRow,
792
+ rowToFileEntry,
793
+ firstFileEntryFromRows,
794
+ rowToWorktreeId,
795
+ moveRowSelection,
796
+ openFileWithDefaultApp
797
+ };