specflow-cc 1.12.0 → 1.14.0
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/CHANGELOG.md +74 -0
- package/README.md +22 -8
- package/agents/impl-reviewer.md +8 -0
- package/agents/sf-spec-executor-orchestrator.md +8 -0
- package/agents/spec-auditor.md +8 -0
- package/agents/spec-creator.md +13 -2
- package/agents/spec-executor-orchestrator.md +21 -0
- package/agents/spec-executor.md +8 -0
- package/agents/spec-reviser.md +8 -0
- package/agents/spec-splitter.md +11 -1
- package/bin/install.js +20 -0
- package/bin/lib/config.cjs +91 -0
- package/bin/lib/core.cjs +120 -0
- package/bin/lib/spec.cjs +130 -0
- package/bin/lib/state.cjs +241 -0
- package/bin/lib/verify.cjs +117 -0
- package/bin/sf-tools.cjs +103 -0
- package/commands/sf/audit.md +11 -69
- package/commands/sf/autopilot.md +601 -0
- package/commands/sf/done.md +32 -71
- package/commands/sf/health.md +220 -0
- package/commands/sf/help.md +16 -0
- package/commands/sf/review.md +11 -69
- package/commands/sf/revise.md +4 -7
- package/commands/sf/run.md +11 -69
- package/commands/sf/split.md +14 -3
- package/commands/sf/validate.md +154 -0
- package/hooks/context-monitor.js +121 -0
- package/hooks/statusline.js +17 -0
- package/package.json +1 -1
package/bin/lib/spec.cjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/lib/spec.cjs — Spec operations
|
|
3
|
+
*
|
|
4
|
+
* Exports: cmdSpecLoad(), cmdSpecList(), cmdSpecNextId()
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { output, error, safeReadFile, parseFrontmatter } = require('./core.cjs');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load and parse a spec file.
|
|
15
|
+
* @param {string} cwd - Working directory
|
|
16
|
+
* @param {string} id - Spec ID (e.g., "SPEC-007")
|
|
17
|
+
* @param {boolean} raw - Output raw string
|
|
18
|
+
*/
|
|
19
|
+
function cmdSpecLoad(cwd, id, raw) {
|
|
20
|
+
if (!id) {
|
|
21
|
+
error('Missing spec ID. Usage: spec load <id>');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const specPath = path.join(cwd, '.specflow', 'specs', id + '.md');
|
|
25
|
+
const content = safeReadFile(specPath);
|
|
26
|
+
|
|
27
|
+
if (!content) {
|
|
28
|
+
error('Spec not found: ' + specPath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parsed = parseFrontmatter(content);
|
|
32
|
+
|
|
33
|
+
output({
|
|
34
|
+
frontmatter: parsed.frontmatter,
|
|
35
|
+
body: parsed.body,
|
|
36
|
+
}, raw, parsed.body);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* List all specs in .specflow/specs/.
|
|
41
|
+
* Extracts id, title, status, complexity, priority from each.
|
|
42
|
+
* Title is parsed from the first # heading in the body.
|
|
43
|
+
* @param {string} cwd - Working directory
|
|
44
|
+
* @param {boolean} raw - Output raw string
|
|
45
|
+
*/
|
|
46
|
+
function cmdSpecList(cwd, raw) {
|
|
47
|
+
const specsDir = path.join(cwd, '.specflow', 'specs');
|
|
48
|
+
|
|
49
|
+
let files;
|
|
50
|
+
try {
|
|
51
|
+
files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md')).sort();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
error('Cannot read specs directory: ' + specsDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const specs = [];
|
|
57
|
+
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const content = safeReadFile(path.join(specsDir, file));
|
|
60
|
+
if (!content) continue;
|
|
61
|
+
|
|
62
|
+
const parsed = parseFrontmatter(content);
|
|
63
|
+
const fm = parsed.frontmatter;
|
|
64
|
+
|
|
65
|
+
// Extract title from first # heading in body
|
|
66
|
+
let title = '';
|
|
67
|
+
const titleMatch = parsed.body.match(/^#\s+(.+)$/m);
|
|
68
|
+
if (titleMatch) {
|
|
69
|
+
title = titleMatch[1].trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
specs.push({
|
|
73
|
+
id: fm.id || file.replace('.md', ''),
|
|
74
|
+
title: title,
|
|
75
|
+
status: fm.status || '',
|
|
76
|
+
complexity: fm.complexity || '',
|
|
77
|
+
priority: fm.priority || '',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
output(specs, raw, specs.map(s => s.id).join('\n'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Calculate the next available SPEC-XXX number.
|
|
86
|
+
* Scans both .specflow/specs/ and .specflow/archive/ directories.
|
|
87
|
+
* Ignores non-numeric spec IDs (e.g., SPEC-GSD-A).
|
|
88
|
+
* @param {string} cwd - Working directory
|
|
89
|
+
* @param {boolean} raw - Output raw string
|
|
90
|
+
*/
|
|
91
|
+
function cmdSpecNextId(cwd, raw) {
|
|
92
|
+
const dirs = [
|
|
93
|
+
path.join(cwd, '.specflow', 'specs'),
|
|
94
|
+
path.join(cwd, '.specflow', 'archive'),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
let maxNum = 0;
|
|
98
|
+
|
|
99
|
+
for (const dir of dirs) {
|
|
100
|
+
let files;
|
|
101
|
+
try {
|
|
102
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
continue; // directory may not exist
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
// Match SPEC-NNN pattern (numeric only)
|
|
109
|
+
const match = file.match(/^SPEC-(\d+)\.md$/);
|
|
110
|
+
if (match) {
|
|
111
|
+
const num = parseInt(match[1], 10);
|
|
112
|
+
if (num > maxNum) maxNum = num;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const nextNumber = maxNum + 1;
|
|
118
|
+
const nextId = 'SPEC-' + String(nextNumber).padStart(3, '0');
|
|
119
|
+
|
|
120
|
+
output({
|
|
121
|
+
next_id: nextId,
|
|
122
|
+
next_number: nextNumber,
|
|
123
|
+
}, raw, nextId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
cmdSpecLoad,
|
|
128
|
+
cmdSpecList,
|
|
129
|
+
cmdSpecNextId,
|
|
130
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/lib/state.cjs — STATE.md CRUD operations
|
|
3
|
+
*
|
|
4
|
+
* Exports: cmdStateGet(), cmdStateSetActive(), cmdQueueNext()
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { output, error, safeReadFile } = require('./core.cjs');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract a bold-field value from STATE.md content.
|
|
15
|
+
* Matches patterns like: **Field:** value
|
|
16
|
+
* @param {string} content - STATE.md content
|
|
17
|
+
* @param {string} field - Field name (e.g., "Status")
|
|
18
|
+
* @returns {string|null}
|
|
19
|
+
*/
|
|
20
|
+
function extractBoldField(content, field) {
|
|
21
|
+
const regex = new RegExp(`\\*\\*${field}:\\*\\*\\s*(.+)`, 'i');
|
|
22
|
+
const match = content.match(regex);
|
|
23
|
+
return match ? match[1].trim() : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract the active spec ID from STATE.md.
|
|
28
|
+
* The spec ID is on the line immediately after "## Active Specification".
|
|
29
|
+
* @param {string} content - STATE.md content
|
|
30
|
+
* @returns {string|null}
|
|
31
|
+
*/
|
|
32
|
+
function extractActiveSpec(content) {
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
for (let i = 0; i < lines.length; i++) {
|
|
35
|
+
if (lines[i].trim() === '## Active Specification') {
|
|
36
|
+
// Next non-empty line has the spec ID
|
|
37
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
38
|
+
const line = lines[j].trim();
|
|
39
|
+
if (line && !line.startsWith('**') && !line.startsWith('#')) {
|
|
40
|
+
return line;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse the queue table from STATE.md.
|
|
50
|
+
* Expects pipe-delimited markdown table with columns:
|
|
51
|
+
* Priority | ID | Title | Status | Complexity | Depends On
|
|
52
|
+
* @param {string} content - STATE.md content
|
|
53
|
+
* @returns {Array<Object>}
|
|
54
|
+
*/
|
|
55
|
+
function parseQueueTable(content) {
|
|
56
|
+
const lines = content.split('\n');
|
|
57
|
+
const queue = [];
|
|
58
|
+
let inQueue = false;
|
|
59
|
+
let headerFound = false;
|
|
60
|
+
let separatorFound = false;
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
|
|
65
|
+
if (trimmed === '## Queue') {
|
|
66
|
+
inQueue = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (inQueue && trimmed.startsWith('##') && trimmed !== '## Queue') {
|
|
71
|
+
break; // next section
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!inQueue) continue;
|
|
75
|
+
|
|
76
|
+
if (trimmed.startsWith('|') && !headerFound) {
|
|
77
|
+
// Check if this is the header row
|
|
78
|
+
if (trimmed.toLowerCase().includes('priority') && trimmed.toLowerCase().includes('id')) {
|
|
79
|
+
headerFound = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (headerFound && !separatorFound && trimmed.startsWith('|') && trimmed.includes('---')) {
|
|
85
|
+
separatorFound = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (headerFound && separatorFound && trimmed.startsWith('|')) {
|
|
90
|
+
const cells = trimmed.split('|').map(c => c.trim()).filter(c => c !== '');
|
|
91
|
+
if (cells.length >= 4) {
|
|
92
|
+
queue.push({
|
|
93
|
+
priority: cells[0] || '',
|
|
94
|
+
id: cells[1] || '',
|
|
95
|
+
title: cells[2] || '',
|
|
96
|
+
status: cells[3] || '',
|
|
97
|
+
complexity: cells[4] || '',
|
|
98
|
+
depends_on: cells[5] || '',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return queue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get current active spec, status, and next step from STATE.md.
|
|
109
|
+
* @param {string} cwd - Working directory
|
|
110
|
+
* @param {boolean} raw - Output raw string
|
|
111
|
+
*/
|
|
112
|
+
function cmdStateGet(cwd, raw) {
|
|
113
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
114
|
+
const content = safeReadFile(statePath);
|
|
115
|
+
|
|
116
|
+
if (!content) {
|
|
117
|
+
error('STATE.md not found at ' + statePath);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const activeSpec = extractActiveSpec(content);
|
|
121
|
+
const status = extractBoldField(content, 'Status');
|
|
122
|
+
const nextStep = extractBoldField(content, 'Next Step');
|
|
123
|
+
|
|
124
|
+
const result = {
|
|
125
|
+
active_spec: activeSpec || null,
|
|
126
|
+
status: status || null,
|
|
127
|
+
next_step: nextStep || null,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
output(result, raw, result.active_spec);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Update active spec, status, and optionally next step in STATE.md.
|
|
135
|
+
* @param {string} cwd - Working directory
|
|
136
|
+
* @param {string} id - Spec ID (e.g., "SPEC-007")
|
|
137
|
+
* @param {string} status - New status
|
|
138
|
+
* @param {string} [nextStep] - Optional new next step
|
|
139
|
+
* @param {boolean} raw - Output raw string
|
|
140
|
+
*/
|
|
141
|
+
function cmdStateSetActive(cwd, id, status, nextStep, raw) {
|
|
142
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
143
|
+
const content = safeReadFile(statePath);
|
|
144
|
+
|
|
145
|
+
if (!content) {
|
|
146
|
+
error('STATE.md not found at ' + statePath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lines = content.split('\n');
|
|
150
|
+
const result = [];
|
|
151
|
+
let inActiveSection = false;
|
|
152
|
+
let specIdReplaced = false;
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < lines.length; i++) {
|
|
155
|
+
const trimmed = lines[i].trim();
|
|
156
|
+
|
|
157
|
+
if (trimmed === '## Active Specification') {
|
|
158
|
+
inActiveSection = true;
|
|
159
|
+
result.push(lines[i]);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (inActiveSection && trimmed.startsWith('## ') && trimmed !== '## Active Specification') {
|
|
164
|
+
inActiveSection = false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (inActiveSection && !specIdReplaced && trimmed && !trimmed.startsWith('**') && !trimmed.startsWith('#')) {
|
|
168
|
+
// This is the spec ID line — replace it
|
|
169
|
+
result.push(id);
|
|
170
|
+
specIdReplaced = true;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (inActiveSection && trimmed.startsWith('**Status:**')) {
|
|
175
|
+
result.push('**Status:** ' + status);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (inActiveSection && trimmed.startsWith('**Next Step:**')) {
|
|
180
|
+
if (nextStep !== undefined && nextStep !== null) {
|
|
181
|
+
result.push('**Next Step:** ' + nextStep);
|
|
182
|
+
} else {
|
|
183
|
+
result.push(lines[i]); // preserve existing
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
result.push(lines[i]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fs.writeFileSync(statePath, result.join('\n'), 'utf8');
|
|
192
|
+
|
|
193
|
+
const resultObj = {
|
|
194
|
+
updated: true,
|
|
195
|
+
active_spec: id,
|
|
196
|
+
status: status,
|
|
197
|
+
next_step: nextStep || extractBoldField(result.join('\n'), 'Next Step'),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
output(resultObj, raw, 'updated');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the first actionable spec from the queue.
|
|
205
|
+
* "Actionable" = status is not "done" or "complete".
|
|
206
|
+
* @param {string} cwd - Working directory
|
|
207
|
+
* @param {boolean} raw - Output raw string
|
|
208
|
+
*/
|
|
209
|
+
function cmdQueueNext(cwd, raw) {
|
|
210
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
211
|
+
const content = safeReadFile(statePath);
|
|
212
|
+
|
|
213
|
+
if (!content) {
|
|
214
|
+
error('STATE.md not found at ' + statePath);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const queue = parseQueueTable(content);
|
|
218
|
+
|
|
219
|
+
const next = queue.find(entry => {
|
|
220
|
+
const s = entry.status.toLowerCase();
|
|
221
|
+
return s !== 'done' && s !== 'complete';
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (next) {
|
|
225
|
+
output({
|
|
226
|
+
id: next.id,
|
|
227
|
+
title: next.title,
|
|
228
|
+
status: next.status,
|
|
229
|
+
priority: next.priority,
|
|
230
|
+
}, raw, next.id);
|
|
231
|
+
} else {
|
|
232
|
+
output({ id: null }, raw, '');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
cmdStateGet,
|
|
238
|
+
cmdStateSetActive,
|
|
239
|
+
cmdQueueNext,
|
|
240
|
+
extractActiveSpec,
|
|
241
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/lib/verify.cjs — Structure verification checks
|
|
3
|
+
*
|
|
4
|
+
* Exports: cmdVerifyStructure()
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { output, safeReadFile, parseFrontmatter } = require('./core.cjs');
|
|
12
|
+
const { extractActiveSpec } = require('./state.cjs');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Verify .specflow/ directory structure and integrity.
|
|
16
|
+
* Performs 6 read-only checks. Does NOT repair issues.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} cwd - Working directory
|
|
19
|
+
* @param {boolean} raw - Output raw string
|
|
20
|
+
*/
|
|
21
|
+
function cmdVerifyStructure(cwd, raw) {
|
|
22
|
+
const checks = [];
|
|
23
|
+
const errors = [];
|
|
24
|
+
|
|
25
|
+
// Check 1: .specflow/ directory exists
|
|
26
|
+
const specflowDir = path.join(cwd, '.specflow');
|
|
27
|
+
const specflowExists = fs.existsSync(specflowDir) && fs.statSync(specflowDir).isDirectory();
|
|
28
|
+
checks.push({ name: '.specflow/ directory exists', passed: specflowExists });
|
|
29
|
+
if (!specflowExists) {
|
|
30
|
+
errors.push('.specflow/ directory not found');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check 2: STATE.md exists and has "Active Specification" section
|
|
34
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
35
|
+
const stateContent = safeReadFile(statePath);
|
|
36
|
+
const stateHasSection = stateContent ? stateContent.includes('## Active Specification') : false;
|
|
37
|
+
const stateValid = !!stateContent && stateHasSection;
|
|
38
|
+
checks.push({ name: 'STATE.md exists with Active Specification section', passed: stateValid });
|
|
39
|
+
if (!stateContent) {
|
|
40
|
+
errors.push('STATE.md not found');
|
|
41
|
+
} else if (!stateHasSection) {
|
|
42
|
+
errors.push('STATE.md missing "## Active Specification" section');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check 3: specs/ directory exists
|
|
46
|
+
const specsDir = path.join(cwd, '.specflow', 'specs');
|
|
47
|
+
const specsDirExists = fs.existsSync(specsDir) && fs.statSync(specsDir).isDirectory();
|
|
48
|
+
checks.push({ name: '.specflow/specs/ directory exists', passed: specsDirExists });
|
|
49
|
+
if (!specsDirExists) {
|
|
50
|
+
errors.push('.specflow/specs/ directory not found');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check 4: archive/ directory exists
|
|
54
|
+
const archiveDir = path.join(cwd, '.specflow', 'archive');
|
|
55
|
+
const archiveDirExists = fs.existsSync(archiveDir) && fs.statSync(archiveDir).isDirectory();
|
|
56
|
+
checks.push({ name: '.specflow/archive/ directory exists', passed: archiveDirExists });
|
|
57
|
+
if (!archiveDirExists) {
|
|
58
|
+
errors.push('.specflow/archive/ directory not found');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check 5: Active spec referenced in STATE.md exists in specs/ (if set)
|
|
62
|
+
let activeSpecCheck = true;
|
|
63
|
+
if (stateContent) {
|
|
64
|
+
const activeSpec = extractActiveSpec(stateContent);
|
|
65
|
+
|
|
66
|
+
if (activeSpec && activeSpec !== 'None' && activeSpec !== '(none)') {
|
|
67
|
+
const activeSpecPath = path.join(specsDir, activeSpec + '.md');
|
|
68
|
+
const activeSpecExists = fs.existsSync(activeSpecPath);
|
|
69
|
+
activeSpecCheck = activeSpecExists;
|
|
70
|
+
if (!activeSpecExists) {
|
|
71
|
+
errors.push('Active spec ' + activeSpec + ' not found in specs/');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
checks.push({ name: 'Active spec exists in specs/ (if set)', passed: activeSpecCheck });
|
|
76
|
+
|
|
77
|
+
// Check 6: All specs in specs/ have valid YAML frontmatter with required fields
|
|
78
|
+
let allSpecsValid = true;
|
|
79
|
+
if (specsDirExists) {
|
|
80
|
+
let specFiles;
|
|
81
|
+
try {
|
|
82
|
+
specFiles = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
83
|
+
} catch (e) {
|
|
84
|
+
specFiles = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const file of specFiles) {
|
|
88
|
+
const content = safeReadFile(path.join(specsDir, file));
|
|
89
|
+
if (!content) {
|
|
90
|
+
allSpecsValid = false;
|
|
91
|
+
errors.push('Cannot read spec file: ' + file);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parsed = parseFrontmatter(content);
|
|
96
|
+
const fm = parsed.frontmatter;
|
|
97
|
+
const requiredFields = ['id', 'type', 'status'];
|
|
98
|
+
const missingFields = requiredFields.filter(f => !fm[f]);
|
|
99
|
+
|
|
100
|
+
if (missingFields.length > 0) {
|
|
101
|
+
allSpecsValid = false;
|
|
102
|
+
errors.push(file + ' missing required frontmatter fields: ' + missingFields.join(', '));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
allSpecsValid = false;
|
|
107
|
+
}
|
|
108
|
+
checks.push({ name: 'All specs have valid frontmatter (id, type, status)', passed: allSpecsValid });
|
|
109
|
+
|
|
110
|
+
const valid = checks.every(c => c.passed);
|
|
111
|
+
|
|
112
|
+
output({ valid, checks, errors }, raw, valid ? 'valid' : 'invalid');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
cmdVerifyStructure,
|
|
117
|
+
};
|
package/bin/sf-tools.cjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bin/sf-tools.cjs — Centralized CLI for SpecFlow operations
|
|
5
|
+
*
|
|
6
|
+
* Usage: node bin/sf-tools.cjs <command> [args...]
|
|
7
|
+
*
|
|
8
|
+
* Commands:
|
|
9
|
+
* spec load <id> Parse spec file, return frontmatter + body
|
|
10
|
+
* spec list List all specs
|
|
11
|
+
* spec next-id Next available SPEC-XXX number
|
|
12
|
+
* queue next First actionable spec from queue
|
|
13
|
+
* state get Current active spec, status, next step
|
|
14
|
+
* state set-active <id> <status> [next] Update active spec in STATE.md
|
|
15
|
+
* resolve-model <agent-type> Model for agent by current profile
|
|
16
|
+
* verify-structure Check .specflow/ integrity
|
|
17
|
+
* generate-slug <text> Text to URL-safe slug
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { output, error, generateSlug } = require('./lib/core.cjs');
|
|
23
|
+
const { cmdStateGet, cmdStateSetActive, cmdQueueNext } = require('./lib/state.cjs');
|
|
24
|
+
const { cmdSpecLoad, cmdSpecList, cmdSpecNextId } = require('./lib/spec.cjs');
|
|
25
|
+
const { cmdResolveModel } = require('./lib/config.cjs');
|
|
26
|
+
const { cmdVerifyStructure } = require('./lib/verify.cjs');
|
|
27
|
+
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const raw = args.includes('--raw');
|
|
31
|
+
const filteredArgs = args.filter(a => a !== '--raw');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Command dispatch table.
|
|
35
|
+
* Keys are "command subcommand" or just "command".
|
|
36
|
+
*/
|
|
37
|
+
const COMMANDS = {
|
|
38
|
+
'spec load': () => {
|
|
39
|
+
if (!filteredArgs[2]) error('Missing spec ID. Usage: spec load <id>');
|
|
40
|
+
cmdSpecLoad(cwd, filteredArgs[2], raw);
|
|
41
|
+
},
|
|
42
|
+
'spec list': () => cmdSpecList(cwd, raw),
|
|
43
|
+
'spec next-id': () => cmdSpecNextId(cwd, raw),
|
|
44
|
+
'queue next': () => cmdQueueNext(cwd, raw),
|
|
45
|
+
'state get': () => cmdStateGet(cwd, raw),
|
|
46
|
+
'state set-active': () => {
|
|
47
|
+
if (!filteredArgs[2] || !filteredArgs[3]) {
|
|
48
|
+
error('Missing arguments. Usage: state set-active <id> <status> [next_step]');
|
|
49
|
+
}
|
|
50
|
+
cmdStateSetActive(cwd, filteredArgs[2], filteredArgs[3], filteredArgs[4], raw);
|
|
51
|
+
},
|
|
52
|
+
'resolve-model': () => {
|
|
53
|
+
if (!filteredArgs[1]) error('Missing agent type. Usage: resolve-model <agent-type>');
|
|
54
|
+
cmdResolveModel(cwd, filteredArgs[1], raw);
|
|
55
|
+
},
|
|
56
|
+
'verify-structure': () => cmdVerifyStructure(cwd, raw),
|
|
57
|
+
'generate-slug': () => {
|
|
58
|
+
if (!filteredArgs[1]) error('Missing text. Usage: generate-slug <text>');
|
|
59
|
+
output({ slug: generateSlug(filteredArgs[1]) }, raw, generateSlug(filteredArgs[1]));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function printUsage() {
|
|
64
|
+
const usage = `sf-tools — SpecFlow CLI
|
|
65
|
+
|
|
66
|
+
Usage: node bin/sf-tools.cjs <command> [args...] [--raw]
|
|
67
|
+
|
|
68
|
+
Commands:
|
|
69
|
+
spec load <id> Parse spec file, return frontmatter + body
|
|
70
|
+
spec list List all specs from .specflow/specs/
|
|
71
|
+
spec next-id Next available SPEC-XXX number
|
|
72
|
+
queue next First actionable spec from queue table
|
|
73
|
+
state get Current active spec, status, next step
|
|
74
|
+
state set-active <id> <status> [next] Update active spec, status, next step
|
|
75
|
+
resolve-model <agent-type> Resolve model for agent by current profile
|
|
76
|
+
verify-structure Check .specflow/ directory integrity
|
|
77
|
+
generate-slug <text> Convert text to URL-safe slug
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--raw Output plain string instead of JSON
|
|
81
|
+
|
|
82
|
+
All commands output JSON to stdout. Errors go to stderr with exit code 1.
|
|
83
|
+
`;
|
|
84
|
+
process.stdout.write(usage);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Main dispatch
|
|
88
|
+
if (filteredArgs.length === 0) {
|
|
89
|
+
printUsage();
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Try two-word command first, then single-word
|
|
94
|
+
const twoWord = filteredArgs[0] + ' ' + (filteredArgs[1] || '');
|
|
95
|
+
const oneWord = filteredArgs[0];
|
|
96
|
+
|
|
97
|
+
if (COMMANDS[twoWord]) {
|
|
98
|
+
COMMANDS[twoWord]();
|
|
99
|
+
} else if (COMMANDS[oneWord]) {
|
|
100
|
+
COMMANDS[oneWord]();
|
|
101
|
+
} else {
|
|
102
|
+
error('Unknown command: ' + filteredArgs.join(' ') + '. Run without arguments for usage help.');
|
|
103
|
+
}
|
package/commands/sf/audit.md
CHANGED
|
@@ -242,75 +242,17 @@ The agent will:
|
|
|
242
242
|
|
|
243
243
|
After the agent updates STATE.md, check if rotation is needed:
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
RECENT_DECISIONS=$(echo "$DECISIONS" | tail -5)
|
|
257
|
-
OLD_DECISION_COUNT=$((DECISION_COUNT - 5))
|
|
258
|
-
OLD_DECISIONS=$(echo "$DECISIONS" | head -n $OLD_DECISION_COUNT)
|
|
259
|
-
|
|
260
|
-
# Create or append to archive
|
|
261
|
-
if [ ! -f .specflow/DECISIONS_ARCHIVE.md ]; then
|
|
262
|
-
cat > .specflow/DECISIONS_ARCHIVE.md << 'EOF'
|
|
263
|
-
# SpecFlow Decisions Archive
|
|
264
|
-
|
|
265
|
-
Historical decisions rotated from STATE.md to maintain compactness.
|
|
266
|
-
|
|
267
|
-
## Archived Decisions
|
|
268
|
-
|
|
269
|
-
| Date | Decision |
|
|
270
|
-
|------|----------|
|
|
271
|
-
EOF
|
|
272
|
-
fi
|
|
273
|
-
|
|
274
|
-
# Write old decisions to temp file for awk to read (awk -v cannot handle multiline strings)
|
|
275
|
-
TEMP_OLD=$(mktemp)
|
|
276
|
-
echo "$OLD_DECISIONS" > "$TEMP_OLD"
|
|
277
|
-
|
|
278
|
-
# Append old decisions to archive (insert after table header)
|
|
279
|
-
TEMP_ARCHIVE=$(mktemp)
|
|
280
|
-
awk -v oldfile="$TEMP_OLD" '
|
|
281
|
-
/^\| Date \| Decision \|$/ { print; getline; print; while ((getline line < oldfile) > 0) print line; close(oldfile); next }
|
|
282
|
-
{print}
|
|
283
|
-
' .specflow/DECISIONS_ARCHIVE.md > "$TEMP_ARCHIVE"
|
|
284
|
-
mv "$TEMP_ARCHIVE" .specflow/DECISIONS_ARCHIVE.md
|
|
285
|
-
rm -f "$TEMP_OLD"
|
|
286
|
-
|
|
287
|
-
# Write recent decisions to temp file for awk to read
|
|
288
|
-
TEMP_RECENT=$(mktemp)
|
|
289
|
-
echo "$RECENT_DECISIONS" > "$TEMP_RECENT"
|
|
290
|
-
|
|
291
|
-
# Update STATE.md with only recent decisions
|
|
292
|
-
TEMP_STATE=$(mktemp)
|
|
293
|
-
awk -v recentfile="$TEMP_RECENT" '
|
|
294
|
-
/^## Decisions$/ {
|
|
295
|
-
print
|
|
296
|
-
print ""
|
|
297
|
-
print "| Date | Decision |"
|
|
298
|
-
print "|------|----------|"
|
|
299
|
-
while ((getline line < recentfile) > 0) print line
|
|
300
|
-
close(recentfile)
|
|
301
|
-
in_decisions=1
|
|
302
|
-
next
|
|
303
|
-
}
|
|
304
|
-
/^## / && in_decisions { in_decisions=0 }
|
|
305
|
-
!in_decisions || !/^\|/ { print }
|
|
306
|
-
' .specflow/STATE.md > "$TEMP_STATE"
|
|
307
|
-
mv "$TEMP_STATE" .specflow/STATE.md
|
|
308
|
-
rm -f "$TEMP_RECENT"
|
|
309
|
-
|
|
310
|
-
echo "Rotated $(echo "$OLD_DECISIONS" | grep -c '^|') old decisions to DECISIONS_ARCHIVE.md"
|
|
311
|
-
fi
|
|
312
|
-
fi
|
|
313
|
-
```
|
|
245
|
+
1. Use the Read tool to read `.specflow/STATE.md` and count total lines
|
|
246
|
+
2. If total lines <= 100, no action needed
|
|
247
|
+
3. If total lines > 100:
|
|
248
|
+
a. Read the `## Decisions` section and extract all decision rows (lines matching `| YYYY-`)
|
|
249
|
+
b. Count decision rows. If <= 7, no rotation needed
|
|
250
|
+
c. If > 7 decisions:
|
|
251
|
+
- Identify the 5 most recent decisions (last 5 rows) -- these STAY
|
|
252
|
+
- Identify older decisions (all rows except last 5) -- these MOVE to archive
|
|
253
|
+
- Read `.specflow/DECISIONS_ARCHIVE.md` (create with template if missing)
|
|
254
|
+
- Write updated DECISIONS_ARCHIVE.md: insert old decisions after the table header row
|
|
255
|
+
- Write updated STATE.md: replace Decisions section content with only the 5 most recent decisions
|
|
314
256
|
|
|
315
257
|
## Step 7: Display Result
|
|
316
258
|
|