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,362 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ function runGit(args, cwd, options = {}) {
6
+ const acceptedStatuses = Array.isArray(options.acceptedStatuses) && options.acceptedStatuses.length > 0
7
+ ? options.acceptedStatuses
8
+ : [0];
9
+ const result = spawnSync('git', args, {
10
+ cwd,
11
+ encoding: 'utf8',
12
+ maxBuffer: 10 * 1024 * 1024
13
+ });
14
+
15
+ if (result.error) {
16
+ return {
17
+ ok: false,
18
+ error: result.error.message || String(result.error),
19
+ stdout: '',
20
+ stderr: ''
21
+ };
22
+ }
23
+
24
+ if (typeof result.status === 'number' && !acceptedStatuses.includes(result.status)) {
25
+ return {
26
+ ok: false,
27
+ error: String(result.stderr || '').trim() || `git exited with code ${result.status}`,
28
+ stdout: String(result.stdout || ''),
29
+ stderr: String(result.stderr || '')
30
+ };
31
+ }
32
+
33
+ return {
34
+ ok: true,
35
+ error: null,
36
+ stdout: String(result.stdout || ''),
37
+ stderr: String(result.stderr || '')
38
+ };
39
+ }
40
+
41
+ function findGitRoot(worktreePath) {
42
+ if (typeof worktreePath !== 'string' || worktreePath.trim() === '') {
43
+ return null;
44
+ }
45
+
46
+ const probe = runGit(['rev-parse', '--show-toplevel'], worktreePath);
47
+ if (!probe.ok) {
48
+ return null;
49
+ }
50
+
51
+ const root = probe.stdout.trim();
52
+ return root === '' ? null : root;
53
+ }
54
+
55
+ function parseBranchSummary(line) {
56
+ const raw = String(line || '').replace(/^##\s*/, '').trim();
57
+ if (raw === '') {
58
+ return {
59
+ branch: '(unknown)',
60
+ upstream: null,
61
+ ahead: 0,
62
+ behind: 0,
63
+ detached: false
64
+ };
65
+ }
66
+
67
+ if (raw.startsWith('HEAD ')) {
68
+ return {
69
+ branch: '(detached)',
70
+ upstream: null,
71
+ ahead: 0,
72
+ behind: 0,
73
+ detached: true
74
+ };
75
+ }
76
+
77
+ const [branchPart, trackingPartRaw] = raw.split(/\s+/, 2);
78
+ let branch = branchPart;
79
+ let upstream = null;
80
+ if (branch.includes('...')) {
81
+ const [name, remote] = branch.split('...', 2);
82
+ branch = name || '(unknown)';
83
+ upstream = remote || null;
84
+ }
85
+
86
+ const trackingPart = typeof trackingPartRaw === 'string' ? trackingPartRaw : '';
87
+ const aheadMatch = trackingPart.match(/ahead\s+(\d+)/);
88
+ const behindMatch = trackingPart.match(/behind\s+(\d+)/);
89
+ const ahead = aheadMatch ? Number.parseInt(aheadMatch[1], 10) : 0;
90
+ const behind = behindMatch ? Number.parseInt(behindMatch[1], 10) : 0;
91
+
92
+ return {
93
+ branch: branch || '(unknown)',
94
+ upstream,
95
+ ahead: Number.isFinite(ahead) ? ahead : 0,
96
+ behind: Number.isFinite(behind) ? behind : 0,
97
+ detached: false
98
+ };
99
+ }
100
+
101
+ function parseStatusEntry(line, repoRoot) {
102
+ const raw = String(line || '');
103
+ if (raw.trim() === '' || raw.startsWith('## ')) {
104
+ return null;
105
+ }
106
+
107
+ const code = raw.slice(0, 2);
108
+ const statusX = code.charAt(0);
109
+ const statusY = code.charAt(1);
110
+ const remainder = raw.length > 3 ? raw.slice(3) : '';
111
+
112
+ let relativePath = remainder.trim();
113
+ if (relativePath.includes(' -> ')) {
114
+ const parts = relativePath.split(' -> ');
115
+ relativePath = parts[parts.length - 1].trim();
116
+ }
117
+
118
+ const absolutePath = relativePath === ''
119
+ ? ''
120
+ : path.join(repoRoot, relativePath);
121
+ const isUntracked = code === '??';
122
+ const isConflicted = statusX === 'U'
123
+ || statusY === 'U'
124
+ || code === 'AA'
125
+ || code === 'DD';
126
+ const isStaged = !isUntracked && statusX !== ' ';
127
+ const isUnstaged = !isUntracked && statusY !== ' ';
128
+
129
+ return {
130
+ code,
131
+ statusX,
132
+ statusY,
133
+ relativePath,
134
+ absolutePath,
135
+ staged: isStaged,
136
+ unstaged: isUnstaged,
137
+ untracked: isUntracked,
138
+ conflicted: isConflicted
139
+ };
140
+ }
141
+
142
+ function buildBucketItem(entry, bucket) {
143
+ return {
144
+ key: `${bucket}:${entry.relativePath}`,
145
+ bucket,
146
+ code: entry.code,
147
+ path: entry.absolutePath,
148
+ relativePath: entry.relativePath,
149
+ label: entry.relativePath
150
+ };
151
+ }
152
+
153
+ function listGitChanges(worktreePath) {
154
+ const gitRoot = findGitRoot(worktreePath);
155
+ if (!gitRoot) {
156
+ return {
157
+ available: false,
158
+ rootPath: null,
159
+ branch: '(not a git repo)',
160
+ upstream: null,
161
+ ahead: 0,
162
+ behind: 0,
163
+ detached: false,
164
+ clean: true,
165
+ counts: {
166
+ total: 0,
167
+ staged: 0,
168
+ unstaged: 0,
169
+ untracked: 0,
170
+ conflicted: 0
171
+ },
172
+ staged: [],
173
+ unstaged: [],
174
+ untracked: [],
175
+ conflicted: [],
176
+ generatedAt: new Date().toISOString()
177
+ };
178
+ }
179
+
180
+ const statusResult = runGit(
181
+ ['-c', 'color.ui=false', '-c', 'core.quotepath=false', 'status', '--porcelain', '--branch', '--untracked-files=all'],
182
+ gitRoot
183
+ );
184
+
185
+ if (!statusResult.ok) {
186
+ return {
187
+ available: false,
188
+ rootPath: gitRoot,
189
+ branch: '(status unavailable)',
190
+ upstream: null,
191
+ ahead: 0,
192
+ behind: 0,
193
+ detached: false,
194
+ clean: true,
195
+ counts: {
196
+ total: 0,
197
+ staged: 0,
198
+ unstaged: 0,
199
+ untracked: 0,
200
+ conflicted: 0
201
+ },
202
+ staged: [],
203
+ unstaged: [],
204
+ untracked: [],
205
+ conflicted: [],
206
+ error: statusResult.error,
207
+ generatedAt: new Date().toISOString()
208
+ };
209
+ }
210
+
211
+ const lines = statusResult.stdout.split(/\r?\n/).filter(Boolean);
212
+ const branchInfo = parseBranchSummary(lines[0] || '');
213
+
214
+ const staged = [];
215
+ const unstaged = [];
216
+ const untracked = [];
217
+ const conflicted = [];
218
+
219
+ for (const line of lines.slice(1)) {
220
+ const entry = parseStatusEntry(line, gitRoot);
221
+ if (!entry || entry.relativePath === '') {
222
+ continue;
223
+ }
224
+ if (entry.conflicted) {
225
+ conflicted.push(buildBucketItem(entry, 'conflicted'));
226
+ }
227
+ if (entry.staged) {
228
+ staged.push(buildBucketItem(entry, 'staged'));
229
+ }
230
+ if (entry.unstaged) {
231
+ unstaged.push(buildBucketItem(entry, 'unstaged'));
232
+ }
233
+ if (entry.untracked) {
234
+ untracked.push(buildBucketItem(entry, 'untracked'));
235
+ }
236
+ }
237
+
238
+ const uniqueCount = new Set([
239
+ ...staged.map((item) => item.relativePath),
240
+ ...unstaged.map((item) => item.relativePath),
241
+ ...untracked.map((item) => item.relativePath),
242
+ ...conflicted.map((item) => item.relativePath)
243
+ ]).size;
244
+
245
+ return {
246
+ available: true,
247
+ rootPath: gitRoot,
248
+ branch: branchInfo.branch,
249
+ upstream: branchInfo.upstream,
250
+ ahead: branchInfo.ahead,
251
+ behind: branchInfo.behind,
252
+ detached: branchInfo.detached,
253
+ clean: uniqueCount === 0,
254
+ counts: {
255
+ total: uniqueCount,
256
+ staged: staged.length,
257
+ unstaged: unstaged.length,
258
+ untracked: untracked.length,
259
+ conflicted: conflicted.length
260
+ },
261
+ staged,
262
+ unstaged,
263
+ untracked,
264
+ conflicted,
265
+ generatedAt: new Date().toISOString()
266
+ };
267
+ }
268
+
269
+ function readUntrackedFileDiff(repoRoot, absolutePath) {
270
+ const exists = typeof absolutePath === 'string' && absolutePath !== '' && fs.existsSync(absolutePath);
271
+ if (!exists) {
272
+ return '';
273
+ }
274
+
275
+ const result = runGit(
276
+ ['-c', 'color.ui=false', '--no-pager', 'diff', '--no-index', '--', '/dev/null', absolutePath],
277
+ repoRoot,
278
+ { acceptedStatuses: [0, 1] }
279
+ );
280
+ if (!result.ok) {
281
+ return '';
282
+ }
283
+ return result.stdout;
284
+ }
285
+
286
+ function loadGitDiffPreview(changeEntry) {
287
+ const bucket = typeof changeEntry?.bucket === 'string' ? changeEntry.bucket : 'unstaged';
288
+ const repoRoot = typeof changeEntry?.repoRoot === 'string'
289
+ ? changeEntry.repoRoot
290
+ : (typeof changeEntry?.workspacePath === 'string' ? findGitRoot(changeEntry.workspacePath) : null);
291
+ const relativePath = typeof changeEntry?.relativePath === 'string' ? changeEntry.relativePath : '';
292
+ const absolutePath = typeof changeEntry?.path === 'string' ? changeEntry.path : '';
293
+
294
+ if (!repoRoot) {
295
+ return '[git] repository is unavailable for preview.';
296
+ }
297
+ if (relativePath === '') {
298
+ return '[git] no file selected.';
299
+ }
300
+
301
+ if (bucket === 'untracked') {
302
+ const rawDiff = readUntrackedFileDiff(repoRoot, absolutePath);
303
+ if (rawDiff.trim() !== '') {
304
+ return rawDiff;
305
+ }
306
+ }
307
+
308
+ const args = ['-c', 'color.ui=false', '--no-pager', 'diff'];
309
+ if (bucket === 'staged') {
310
+ args.push('--cached');
311
+ }
312
+ args.push('--', relativePath);
313
+
314
+ const result = runGit(args, repoRoot);
315
+ if (!result.ok) {
316
+ return `[git] unable to load diff: ${result.error}`;
317
+ }
318
+
319
+ const output = result.stdout.trim();
320
+ if (output === '') {
321
+ return '[git] no diff output for this file.';
322
+ }
323
+
324
+ return result.stdout;
325
+ }
326
+
327
+ function loadGitCommitPreview(changeEntry) {
328
+ const repoRoot = typeof changeEntry?.repoRoot === 'string'
329
+ ? changeEntry.repoRoot
330
+ : (typeof changeEntry?.workspacePath === 'string' ? findGitRoot(changeEntry.workspacePath) : null);
331
+ const commitHash = typeof changeEntry?.commitHash === 'string'
332
+ ? changeEntry.commitHash.trim()
333
+ : '';
334
+
335
+ if (!repoRoot) {
336
+ return '[git] repository is unavailable for commit preview.';
337
+ }
338
+ if (commitHash === '') {
339
+ return '[git] no commit selected.';
340
+ }
341
+
342
+ const result = runGit(
343
+ ['-c', 'color.ui=false', '--no-pager', 'show', '--patch', '--stat', '--no-ext-diff', commitHash],
344
+ repoRoot
345
+ );
346
+ if (!result.ok) {
347
+ return `[git] unable to load commit diff: ${result.error}`;
348
+ }
349
+
350
+ const output = result.stdout.trim();
351
+ if (output === '') {
352
+ return '[git] no commit output for this selection.';
353
+ }
354
+
355
+ return result.stdout;
356
+ }
357
+
358
+ module.exports = {
359
+ listGitChanges,
360
+ loadGitDiffPreview,
361
+ loadGitCommitPreview
362
+ };
@@ -0,0 +1,248 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ function normalizePath(value) {
6
+ if (typeof value !== 'string' || value.trim() === '') {
7
+ return null;
8
+ }
9
+ try {
10
+ return path.resolve(value.trim());
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ function parseBranchName(refLine) {
17
+ if (typeof refLine !== 'string' || refLine.trim() === '') {
18
+ return '';
19
+ }
20
+ const prefix = 'refs/heads/';
21
+ if (refLine.startsWith(prefix)) {
22
+ return refLine.slice(prefix.length);
23
+ }
24
+ return refLine.trim();
25
+ }
26
+
27
+ function buildWorktreeId(worktreePath) {
28
+ return String(worktreePath || '')
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9/_-]+/g, '-');
31
+ }
32
+
33
+ function parseGitWorktreePorcelain(rawOutput, fallbackWorkspacePath = process.cwd()) {
34
+ const text = String(rawOutput || '');
35
+ const blocks = text
36
+ .split(/\n\s*\n/g)
37
+ .map((block) => block.trim())
38
+ .filter(Boolean);
39
+
40
+ const worktrees = [];
41
+ for (const block of blocks) {
42
+ const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
43
+ if (lines.length === 0) {
44
+ continue;
45
+ }
46
+
47
+ const pathLine = lines.find((line) => line.startsWith('worktree '));
48
+ if (!pathLine) {
49
+ continue;
50
+ }
51
+
52
+ const worktreePath = normalizePath(pathLine.slice('worktree '.length));
53
+ if (!worktreePath) {
54
+ continue;
55
+ }
56
+
57
+ const branchRef = (lines.find((line) => line.startsWith('branch ')) || '').slice('branch '.length);
58
+ const head = (lines.find((line) => line.startsWith('HEAD ')) || '').slice('HEAD '.length);
59
+ const detached = lines.includes('detached');
60
+ const prunable = lines.some((line) => line.startsWith('prunable'));
61
+ const locked = lines.some((line) => line.startsWith('locked'));
62
+ const branch = parseBranchName(branchRef);
63
+ const name = path.basename(worktreePath);
64
+ const displayBranch = detached ? `[detached:${head.slice(0, 7) || 'unknown'}]` : (branch || '[unknown]');
65
+
66
+ worktrees.push({
67
+ id: buildWorktreeId(worktreePath),
68
+ path: worktreePath,
69
+ name,
70
+ branch,
71
+ displayBranch,
72
+ head: head || '',
73
+ detached,
74
+ prunable,
75
+ locked,
76
+ isMainBranch: branch === 'main' || branch === 'master',
77
+ isCurrentPath: false
78
+ });
79
+ }
80
+
81
+ if (worktrees.length === 0) {
82
+ const fallbackPath = normalizePath(fallbackWorkspacePath) || normalizePath(process.cwd()) || process.cwd();
83
+ return [{
84
+ id: buildWorktreeId(fallbackPath),
85
+ path: fallbackPath,
86
+ name: path.basename(fallbackPath),
87
+ branch: '',
88
+ displayBranch: '[non-git]',
89
+ head: '',
90
+ detached: false,
91
+ prunable: false,
92
+ locked: false,
93
+ isMainBranch: false,
94
+ isCurrentPath: true
95
+ }];
96
+ }
97
+
98
+ return worktrees;
99
+ }
100
+
101
+ function markCurrentWorktree(worktrees, workspacePath) {
102
+ const currentPath = normalizePath(workspacePath);
103
+ const safeWorktrees = Array.isArray(worktrees) ? worktrees : [];
104
+ const marked = safeWorktrees.map((worktree) => ({
105
+ ...worktree,
106
+ isCurrentPath: currentPath != null && normalizePath(worktree.path) === currentPath
107
+ }));
108
+
109
+ if (marked.some((worktree) => worktree.isCurrentPath)) {
110
+ return marked;
111
+ }
112
+
113
+ if (currentPath) {
114
+ return marked.map((worktree, index) => ({
115
+ ...worktree,
116
+ isCurrentPath: index === 0
117
+ }));
118
+ }
119
+
120
+ return marked;
121
+ }
122
+
123
+ function sortWorktrees(worktrees) {
124
+ const safeWorktrees = Array.isArray(worktrees) ? [...worktrees] : [];
125
+ return safeWorktrees.sort((a, b) => {
126
+ if (a.isCurrentPath !== b.isCurrentPath) {
127
+ return a.isCurrentPath ? -1 : 1;
128
+ }
129
+ if (a.isMainBranch !== b.isMainBranch) {
130
+ return a.isMainBranch ? -1 : 1;
131
+ }
132
+ return String(a.displayBranch || a.name || '').localeCompare(String(b.displayBranch || b.name || ''));
133
+ });
134
+ }
135
+
136
+ function isGitWorkspace(workspacePath) {
137
+ const cwd = normalizePath(workspacePath) || process.cwd();
138
+ const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
139
+ cwd,
140
+ encoding: 'utf8'
141
+ });
142
+ if (result.error || result.status !== 0) {
143
+ return false;
144
+ }
145
+ return String(result.stdout || '').trim() === 'true';
146
+ }
147
+
148
+ function discoverGitWorktrees(workspacePath) {
149
+ const cwd = normalizePath(workspacePath) || process.cwd();
150
+ if (!isGitWorkspace(cwd)) {
151
+ return {
152
+ worktrees: markCurrentWorktree(parseGitWorktreePorcelain('', cwd), cwd),
153
+ source: 'fallback',
154
+ isGitRepo: false
155
+ };
156
+ }
157
+
158
+ const result = spawnSync('git', ['worktree', 'list', '--porcelain'], {
159
+ cwd,
160
+ encoding: 'utf8'
161
+ });
162
+
163
+ if (result.error || result.status !== 0) {
164
+ return {
165
+ worktrees: markCurrentWorktree(parseGitWorktreePorcelain('', cwd), cwd),
166
+ source: 'fallback',
167
+ isGitRepo: true,
168
+ error: result.error ? result.error.message : String(result.stderr || '').trim()
169
+ };
170
+ }
171
+
172
+ const parsed = parseGitWorktreePorcelain(result.stdout, cwd);
173
+ const marked = markCurrentWorktree(parsed, cwd);
174
+ return {
175
+ worktrees: sortWorktrees(marked),
176
+ source: 'git',
177
+ isGitRepo: true
178
+ };
179
+ }
180
+
181
+ function pickWorktree(worktrees, selector, workspacePath) {
182
+ const safeWorktrees = Array.isArray(worktrees) ? worktrees : [];
183
+ if (safeWorktrees.length === 0) {
184
+ return null;
185
+ }
186
+
187
+ const normalizedSelector = String(selector || '').trim();
188
+ const selectorPath = normalizePath(normalizedSelector);
189
+ const currentPath = normalizePath(workspacePath);
190
+
191
+ if (normalizedSelector !== '') {
192
+ const byId = safeWorktrees.find((item) => item.id === normalizedSelector);
193
+ if (byId) {
194
+ return byId;
195
+ }
196
+
197
+ if (selectorPath) {
198
+ const byPath = safeWorktrees.find((item) => normalizePath(item.path) === selectorPath);
199
+ if (byPath) {
200
+ return byPath;
201
+ }
202
+ }
203
+
204
+ const byBranch = safeWorktrees.find((item) => item.branch === normalizedSelector || item.displayBranch === normalizedSelector);
205
+ if (byBranch) {
206
+ return byBranch;
207
+ }
208
+
209
+ const byName = safeWorktrees.find((item) => item.name === normalizedSelector);
210
+ if (byName) {
211
+ return byName;
212
+ }
213
+ }
214
+
215
+ if (currentPath) {
216
+ const byCurrentPath = safeWorktrees.find((item) => normalizePath(item.path) === currentPath);
217
+ if (byCurrentPath) {
218
+ return byCurrentPath;
219
+ }
220
+ }
221
+
222
+ const markedCurrent = safeWorktrees.find((item) => item.isCurrentPath);
223
+ if (markedCurrent) {
224
+ return markedCurrent;
225
+ }
226
+
227
+ return safeWorktrees[0];
228
+ }
229
+
230
+ function pathExistsAsDirectory(targetPath) {
231
+ try {
232
+ return fs.statSync(targetPath).isDirectory();
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ module.exports = {
239
+ normalizePath,
240
+ parseBranchName,
241
+ parseGitWorktreePorcelain,
242
+ markCurrentWorktree,
243
+ sortWorktrees,
244
+ isGitWorkspace,
245
+ discoverGitWorktrees,
246
+ pickWorktree,
247
+ pathExistsAsDirectory
248
+ };