specsmd 0.1.22 → 0.1.24

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.
@@ -0,0 +1,175 @@
1
+ const { createWatchRuntime } = require('../runtime/watch-runtime');
2
+ const { createInitialUIState, cycleView, cycleRunFilter } = require('./store');
3
+ const { formatDashboardText } = require('./renderer');
4
+
5
+ function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
6
+ if (!error) {
7
+ return {
8
+ code: defaultCode,
9
+ message: 'Unknown dashboard error.'
10
+ };
11
+ }
12
+
13
+ if (typeof error === 'string') {
14
+ return {
15
+ code: defaultCode,
16
+ message: error
17
+ };
18
+ }
19
+
20
+ if (typeof error === 'object') {
21
+ return {
22
+ code: error.code || defaultCode,
23
+ message: error.message || 'Unknown dashboard error.',
24
+ details: error.details,
25
+ path: error.path,
26
+ hint: error.hint
27
+ };
28
+ }
29
+
30
+ return {
31
+ code: defaultCode,
32
+ message: String(error)
33
+ };
34
+ }
35
+
36
+ function createDashboardApp(deps) {
37
+ const {
38
+ React,
39
+ ink,
40
+ parseSnapshot,
41
+ workspacePath,
42
+ rootPath,
43
+ flow,
44
+ refreshMs,
45
+ watchEnabled,
46
+ initialSnapshot,
47
+ initialError
48
+ } = deps;
49
+
50
+ const { Box, Text, useApp, useInput } = ink;
51
+ const { useState, useEffect, useCallback } = React;
52
+
53
+ function DashboardApp() {
54
+ const { exit } = useApp();
55
+
56
+ const [snapshot, setSnapshot] = useState(initialSnapshot || null);
57
+ const [error, setError] = useState(initialError ? toDashboardError(initialError) : null);
58
+ const [ui, setUi] = useState(createInitialUIState());
59
+ const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
60
+ const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
61
+
62
+ const refresh = useCallback(async () => {
63
+ try {
64
+ const result = await parseSnapshot();
65
+
66
+ if (result?.ok) {
67
+ setSnapshot(result.snapshot || null);
68
+ setError(null);
69
+ setWatchStatus(watchEnabled ? 'watching' : 'off');
70
+ } else {
71
+ setError(toDashboardError(result?.error, 'PARSE_ERROR'));
72
+ }
73
+ } catch (refreshError) {
74
+ setError(toDashboardError(refreshError, 'REFRESH_FAILED'));
75
+ } finally {
76
+ setLastRefreshAt(new Date().toISOString());
77
+ }
78
+ }, [parseSnapshot, watchEnabled]);
79
+
80
+ useInput((input, key) => {
81
+ if ((key.ctrl && input === 'c') || input === 'q') {
82
+ exit();
83
+ return;
84
+ }
85
+
86
+ if (input === 'r') {
87
+ void refresh();
88
+ return;
89
+ }
90
+
91
+ if (input === 'h' || input === '?') {
92
+ setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }));
93
+ return;
94
+ }
95
+
96
+ if (input === '1') {
97
+ setUi((previous) => ({ ...previous, view: 'runs' }));
98
+ return;
99
+ }
100
+
101
+ if (input === '2') {
102
+ setUi((previous) => ({ ...previous, view: 'overview' }));
103
+ return;
104
+ }
105
+
106
+ if (key.tab) {
107
+ setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
108
+ return;
109
+ }
110
+
111
+ if (input === 'f') {
112
+ setUi((previous) => ({ ...previous, runFilter: cycleRunFilter(previous.runFilter) }));
113
+ }
114
+ });
115
+
116
+ useEffect(() => {
117
+ void refresh();
118
+ }, [refresh]);
119
+
120
+ useEffect(() => {
121
+ if (!watchEnabled) {
122
+ return undefined;
123
+ }
124
+
125
+ const runtime = createWatchRuntime({
126
+ rootPath: rootPath || `${workspacePath}/.specs-fire`,
127
+ debounceMs: 250,
128
+ onRefresh: () => {
129
+ void refresh();
130
+ },
131
+ onError: (watchError) => {
132
+ setWatchStatus('reconnecting');
133
+ setError(toDashboardError(watchError, 'WATCH_ERROR'));
134
+ }
135
+ });
136
+
137
+ runtime.start();
138
+ const interval = setInterval(() => {
139
+ void refresh();
140
+ }, refreshMs);
141
+
142
+ return () => {
143
+ clearInterval(interval);
144
+ void runtime.close();
145
+ };
146
+ }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath]);
147
+
148
+ const dashboardOutput = formatDashboardText({
149
+ snapshot,
150
+ error,
151
+ flow,
152
+ workspacePath,
153
+ view: ui.view,
154
+ runFilter: ui.runFilter,
155
+ watchEnabled,
156
+ watchStatus,
157
+ showHelp: ui.showHelp,
158
+ lastRefreshAt,
159
+ width: process.stdout.columns || 120
160
+ });
161
+
162
+ return React.createElement(
163
+ Box,
164
+ { flexDirection: 'column' },
165
+ React.createElement(Text, null, dashboardOutput)
166
+ );
167
+ }
168
+
169
+ return DashboardApp;
170
+ }
171
+
172
+ module.exports = {
173
+ createDashboardApp,
174
+ toDashboardError
175
+ };
@@ -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,62 @@
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
+ runFilter,
34
+ watchEnabled,
35
+ watchStatus,
36
+ lastRefreshAt,
37
+ width
38
+ } = params;
39
+
40
+ const projectName = snapshot?.project?.name || 'Unnamed FIRE project';
41
+ const topLine = `specsmd dashboard | ${flow.toUpperCase()} | ${projectName}`;
42
+ const subLine = [
43
+ `path: ${workspacePath}`,
44
+ `updated: ${formatTime(lastRefreshAt)}`,
45
+ `watch: ${watchEnabled ? watchStatus : 'off'}`,
46
+ `view: ${view}`,
47
+ `filter: ${runFilter}`
48
+ ].join(' | ');
49
+
50
+ const horizontal = '-'.repeat(Math.max(20, Math.min(width || 120, 120)));
51
+
52
+ return [
53
+ truncate(topLine, width),
54
+ truncate(subLine, width),
55
+ truncate(horizontal, width)
56
+ ];
57
+ }
58
+
59
+ module.exports = {
60
+ renderHeaderLines,
61
+ truncate
62
+ };
@@ -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 | f cycle run filter', 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,78 @@
1
+ const { renderHeaderLines, truncate } = require('./components/header');
2
+ const { renderStatsLines } = require('./components/stats-strip');
3
+ const { renderErrorLines } = require('./components/error-banner');
4
+ const { renderHelpLines } = require('./components/help-footer');
5
+ const { renderRunsViewLines } = require('./views/runs-view');
6
+ const { renderOverviewViewLines } = require('./views/overview-view');
7
+
8
+ function normalizeWidth(width) {
9
+ if (!Number.isFinite(width)) {
10
+ return 120;
11
+ }
12
+
13
+ return Math.max(40, Math.min(Math.floor(width), 180));
14
+ }
15
+
16
+ function buildDashboardLines(params) {
17
+ const {
18
+ snapshot,
19
+ error,
20
+ flow,
21
+ workspacePath,
22
+ view,
23
+ runFilter,
24
+ watchEnabled,
25
+ watchStatus,
26
+ showHelp,
27
+ lastRefreshAt,
28
+ width
29
+ } = params;
30
+
31
+ const safeWidth = normalizeWidth(width);
32
+ const lines = [];
33
+
34
+ lines.push(...renderHeaderLines({
35
+ snapshot,
36
+ flow,
37
+ workspacePath,
38
+ view,
39
+ runFilter,
40
+ watchEnabled,
41
+ watchStatus,
42
+ lastRefreshAt,
43
+ width: safeWidth
44
+ }));
45
+
46
+ if (snapshot) {
47
+ lines.push(...renderStatsLines(snapshot, safeWidth));
48
+ lines.push('');
49
+ }
50
+
51
+ if (error) {
52
+ lines.push(...renderErrorLines(error, safeWidth, watchEnabled));
53
+ lines.push('');
54
+ }
55
+
56
+ if (!snapshot) {
57
+ lines.push(truncate('No snapshot available yet. Waiting for refresh...', safeWidth));
58
+ } else if (view === 'overview') {
59
+ lines.push(...renderOverviewViewLines(snapshot, safeWidth));
60
+ } else {
61
+ lines.push(...renderRunsViewLines(snapshot, runFilter, safeWidth));
62
+ }
63
+
64
+ lines.push('');
65
+ lines.push(...renderHelpLines(showHelp, safeWidth));
66
+
67
+ return lines;
68
+ }
69
+
70
+ function formatDashboardText(params) {
71
+ return buildDashboardLines(params).join('\n');
72
+ }
73
+
74
+ module.exports = {
75
+ buildDashboardLines,
76
+ formatDashboardText,
77
+ normalizeWidth
78
+ };
@@ -0,0 +1,30 @@
1
+ function createInitialUIState() {
2
+ return {
3
+ view: 'runs',
4
+ runFilter: 'all',
5
+ showHelp: true
6
+ };
7
+ }
8
+
9
+ function cycleView(current) {
10
+ if (current === 'runs') {
11
+ return 'overview';
12
+ }
13
+ return 'runs';
14
+ }
15
+
16
+ function cycleRunFilter(current) {
17
+ if (current === 'all') {
18
+ return 'active';
19
+ }
20
+ if (current === 'active') {
21
+ return 'completed';
22
+ }
23
+ return 'all';
24
+ }
25
+
26
+ module.exports = {
27
+ createInitialUIState,
28
+ cycleView,
29
+ cycleRunFilter
30
+ };
@@ -0,0 +1,61 @@
1
+ const { truncate } = require('../components/header');
2
+
3
+ const STANDARD_TYPES = [
4
+ 'constitution',
5
+ 'tech-stack',
6
+ 'coding-standards',
7
+ 'testing-standards',
8
+ 'system-architecture'
9
+ ];
10
+
11
+ function renderOverviewViewLines(snapshot, width) {
12
+ const lines = ['Overview'];
13
+
14
+ if (!snapshot?.initialized) {
15
+ lines.push('FIRE project folder exists but state.yaml is missing.');
16
+ lines.push('Run initialization and the overview will appear automatically.');
17
+ return lines.map((line) => truncate(line, width));
18
+ }
19
+
20
+ const project = snapshot.project || {};
21
+ const workspace = snapshot.workspace || {};
22
+
23
+ lines.push(`Project: ${project.name || 'Unknown'} | FIRE version: ${project.fireVersion || snapshot.version || '0.0.0'}`);
24
+ lines.push(`Workspace: ${workspace.type || 'unknown'} / ${workspace.structure || 'unknown'} | autonomy: ${workspace.autonomyBias || 'unknown'} | run scope pref: ${workspace.runScopePreference || 'unknown'}`);
25
+ lines.push('');
26
+
27
+ lines.push('Intent Summary');
28
+ lines.push(` total: ${snapshot.stats.totalIntents} | completed: ${snapshot.stats.completedIntents} | in_progress: ${snapshot.stats.inProgressIntents} | pending: ${snapshot.stats.pendingIntents} | blocked: ${snapshot.stats.blockedIntents}`);
29
+ lines.push('');
30
+
31
+ lines.push('Work Item Summary');
32
+ lines.push(` total: ${snapshot.stats.totalWorkItems} | completed: ${snapshot.stats.completedWorkItems} | in_progress: ${snapshot.stats.inProgressWorkItems} | pending: ${snapshot.stats.pendingWorkItems} | blocked: ${snapshot.stats.blockedWorkItems}`);
33
+ lines.push('');
34
+
35
+ const standardSet = new Set((snapshot.standards || []).map((item) => item.type));
36
+ lines.push('Standards');
37
+ for (const type of STANDARD_TYPES) {
38
+ const marker = standardSet.has(type) ? '[x]' : '[ ]';
39
+ lines.push(` ${marker} ${type}.md`);
40
+ }
41
+
42
+ lines.push('');
43
+ lines.push('Top Intents');
44
+
45
+ const intents = (snapshot.intents || []).slice(0, 6);
46
+ if (intents.length === 0) {
47
+ lines.push(' - none');
48
+ } else {
49
+ for (const intent of intents) {
50
+ const totalWorkItems = (intent.workItems || []).length;
51
+ const completedWorkItems = (intent.workItems || []).filter((item) => item.status === 'completed').length;
52
+ lines.push(` - ${intent.id}: ${intent.status} (${completedWorkItems}/${totalWorkItems} work items completed)`);
53
+ }
54
+ }
55
+
56
+ return lines.map((line) => truncate(line, width));
57
+ }
58
+
59
+ module.exports = {
60
+ renderOverviewViewLines
61
+ };
@@ -0,0 +1,98 @@
1
+ const { truncate } = require('../components/header');
2
+
3
+ function safeArray(value) {
4
+ return Array.isArray(value) ? value : [];
5
+ }
6
+
7
+ function formatRunProgress(run) {
8
+ const workItems = safeArray(run.workItems);
9
+ const total = workItems.length;
10
+ const completed = workItems.filter((item) => item.status === 'completed').length;
11
+ const inProgress = workItems.filter((item) => item.status === 'in_progress').length;
12
+ return `${completed}/${total} completed${inProgress > 0 ? `, ${inProgress} in_progress` : ''}`;
13
+ }
14
+
15
+ function renderActiveRunLines(activeRuns, width) {
16
+ const lines = ['Active Runs'];
17
+ if (!activeRuns || activeRuns.length === 0) {
18
+ lines.push(' - none');
19
+ return lines.map((line) => truncate(line, width));
20
+ }
21
+
22
+ for (const run of activeRuns) {
23
+ const currentItem = run.currentItem || 'n/a';
24
+ const artifacts = [
25
+ run.hasPlan ? 'plan' : null,
26
+ run.hasWalkthrough ? 'walkthrough' : null,
27
+ run.hasTestReport ? 'test-report' : null
28
+ ].filter(Boolean).join(', ') || 'no artifacts yet';
29
+
30
+ lines.push(` - ${run.id} [${run.scope}] current: ${currentItem}`);
31
+ lines.push(` progress: ${formatRunProgress(run)} | artifacts: ${artifacts}`);
32
+ }
33
+
34
+ return lines.map((line) => truncate(line, width));
35
+ }
36
+
37
+ function renderPendingQueueLines(pendingItems, width) {
38
+ const lines = ['Pending Queue'];
39
+ if (!pendingItems || pendingItems.length === 0) {
40
+ lines.push(' - none');
41
+ return lines.map((line) => truncate(line, width));
42
+ }
43
+
44
+ for (const item of pendingItems.slice(0, 12)) {
45
+ const deps = item.dependencies && item.dependencies.length > 0
46
+ ? ` deps:${item.dependencies.join(',')}`
47
+ : '';
48
+ lines.push(` - ${item.id} (${item.mode}/${item.complexity}) in ${item.intentTitle}${deps}`);
49
+ }
50
+
51
+ if (pendingItems.length > 12) {
52
+ lines.push(` ... ${pendingItems.length - 12} more pending items`);
53
+ }
54
+
55
+ return lines.map((line) => truncate(line, width));
56
+ }
57
+
58
+ function renderCompletedRunLines(completedRuns, width) {
59
+ const lines = ['Recent Completed Runs'];
60
+ if (!completedRuns || completedRuns.length === 0) {
61
+ lines.push(' - none');
62
+ return lines.map((line) => truncate(line, width));
63
+ }
64
+
65
+ for (const run of completedRuns.slice(0, 5)) {
66
+ const completedAt = run.completedAt || 'unknown';
67
+ lines.push(` - ${run.id} [${run.scope}] completed: ${completedAt} | ${formatRunProgress(run)}`);
68
+ }
69
+
70
+ return lines.map((line) => truncate(line, width));
71
+ }
72
+
73
+ function renderRunsViewLines(snapshot, runFilter, width) {
74
+ const lines = [];
75
+
76
+ if (!snapshot?.initialized) {
77
+ lines.push('FIRE detected, but state.yaml is not initialized yet.');
78
+ lines.push('Initialize FIRE project context, then the dashboard will auto-populate.');
79
+ return lines.map((line) => truncate(line, width));
80
+ }
81
+
82
+ if (runFilter !== 'completed') {
83
+ lines.push(...renderActiveRunLines(snapshot.activeRuns, width));
84
+ lines.push('');
85
+ lines.push(...renderPendingQueueLines(snapshot.pendingItems, width));
86
+ lines.push('');
87
+ }
88
+
89
+ if (runFilter !== 'active') {
90
+ lines.push(...renderCompletedRunLines(snapshot.completedRuns, width));
91
+ }
92
+
93
+ return lines.map((line) => truncate(line, width));
94
+ }
95
+
96
+ module.exports = {
97
+ renderRunsViewLines
98
+ };
@@ -1,4 +1,8 @@
1
1
  const ToolInstaller = require('./ToolInstaller');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const CLIUtils = require('../cli-utils');
