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.
- package/README.md +15 -0
- package/bin/cli.js +15 -1
- package/flows/fire/agents/builder/agent.md +2 -2
- package/flows/fire/agents/builder/skills/code-review/SKILL.md +1 -1
- package/flows/fire/agents/builder/skills/run-execute/SKILL.md +16 -7
- package/flows/fire/agents/builder/skills/run-execute/scripts/complete-run.cjs +22 -3
- package/flows/fire/agents/builder/skills/run-execute/scripts/init-run.cjs +63 -20
- package/flows/fire/agents/builder/skills/run-execute/scripts/update-checkpoint.cjs +254 -0
- package/flows/fire/agents/builder/skills/run-execute/scripts/update-phase.cjs +17 -6
- package/flows/fire/agents/builder/skills/run-status/SKILL.md +1 -1
- package/flows/fire/agents/orchestrator/agent.md +1 -1
- package/flows/fire/agents/orchestrator/skills/status/SKILL.md +2 -2
- package/flows/fire/memory-bank.yaml +4 -4
- package/lib/dashboard/aidlc/parser.js +581 -0
- package/lib/dashboard/fire/model.js +382 -0
- package/lib/dashboard/fire/parser.js +470 -0
- package/lib/dashboard/flow-detect.js +86 -0
- package/lib/dashboard/git/changes.js +362 -0
- package/lib/dashboard/git/worktrees.js +248 -0
- package/lib/dashboard/index.js +709 -0
- package/lib/dashboard/runtime/watch-runtime.js +122 -0
- package/lib/dashboard/simple/parser.js +293 -0
- package/lib/dashboard/tui/app.js +1675 -0
- package/lib/dashboard/tui/components/error-banner.js +35 -0
- package/lib/dashboard/tui/components/header.js +60 -0
- package/lib/dashboard/tui/components/help-footer.js +15 -0
- package/lib/dashboard/tui/components/stats-strip.js +35 -0
- package/lib/dashboard/tui/file-entries.js +383 -0
- package/lib/dashboard/tui/flow-builders.js +991 -0
- package/lib/dashboard/tui/git-builders.js +218 -0
- package/lib/dashboard/tui/helpers.js +236 -0
- package/lib/dashboard/tui/overlays.js +242 -0
- package/lib/dashboard/tui/preview.js +220 -0
- package/lib/dashboard/tui/renderer.js +76 -0
- package/lib/dashboard/tui/row-builders.js +797 -0
- package/lib/dashboard/tui/sections.js +45 -0
- package/lib/dashboard/tui/store.js +44 -0
- package/lib/dashboard/tui/views/overview-view.js +61 -0
- package/lib/dashboard/tui/views/runs-view.js +93 -0
- package/lib/dashboard/tui/worktree-builders.js +229 -0
- package/lib/installers/CodexInstaller.js +72 -1
- 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
|
+
};
|