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,35 @@
1
+ const { truncate } = require('./header');
2
+
3
+ function renderErrorLines(error, width, watchEnabled = true) {
4
+ if (!error) {
5
+ return [];
6
+ }
7
+
8
+ const lines = [
9
+ `[error:${error.code || 'UNKNOWN'}] ${error.message || 'Unknown error'}`
10
+ ];
11
+
12
+ if (error.details) {
13
+ lines.push(`details: ${error.details}`);
14
+ }
15
+
16
+ if (error.path) {
17
+ lines.push(`path: ${error.path}`);
18
+ }
19
+
20
+ if (error.hint) {
21
+ lines.push(`hint: ${error.hint}`);
22
+ }
23
+
24
+ if (watchEnabled) {
25
+ lines.push('Dashboard keeps running and will recover after the next valid update.');
26
+ } else {
27
+ lines.push('Fix the error and rerun dashboard.');
28
+ }
29
+
30
+ return lines.map((line) => truncate(line, width));
31
+ }
32
+
33
+ module.exports = {
34
+ renderErrorLines
35
+ };
@@ -0,0 +1,60 @@
1
+ function truncate(value, width) {
2
+ const text = String(value);
3
+ if (!Number.isFinite(width) || width <= 0 || text.length <= width) {
4
+ return text;
5
+ }
6
+
7
+ if (width <= 3) {
8
+ return text.slice(0, width);
9
+ }
10
+
11
+ return `${text.slice(0, width - 3)}...`;
12
+ }
13
+
14
+ function formatTime(value) {
15
+ if (!value) {
16
+ return 'n/a';
17
+ }
18
+
19
+ const date = new Date(value);
20
+ if (Number.isNaN(date.getTime())) {
21
+ return value;
22
+ }
23
+
24
+ return date.toLocaleTimeString();
25
+ }
26
+
27
+ function renderHeaderLines(params) {
28
+ const {
29
+ snapshot,
30
+ flow,
31
+ workspacePath,
32
+ view,
33
+ watchEnabled,
34
+ watchStatus,
35
+ lastRefreshAt,
36
+ width
37
+ } = params;
38
+
39
+ const projectName = snapshot?.project?.name || 'Unnamed FIRE project';
40
+ const topLine = `specsmd dashboard | ${flow.toUpperCase()} | ${projectName}`;
41
+ const subLine = [
42
+ `path: ${workspacePath}`,
43
+ `updated: ${formatTime(lastRefreshAt)}`,
44
+ `watch: ${watchEnabled ? watchStatus : 'off'}`,
45
+ `view: ${view}`
46
+ ].join(' | ');
47
+
48
+ const horizontal = '-'.repeat(Math.max(20, Math.min(width || 120, 120)));
49
+
50
+ return [
51
+ truncate(topLine, width),
52
+ truncate(subLine, width),
53
+ truncate(horizontal, width)
54
+ ];
55
+ }
56
+
57
+ module.exports = {
58
+ renderHeaderLines,
59
+ truncate
60
+ };
@@ -0,0 +1,15 @@
1
+ const { truncate } = require('./header');
2
+
3
+ function renderHelpLines(showHelp, width) {
4
+ if (!showHelp) {
5
+ return [truncate('Press h to show keyboard shortcuts.', width)];
6
+ }
7
+
8
+ return [
9
+ truncate('Keys: q quit | r refresh | h/? toggle help | tab cycle view | 1 runs | 2 overview', width)
10
+ ];
11
+ }
12
+
13
+ module.exports = {
14
+ renderHelpLines
15
+ };
@@ -0,0 +1,35 @@
1
+ const { truncate } = require('./header');
2
+
3
+ function safePercent(part, total) {
4
+ if (!total || total <= 0) {
5
+ return 0;
6
+ }
7
+
8
+ return Math.round((part / total) * 100);
9
+ }
10
+
11
+ function renderStatsLines(snapshot, width) {
12
+ if (!snapshot?.initialized) {
13
+ return [truncate('stats: waiting for .specs-fire/state.yaml initialization', width)];
14
+ }
15
+
16
+ const stats = snapshot.stats;
17
+ const workItemProgress = `${stats.completedWorkItems}/${stats.totalWorkItems}`;
18
+ const workItemPct = safePercent(stats.completedWorkItems, stats.totalWorkItems);
19
+
20
+ const intentProgress = `${stats.completedIntents}/${stats.totalIntents}`;
21
+ const intentPct = safePercent(stats.completedIntents, stats.totalIntents);
22
+
23
+ const line = [
24
+ `Intents ${intentProgress} (${intentPct}%)`,
25
+ `Work items ${workItemProgress} (${workItemPct}%)`,
26
+ `Runs ${stats.activeRunsCount} active / ${stats.completedRuns} completed`,
27
+ `Blocked ${stats.blockedWorkItems}`
28
+ ].join(' | ');
29
+
30
+ return [truncate(line, width)];
31
+ }
32
+
33
+ module.exports = {
34
+ renderStatsLines
35
+ };
@@ -0,0 +1,383 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { fileExists, clampIndex } = require('./helpers');
4
+ const { getEffectiveFlow, getCurrentRun, getCurrentBolt, getCurrentSpec } = require('./flow-builders');
5
+
6
+ function listMarkdownFiles(dirPath) {
7
+ try {
8
+ return fs.readdirSync(dirPath, { withFileTypes: true })
9
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
10
+ .map((entry) => entry.name)
11
+ .sort((a, b) => a.localeCompare(b));
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
16
+
17
+ function pushFileEntry(entries, seenPaths, candidate) {
18
+ if (!candidate || typeof candidate.path !== 'string' || typeof candidate.label !== 'string') {
19
+ return;
20
+ }
21
+
22
+ if (!fileExists(candidate.path)) {
23
+ return;
24
+ }
25
+
26
+ if (seenPaths.has(candidate.path)) {
27
+ return;
28
+ }
29
+
30
+ seenPaths.add(candidate.path);
31
+ entries.push({
32
+ path: candidate.path,
33
+ label: candidate.label,
34
+ scope: candidate.scope || 'other'
35
+ });
36
+ }
37
+
38
+ function buildIntentScopedLabel(snapshot, intentId, filePath, fallbackName = 'file.md') {
39
+ const safeIntentId = typeof intentId === 'string' && intentId.trim() !== ''
40
+ ? intentId
41
+ : '';
42
+ const safeFallback = typeof fallbackName === 'string' && fallbackName.trim() !== ''
43
+ ? fallbackName
44
+ : 'file.md';
45
+
46
+ if (typeof filePath === 'string' && filePath.trim() !== '') {
47
+ if (safeIntentId && typeof snapshot?.rootPath === 'string' && snapshot.rootPath.trim() !== '') {
48
+ const intentPath = path.join(snapshot.rootPath, 'intents', safeIntentId);
49
+ const relativePath = path.relative(intentPath, filePath);
50
+ if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
51
+ return `${safeIntentId}/${relativePath.split(path.sep).join('/')}`;
52
+ }
53
+ }
54
+
55
+ const basename = path.basename(filePath);
56
+ return safeIntentId ? `${safeIntentId}/${basename}` : basename;
57
+ }
58
+
59
+ return safeIntentId ? `${safeIntentId}/${safeFallback}` : safeFallback;
60
+ }
61
+
62
+ function findIntentIdForWorkItem(snapshot, workItemId) {
63
+ if (typeof workItemId !== 'string' || workItemId.trim() === '') {
64
+ return '';
65
+ }
66
+
67
+ const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
68
+ for (const intent of intents) {
69
+ const items = Array.isArray(intent?.workItems) ? intent.workItems : [];
70
+ if (items.some((item) => item?.id === workItemId)) {
71
+ return intent?.id || '';
72
+ }
73
+ }
74
+
75
+ return '';
76
+ }
77
+
78
+ function resolveFireWorkItemPath(snapshot, intentId, workItemId, explicitPath) {
79
+ if (typeof explicitPath === 'string' && explicitPath.trim() !== '') {
80
+ return explicitPath;
81
+ }
82
+
83
+ if (typeof snapshot?.rootPath !== 'string' || snapshot.rootPath.trim() === '') {
84
+ return null;
85
+ }
86
+
87
+ if (typeof workItemId !== 'string' || workItemId.trim() === '') {
88
+ return null;
89
+ }
90
+
91
+ const safeIntentId = typeof intentId === 'string' && intentId.trim() !== ''
92
+ ? intentId
93
+ : findIntentIdForWorkItem(snapshot, workItemId);
94
+
95
+ if (!safeIntentId) {
96
+ return null;
97
+ }
98
+
99
+ return path.join(snapshot.rootPath, 'intents', safeIntentId, 'work-items', `${workItemId}.md`);
100
+ }
101
+
102
+ function collectFireRunFiles(run) {
103
+ if (!run || typeof run.folderPath !== 'string') {
104
+ return [];
105
+ }
106
+
107
+ const names = listMarkdownFiles(run.folderPath).sort((a, b) => {
108
+ if (a === 'run.md') return -1;
109
+ if (b === 'run.md') return 1;
110
+ return a.localeCompare(b);
111
+ });
112
+
113
+ return names.map((fileName) => ({
114
+ label: `${run.id}/${fileName}`,
115
+ path: path.join(run.folderPath, fileName)
116
+ }));
117
+ }
118
+
119
+ function collectAidlcBoltFiles(bolt) {
120
+ if (!bolt || typeof bolt.path !== 'string') {
121
+ return [];
122
+ }
123
+
124
+ const fileNames = Array.isArray(bolt.files) && bolt.files.length > 0
125
+ ? bolt.files
126
+ : listMarkdownFiles(bolt.path);
127
+
128
+ return fileNames.map((fileName) => ({
129
+ label: `${bolt.id}/${fileName}`,
130
+ path: path.join(bolt.path, fileName)
131
+ }));
132
+ }
133
+
134
+ function collectSimpleSpecFiles(spec) {
135
+ if (!spec || typeof spec.path !== 'string') {
136
+ return [];
137
+ }
138
+
139
+ const names = [];
140
+ if (spec.hasRequirements) names.push('requirements.md');
141
+ if (spec.hasDesign) names.push('design.md');
142
+ if (spec.hasTasks) names.push('tasks.md');
143
+
144
+ return names.map((fileName) => ({
145
+ label: `${spec.name}/${fileName}`,
146
+ path: path.join(spec.path, fileName)
147
+ }));
148
+ }
149
+
150
+ function collectAidlcIntentContextFiles(snapshot, intentId) {
151
+ if (!snapshot || typeof intentId !== 'string' || intentId.trim() === '') {
152
+ return [];
153
+ }
154
+
155
+ const intentPath = path.join(snapshot.rootPath || '', 'intents', intentId);
156
+ return [
157
+ {
158
+ label: `${intentId}/requirements.md`,
159
+ path: path.join(intentPath, 'requirements.md'),
160
+ scope: 'intent'
161
+ },
162
+ {
163
+ label: `${intentId}/system-context.md`,
164
+ path: path.join(intentPath, 'system-context.md'),
165
+ scope: 'intent'
166
+ },
167
+ {
168
+ label: `${intentId}/units.md`,
169
+ path: path.join(intentPath, 'units.md'),
170
+ scope: 'intent'
171
+ }
172
+ ];
173
+ }
174
+
175
+ function filterExistingFiles(files) {
176
+ return (Array.isArray(files) ? files : []).filter((file) => {
177
+ if (!file || typeof file.path !== 'string' || typeof file.label !== 'string') {
178
+ return false;
179
+ }
180
+ if (file.allowMissing === true) {
181
+ return true;
182
+ }
183
+ return fileExists(file.path);
184
+ });
185
+ }
186
+
187
+ function getRunFileEntries(snapshot, flow, options = {}) {
188
+ const includeBacklog = options.includeBacklog !== false;
189
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
190
+ const entries = [];
191
+ const seenPaths = new Set();
192
+
193
+ if (effectiveFlow === 'aidlc') {
194
+ const bolt = getCurrentBolt(snapshot);
195
+ for (const file of collectAidlcBoltFiles(bolt)) {
196
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
197
+ }
198
+
199
+ if (!includeBacklog) {
200
+ return entries;
201
+ }
202
+
203
+ const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
204
+ for (const pendingBolt of pendingBolts) {
205
+ for (const file of collectAidlcBoltFiles(pendingBolt)) {
206
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'upcoming' });
207
+ }
208
+ }
209
+
210
+ const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
211
+ for (const completedBolt of completedBolts) {
212
+ for (const file of collectAidlcBoltFiles(completedBolt)) {
213
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
214
+ }
215
+ }
216
+
217
+ const intentIds = new Set([
218
+ ...pendingBolts.map((item) => item?.intent).filter(Boolean),
219
+ ...completedBolts.map((item) => item?.intent).filter(Boolean)
220
+ ]);
221
+
222
+ for (const intentId of intentIds) {
223
+ const intentPath = path.join(snapshot?.rootPath || '', 'intents', intentId);
224
+ pushFileEntry(entries, seenPaths, {
225
+ label: `${intentId}/requirements.md`,
226
+ path: path.join(intentPath, 'requirements.md'),
227
+ scope: 'intent'
228
+ });
229
+ pushFileEntry(entries, seenPaths, {
230
+ label: `${intentId}/system-context.md`,
231
+ path: path.join(intentPath, 'system-context.md'),
232
+ scope: 'intent'
233
+ });
234
+ pushFileEntry(entries, seenPaths, {
235
+ label: `${intentId}/units.md`,
236
+ path: path.join(intentPath, 'units.md'),
237
+ scope: 'intent'
238
+ });
239
+ }
240
+ return entries;
241
+ }
242
+
243
+ if (effectiveFlow === 'simple') {
244
+ const spec = getCurrentSpec(snapshot);
245
+ for (const file of collectSimpleSpecFiles(spec)) {
246
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
247
+ }
248
+
249
+ if (!includeBacklog) {
250
+ return entries;
251
+ }
252
+
253
+ const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
254
+ for (const pendingSpec of pendingSpecs) {
255
+ for (const file of collectSimpleSpecFiles(pendingSpec)) {
256
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'upcoming' });
257
+ }
258
+ }
259
+
260
+ const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
261
+ for (const completedSpec of completedSpecs) {
262
+ for (const file of collectSimpleSpecFiles(completedSpec)) {
263
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
264
+ }
265
+ }
266
+
267
+ return entries;
268
+ }
269
+
270
+ const run = getCurrentRun(snapshot);
271
+ for (const file of collectFireRunFiles(run)) {
272
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
273
+ }
274
+
275
+ if (!includeBacklog) {
276
+ return entries;
277
+ }
278
+
279
+ const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
280
+ for (const pendingItem of pendingItems) {
281
+ pushFileEntry(entries, seenPaths, {
282
+ label: buildIntentScopedLabel(
283
+ snapshot,
284
+ pendingItem?.intentId,
285
+ pendingItem?.filePath,
286
+ `${pendingItem?.id || 'work-item'}.md`
287
+ ),
288
+ path: pendingItem?.filePath,
289
+ scope: 'upcoming'
290
+ });
291
+
292
+ if (pendingItem?.intentId) {
293
+ pushFileEntry(entries, seenPaths, {
294
+ label: buildIntentScopedLabel(
295
+ snapshot,
296
+ pendingItem.intentId,
297
+ path.join(snapshot?.rootPath || '', 'intents', pendingItem.intentId, 'brief.md'),
298
+ 'brief.md'
299
+ ),
300
+ path: path.join(snapshot?.rootPath || '', 'intents', pendingItem.intentId, 'brief.md'),
301
+ scope: 'intent'
302
+ });
303
+ }
304
+ }
305
+
306
+ const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
307
+ for (const completedRun of completedRuns) {
308
+ for (const file of collectFireRunFiles(completedRun)) {
309
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
310
+ }
311
+ }
312
+
313
+ const completedIntents = Array.isArray(snapshot?.intents)
314
+ ? snapshot.intents.filter((intent) => intent?.status === 'completed')
315
+ : [];
316
+ for (const intent of completedIntents) {
317
+ pushFileEntry(entries, seenPaths, {
318
+ label: buildIntentScopedLabel(
319
+ snapshot,
320
+ intent?.id,
321
+ path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
322
+ 'brief.md'
323
+ ),
324
+ path: path.join(snapshot?.rootPath || '', 'intents', intent.id, 'brief.md'),
325
+ scope: 'intent'
326
+ });
327
+ }
328
+
329
+ return entries;
330
+ }
331
+
332
+ function getNoFileMessage(flow) {
333
+ return `No selectable files for ${String(flow || 'flow').toUpperCase()}`;
334
+ }
335
+
336
+ function formatScope(scope) {
337
+ if (scope === 'active') return 'ACTIVE';
338
+ if (scope === 'upcoming') return 'UPNEXT';
339
+ if (scope === 'completed') return 'DONE';
340
+ if (scope === 'intent') return 'INTENT';
341
+ if (scope === 'staged') return 'STAGED';
342
+ if (scope === 'unstaged') return 'UNSTAGED';
343
+ if (scope === 'untracked') return 'UNTRACKED';
344
+ if (scope === 'conflicted') return 'CONFLICT';
345
+ return 'FILE';
346
+ }
347
+
348
+ function getNoPendingMessage(flow) {
349
+ if (flow === 'aidlc') return 'No queued bolts';
350
+ if (flow === 'simple') return 'No pending specs';
351
+ return 'No pending work items';
352
+ }
353
+
354
+ function getNoCompletedMessage(flow) {
355
+ if (flow === 'aidlc') return 'No completed bolts yet';
356
+ if (flow === 'simple') return 'No completed specs yet';
357
+ return 'No completed runs yet';
358
+ }
359
+
360
+ function getNoCurrentMessage(flow) {
361
+ if (flow === 'aidlc') return 'No active bolt';
362
+ if (flow === 'simple') return 'No active spec';
363
+ return 'No active run';
364
+ }
365
+
366
+ module.exports = {
367
+ listMarkdownFiles,
368
+ pushFileEntry,
369
+ buildIntentScopedLabel,
370
+ findIntentIdForWorkItem,
371
+ resolveFireWorkItemPath,
372
+ collectFireRunFiles,
373
+ collectAidlcBoltFiles,
374
+ collectSimpleSpecFiles,
375
+ collectAidlcIntentContextFiles,
376
+ filterExistingFiles,
377
+ getRunFileEntries,
378
+ getNoFileMessage,
379
+ formatScope,
380
+ getNoPendingMessage,
381
+ getNoCompletedMessage,
382
+ getNoCurrentMessage
383
+ };