5
+ const { theme } = CLIUtils;
2
6
 
3
7
  class CodexInstaller extends ToolInstaller {
4
8
  get key() {
@@ -10,12 +14,79 @@ class CodexInstaller extends ToolInstaller {
10
14
  }
11
15
 
12
16
  get commandsDir() {
13
- return '.codex';
17
+ return path.join('.codex', 'skills');
14
18
  }
15
19
 
16
20
  get detectPath() {
17
21
  return '.codex';
18
22
  }
23
+
24
+ async installCommands(flowPath, config) {
25
+ const targetSkillsDir = this.commandsDir;
26
+ console.log(theme.dim(` Installing skills to ${targetSkillsDir}/...`));
27
+ await fs.ensureDir(targetSkillsDir);
28
+
29
+ const commandsSourceDir = path.join(flowPath, 'commands');
30
+ if (!await fs.pathExists(commandsSourceDir)) {
31
+ console.log(theme.warning(` No commands folder found at ${commandsSourceDir}`));
32
+ return [];
33
+ }
34
+
35
+ const commandFiles = await fs.readdir(commandsSourceDir);
36
+ const installedFiles = [];
37
+
38
+ for (const cmdFile of commandFiles) {
39
+ if (!cmdFile.endsWith('.md')) continue;
40
+
41
+ try {
42
+ const sourcePath = path.join(commandsSourceDir, cmdFile);
43
+ const content = await fs.readFile(sourcePath, 'utf8');
44
+ const commandName = cmdFile.replace('.md', '');
45
+ const prefix = (config && config.command && config.command.prefix) ? `${config.command.prefix}-` : '';
46
+ const skillName = `specsmd-${prefix}${commandName}`;
47
+
48
+ const { description, body } = this.parseFrontmatter(content);
49
+
50
+ // Build SKILL.md with Codex frontmatter
51
+ const skillContent = [
52
+ '---',
53
+ `name: ${skillName}`,
54
+ `description: "${description || 'specsmd agent'}"`,
55
+ '---',
56
+ '',
57
+ body
58
+ ].join('\n');
59
+
60
+ // Write SKILL.md
61
+ const skillDir = path.join(targetSkillsDir, skillName);
62
+ await fs.ensureDir(skillDir);
63
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent, 'utf8');
64
+
65
+ installedFiles.push(skillName);
66
+ } catch (err) {
67
+ console.log(theme.warning(` Failed to install ${cmdFile}: ${err.message}`));
68
+ }
69
+ }
70
+
71
+ CLIUtils.displayStatus('', `Installed ${installedFiles.length} skills for ${this.name}`, 'success');
72
+ return installedFiles;
73
+ }
74
+
75
+ /**
76
+ * Parse YAML frontmatter from a markdown file
77
+ */
78
+ parseFrontmatter(content) {
79
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
80
+ if (!match) return { description: '', body: content };
81
+
82
+ const frontmatter = match[1];
83
+ const body = match[2];
84
+ const descMatch = frontmatter.match(/description:\s*["']?(.+?)["']?\s*$/m);
85
+ return {
86
+ description: descMatch ? descMatch[1] : '',
87
+ body: body.trim()
88
+ };
89
+ }
19
90
  }
20
91
 
21
92
  module.exports = CodexInstaller;