specflow-cc 1.19.0 → 1.20.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 +33 -0
- package/README.md +9 -1
- package/bin/lib/core.cjs +26 -0
- package/bin/lib/lock.cjs +213 -0
- package/bin/lib/migrate-state.cjs +242 -0
- package/bin/lib/resolve.cjs +171 -0
- package/bin/lib/state.cjs +350 -52
- package/bin/sf-tools.cjs +54 -6
- package/commands/sf/audit.md +24 -12
- package/commands/sf/autopilot.md +39 -21
- package/commands/sf/discuss.md +4 -3
- package/commands/sf/done.md +23 -12
- package/commands/sf/fix.md +22 -10
- package/commands/sf/health.md +17 -4
- package/commands/sf/help.md +6 -0
- package/commands/sf/pause.md +18 -11
- package/commands/sf/review.md +25 -10
- package/commands/sf/revise.md +22 -10
- package/commands/sf/run.md +22 -11
- package/commands/sf/show.md +17 -10
- package/commands/sf/split.md +24 -15
- package/commands/sf/status.md +19 -9
- package/commands/sf/verify.md +22 -11
- package/package.json +1 -1
- package/templates/state.md +6 -4
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/lib/resolve.cjs — Active spec resolver helper
|
|
3
|
+
*
|
|
4
|
+
* Exports: resolveActiveSpec(specId?, stateMdContent)
|
|
5
|
+
*
|
|
6
|
+
* Reads the `## Active Specifications` table from STATE.md and resolves
|
|
7
|
+
* which spec the caller intends to act on.
|
|
8
|
+
*
|
|
9
|
+
* Resolution logic:
|
|
10
|
+
* - specId provided AND in table → {action:'use', id:specId}
|
|
11
|
+
* - specId provided AND NOT in table → {action:'error', code:'SPEC_NOT_ACTIVE', id:specId}
|
|
12
|
+
* - specId omitted AND table empty → {action:'error', code:'NO_ACTIVE_SPEC'}
|
|
13
|
+
* - specId omitted AND exactly 1 row → {action:'use', id:rowId}
|
|
14
|
+
* - specId omitted AND >1 rows → {action:'ask', options:[{id, title, status}, ...]}
|
|
15
|
+
*
|
|
16
|
+
* The `options[].title` field is loaded from the spec's frontmatter H1 or `title:`
|
|
17
|
+
* field at resolution time. If the spec file cannot be read, SPEC-ID is used as
|
|
18
|
+
* the fallback title (per Constraint: tolerate missing spec files gracefully).
|
|
19
|
+
* At most N spec files are read (N = number of active rows; no directory scan).
|
|
20
|
+
*
|
|
21
|
+
* CLI usage (preferred — command files must use this, not require() directly):
|
|
22
|
+
* node bin/sf-tools.cjs state resolve [SPEC-ID]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the `## Active Specifications` table from STATE.md content.
|
|
32
|
+
* Returns an array of {id, status, nextStep} objects.
|
|
33
|
+
* Returns empty array if section or table is missing.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} content - STATE.md file content
|
|
36
|
+
* @returns {Array<{id: string, status: string, nextStep: string}>}
|
|
37
|
+
*/
|
|
38
|
+
function parseActiveSpecsTable(content) {
|
|
39
|
+
if (!content) return [];
|
|
40
|
+
|
|
41
|
+
const lines = content.split('\n');
|
|
42
|
+
const rows = [];
|
|
43
|
+
let inSection = false;
|
|
44
|
+
let headerFound = false;
|
|
45
|
+
let separatorFound = false;
|
|
46
|
+
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
|
|
50
|
+
// Enter section
|
|
51
|
+
if (trimmed === '## Active Specifications') {
|
|
52
|
+
inSection = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Exit section on next ## heading
|
|
57
|
+
if (inSection && trimmed.startsWith('## ') && trimmed !== '## Active Specifications') {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!inSection) continue;
|
|
62
|
+
|
|
63
|
+
// Table header row
|
|
64
|
+
if (!headerFound && trimmed.startsWith('|')) {
|
|
65
|
+
const lower = trimmed.toLowerCase();
|
|
66
|
+
if (lower.includes('spec-id') || lower.includes('spec id')) {
|
|
67
|
+
headerFound = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Separator row
|
|
73
|
+
if (headerFound && !separatorFound && trimmed.startsWith('|') && trimmed.includes('---')) {
|
|
74
|
+
separatorFound = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Data rows
|
|
79
|
+
if (headerFound && separatorFound && trimmed.startsWith('|')) {
|
|
80
|
+
const cells = trimmed.split('|').map(c => c.trim()).filter(c => c !== '');
|
|
81
|
+
if (cells.length >= 1) {
|
|
82
|
+
const id = cells[0] || '';
|
|
83
|
+
const status = cells[1] || '';
|
|
84
|
+
const nextStep = cells[2] || '';
|
|
85
|
+
// Skip empty placeholder rows (e.g. "| — | — | — |")
|
|
86
|
+
if (id && id !== '—' && id !== '-' && /^SPEC-/i.test(id)) {
|
|
87
|
+
rows.push({ id, status, nextStep });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return rows;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attempt to read a spec's title from its frontmatter or first H1.
|
|
98
|
+
* Falls back to the spec ID if the file cannot be read.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} cwd - Working directory
|
|
101
|
+
* @param {string} specId - e.g. "SPEC-007"
|
|
102
|
+
* @returns {string} Title or specId as fallback
|
|
103
|
+
*/
|
|
104
|
+
function readSpecTitle(cwd, specId) {
|
|
105
|
+
try {
|
|
106
|
+
const specPath = path.join(cwd, '.specflow', 'specs', specId + '.md');
|
|
107
|
+
const content = fs.readFileSync(specPath, 'utf8');
|
|
108
|
+
|
|
109
|
+
// Try frontmatter title: field
|
|
110
|
+
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
111
|
+
if (frontmatterMatch) {
|
|
112
|
+
const yaml = frontmatterMatch[1];
|
|
113
|
+
const titleLine = yaml.split('\n').find(l => l.trim().startsWith('title:'));
|
|
114
|
+
if (titleLine) {
|
|
115
|
+
const title = titleLine.replace(/^title:\s*/, '').trim().replace(/^["']|["']$/g, '');
|
|
116
|
+
if (title) return title;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Try first H1
|
|
121
|
+
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
122
|
+
if (h1Match) return h1Match[1].trim();
|
|
123
|
+
|
|
124
|
+
return specId;
|
|
125
|
+
} catch (_) {
|
|
126
|
+
return specId;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the active spec to act on.
|
|
132
|
+
*
|
|
133
|
+
* @param {string|undefined} specId - Optional explicit SPEC-ID argument
|
|
134
|
+
* @param {string} stateMdContent - Contents of .specflow/STATE.md
|
|
135
|
+
* @param {string} [cwd] - Working directory (for spec title lookups; defaults to process.cwd())
|
|
136
|
+
* @returns {{action: string, id?: string, code?: string, options?: Array}}
|
|
137
|
+
*/
|
|
138
|
+
function resolveActiveSpec(specId, stateMdContent, cwd) {
|
|
139
|
+
const workDir = cwd || process.cwd();
|
|
140
|
+
const rows = parseActiveSpecsTable(stateMdContent);
|
|
141
|
+
|
|
142
|
+
if (specId) {
|
|
143
|
+
// Explicit ID provided
|
|
144
|
+
const found = rows.find(r => r.id === specId);
|
|
145
|
+
if (found) {
|
|
146
|
+
return { action: 'use', id: specId };
|
|
147
|
+
} else {
|
|
148
|
+
return { action: 'error', code: 'SPEC_NOT_ACTIVE', id: specId };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// No explicit ID
|
|
153
|
+
if (rows.length === 0) {
|
|
154
|
+
return { action: 'error', code: 'NO_ACTIVE_SPEC' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (rows.length === 1) {
|
|
158
|
+
return { action: 'use', id: rows[0].id };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Multiple active specs — need disambiguation
|
|
162
|
+
const options = rows.map(r => ({
|
|
163
|
+
id: r.id,
|
|
164
|
+
title: readSpecTitle(workDir, r.id),
|
|
165
|
+
status: r.status,
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
return { action: 'ask', options };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { resolveActiveSpec, parseActiveSpecsTable };
|
package/bin/lib/state.cjs
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* bin/lib/state.cjs — STATE.md CRUD operations
|
|
3
3
|
*
|
|
4
|
-
* Exports:
|
|
4
|
+
* Exports:
|
|
5
|
+
* cmdStateGet() — legacy shim (returns single active spec)
|
|
6
|
+
* cmdStateSetActive() — legacy shim (delegates to cmdStateAddActive)
|
|
7
|
+
* cmdStateListActive() — list all active specs; lazy migration on first call
|
|
8
|
+
* cmdStateAddActive() — append/update one row under withStateLock
|
|
9
|
+
* cmdStateRemoveActive() — remove one row under withStateLock
|
|
10
|
+
* cmdStateResolve() — invoke resolveActiveSpec, emit JSON contract
|
|
11
|
+
* cmdStateMigrate() — explicit one-shot migration
|
|
12
|
+
* cmdQueueNext() — first actionable spec from queue
|
|
13
|
+
* extractActiveSpec() — legacy helper (exported for backwards compat)
|
|
14
|
+
*
|
|
15
|
+
* ALL writes to STATE.md go through withStateLock per SPEC-011 AC 14.
|
|
16
|
+
* Read paths remain lock-free (reads are safe to do concurrently).
|
|
5
17
|
*/
|
|
6
18
|
|
|
7
19
|
'use strict';
|
|
8
20
|
|
|
9
|
-
const fs = require('fs');
|
|
10
21
|
const path = require('path');
|
|
11
|
-
const { output, error, safeReadFile } = require('./core.cjs');
|
|
22
|
+
const { output, error, safeReadFile, atomicWrite } = require('./core.cjs');
|
|
23
|
+
const { withStateLock } = require('./lock.cjs');
|
|
24
|
+
const { resolveActiveSpec, parseActiveSpecsTable } = require('./resolve.cjs');
|
|
25
|
+
const { migrateStateMd } = require('./migrate-state.cjs');
|
|
26
|
+
|
|
27
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
12
28
|
|
|
13
29
|
/**
|
|
14
30
|
* Extract a bold-field value from STATE.md content.
|
|
@@ -24,7 +40,7 @@ function extractBoldField(content, field) {
|
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/**
|
|
27
|
-
* Extract the active spec ID from STATE.md.
|
|
43
|
+
* Extract the active spec ID from STATE.md (legacy single-spec format).
|
|
28
44
|
* The spec ID is on the line immediately after "## Active Specification".
|
|
29
45
|
* @param {string} content - STATE.md content
|
|
30
46
|
* @returns {string|null}
|
|
@@ -33,7 +49,6 @@ function extractActiveSpec(content) {
|
|
|
33
49
|
const lines = content.split('\n');
|
|
34
50
|
for (let i = 0; i < lines.length; i++) {
|
|
35
51
|
if (lines[i].trim() === '## Active Specification') {
|
|
36
|
-
// Next non-empty line has the spec ID
|
|
37
52
|
for (let j = i + 1; j < lines.length; j++) {
|
|
38
53
|
const line = lines[j].trim();
|
|
39
54
|
if (line && !line.startsWith('**') && !line.startsWith('#')) {
|
|
@@ -68,13 +83,12 @@ function parseQueueTable(content) {
|
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
if (inQueue && trimmed.startsWith('##') && trimmed !== '## Queue') {
|
|
71
|
-
break;
|
|
86
|
+
break;
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
if (!inQueue) continue;
|
|
75
90
|
|
|
76
91
|
if (trimmed.startsWith('|') && !headerFound) {
|
|
77
|
-
// Check if this is the header row
|
|
78
92
|
if (trimmed.toLowerCase().includes('priority') && trimmed.toLowerCase().includes('id')) {
|
|
79
93
|
headerFound = true;
|
|
80
94
|
continue;
|
|
@@ -104,8 +118,260 @@ function parseQueueTable(content) {
|
|
|
104
118
|
return queue;
|
|
105
119
|
}
|
|
106
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Build the new Active Specifications table section from rows.
|
|
123
|
+
* @param {Array<{id,status,nextStep}>} rows
|
|
124
|
+
* @returns {string} Table markdown (no trailing newline)
|
|
125
|
+
*/
|
|
126
|
+
function buildActiveSpecsTable(rows) {
|
|
127
|
+
let table =
|
|
128
|
+
'## Active Specifications\n\n' +
|
|
129
|
+
'| SPEC-ID | Status | Next Step |\n' +
|
|
130
|
+
'|---------|--------|-----------|';
|
|
131
|
+
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
table += '\n| ' + row.id + ' | ' + row.status + ' | ' + row.nextStep + ' |';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return table;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Rewrite the Active Specifications table in STATE.md content with new rows.
|
|
141
|
+
* If table section does not exist, appends it before ## Queue.
|
|
142
|
+
* Returns updated content string.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} content - STATE.md content
|
|
145
|
+
* @param {Array<{id,status,nextStep}>} rows - New rows for the table
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function rewriteActiveSpecsTable(content, rows) {
|
|
149
|
+
const newTableSection = buildActiveSpecsTable(rows);
|
|
150
|
+
|
|
151
|
+
// Find existing ## Active Specifications section
|
|
152
|
+
const lines = content.split('\n');
|
|
153
|
+
let sectionStart = -1;
|
|
154
|
+
let sectionEnd = lines.length;
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
if (lines[i].trim() === '## Active Specifications') {
|
|
158
|
+
sectionStart = i;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (sectionStart !== -1 && lines[i].trim().startsWith('## ') && i > sectionStart) {
|
|
162
|
+
sectionEnd = i;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (sectionStart !== -1) {
|
|
168
|
+
// Replace existing section
|
|
169
|
+
const before = lines.slice(0, sectionStart);
|
|
170
|
+
const after = lines.slice(sectionEnd);
|
|
171
|
+
// Trim trailing blank lines from before
|
|
172
|
+
while (before.length > 0 && before[before.length - 1].trim() === '') before.pop();
|
|
173
|
+
// Trim leading blank lines from after
|
|
174
|
+
while (after.length > 0 && after[0].trim() === '') after.shift();
|
|
175
|
+
return [...before, ...newTableSection.split('\n'), '', ...after].join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// No existing section — insert before ## Queue
|
|
179
|
+
const queueIdx = lines.findIndex(l => l.trim() === '## Queue');
|
|
180
|
+
if (queueIdx !== -1) {
|
|
181
|
+
const before = lines.slice(0, queueIdx);
|
|
182
|
+
// Trim trailing blank lines from before
|
|
183
|
+
while (before.length > 0 && before[before.length - 1].trim() === '') before.pop();
|
|
184
|
+
const after = lines.slice(queueIdx);
|
|
185
|
+
return [...before, ...newTableSection.split('\n'), '', ...after].join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fallback: append to end
|
|
189
|
+
return content.trimEnd() + '\n\n' + newTableSection + '\n';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Public commands ──────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* List all active specifications from STATE.md.
|
|
196
|
+
* Runs lazy migration if legacy format detected.
|
|
197
|
+
* @param {string} cwd - Working directory
|
|
198
|
+
* @param {boolean} raw - Output raw string
|
|
199
|
+
*/
|
|
200
|
+
function cmdStateListActive(cwd, raw) {
|
|
201
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
202
|
+
let content = safeReadFile(statePath);
|
|
203
|
+
|
|
204
|
+
if (!content) {
|
|
205
|
+
error('STATE.md not found at ' + statePath);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Lazy migration safety net
|
|
209
|
+
if (!content.includes('## Active Specifications')) {
|
|
210
|
+
const migrated = migrateStateMd(content);
|
|
211
|
+
if (migrated !== content) {
|
|
212
|
+
// Write migrated content under lock
|
|
213
|
+
withStateLock(() => {
|
|
214
|
+
// Re-read after acquiring lock (another process may have migrated already)
|
|
215
|
+
const fresh = safeReadFile(statePath) || content;
|
|
216
|
+
if (!fresh.includes('## Active Specifications')) {
|
|
217
|
+
atomicWrite(statePath, migrated);
|
|
218
|
+
}
|
|
219
|
+
}).catch(() => {}); // non-fatal if lock fails
|
|
220
|
+
content = migrated;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const rows = parseActiveSpecsTable(content);
|
|
225
|
+
output({ active_specs: rows }, raw, rows.map(r => r.id).join('\n'));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Add or update one spec row in the Active Specifications table.
|
|
230
|
+
* All writes execute under withStateLock.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} cwd - Working directory
|
|
233
|
+
* @param {string} id - SPEC-ID
|
|
234
|
+
* @param {string} status - Status value
|
|
235
|
+
* @param {string} nextStep - Next step value
|
|
236
|
+
* @param {boolean} raw - Output raw string
|
|
237
|
+
*/
|
|
238
|
+
async function cmdStateAddActive(cwd, id, status, nextStep, raw) {
|
|
239
|
+
if (!id || !status) {
|
|
240
|
+
error('Missing arguments. Usage: state add-active <id> <status> <next_step>');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
244
|
+
|
|
245
|
+
const result = await withStateLock(async () => {
|
|
246
|
+
let content = safeReadFile(statePath);
|
|
247
|
+
if (!content) error('STATE.md not found at ' + statePath);
|
|
248
|
+
|
|
249
|
+
// Lazy migration if needed
|
|
250
|
+
if (!content.includes('## Active Specifications')) {
|
|
251
|
+
content = migrateStateMd(content);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const rows = parseActiveSpecsTable(content);
|
|
255
|
+
|
|
256
|
+
// Update existing row or append new row
|
|
257
|
+
const existingIdx = rows.findIndex(r => r.id === id);
|
|
258
|
+
const newRow = { id, status, nextStep: nextStep || '' };
|
|
259
|
+
|
|
260
|
+
if (existingIdx !== -1) {
|
|
261
|
+
rows[existingIdx] = newRow;
|
|
262
|
+
} else {
|
|
263
|
+
rows.push(newRow);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const updated = rewriteActiveSpecsTable(content, rows);
|
|
267
|
+
atomicWrite(statePath, updated);
|
|
268
|
+
|
|
269
|
+
return { updated: true, id, status, next_step: nextStep || '' };
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
output(result, raw, 'updated');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Remove one spec row from the Active Specifications table.
|
|
277
|
+
* All writes execute under withStateLock.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} cwd - Working directory
|
|
280
|
+
* @param {string} id - SPEC-ID to remove
|
|
281
|
+
* @param {boolean} raw - Output raw string
|
|
282
|
+
*/
|
|
283
|
+
async function cmdStateRemoveActive(cwd, id, raw) {
|
|
284
|
+
if (!id) {
|
|
285
|
+
error('Missing arguments. Usage: state remove-active <id>');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
289
|
+
|
|
290
|
+
const result = await withStateLock(async () => {
|
|
291
|
+
let content = safeReadFile(statePath);
|
|
292
|
+
if (!content) error('STATE.md not found at ' + statePath);
|
|
293
|
+
|
|
294
|
+
if (!content.includes('## Active Specifications')) {
|
|
295
|
+
content = migrateStateMd(content);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const rows = parseActiveSpecsTable(content);
|
|
299
|
+
const filtered = rows.filter(r => r.id !== id);
|
|
300
|
+
|
|
301
|
+
const updated = rewriteActiveSpecsTable(content, filtered);
|
|
302
|
+
atomicWrite(statePath, updated);
|
|
303
|
+
|
|
304
|
+
return { removed: true, id, was_present: rows.length !== filtered.length };
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
output(result, raw, 'removed');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve the active spec to act on using the resolver contract.
|
|
312
|
+
* Emits one of the four JSON shapes defined in SPEC-011.
|
|
313
|
+
*
|
|
314
|
+
* @param {string} cwd - Working directory
|
|
315
|
+
* @param {string|undefined} specId - Optional SPEC-ID argument
|
|
316
|
+
* @param {boolean} raw - Output raw string
|
|
317
|
+
*/
|
|
318
|
+
function cmdStateResolve(cwd, specId, raw) {
|
|
319
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
320
|
+
const content = safeReadFile(statePath);
|
|
321
|
+
|
|
322
|
+
if (!content) {
|
|
323
|
+
output({ action: 'error', code: 'NO_ACTIVE_SPEC' }, raw, '');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Run lazy migration if needed (read-only path, no write needed for resolve)
|
|
328
|
+
let effectiveContent = content;
|
|
329
|
+
if (!content.includes('## Active Specifications')) {
|
|
330
|
+
effectiveContent = migrateStateMd(content);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const resolution = resolveActiveSpec(specId, effectiveContent, cwd);
|
|
334
|
+
|
|
335
|
+
if (raw) {
|
|
336
|
+
output(resolution, false, JSON.stringify(resolution));
|
|
337
|
+
} else {
|
|
338
|
+
output(resolution, false);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Explicit one-shot migration command.
|
|
344
|
+
* Migrates STATE.md from legacy format to new Active Specifications table.
|
|
345
|
+
* Second invocation is a no-op (idempotent).
|
|
346
|
+
*
|
|
347
|
+
* @param {string} cwd - Working directory
|
|
348
|
+
* @param {boolean} raw - Output raw string
|
|
349
|
+
*/
|
|
350
|
+
async function cmdStateMigrate(cwd, raw) {
|
|
351
|
+
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
352
|
+
|
|
353
|
+
const result = await withStateLock(async () => {
|
|
354
|
+
const content = safeReadFile(statePath);
|
|
355
|
+
if (!content) error('STATE.md not found at ' + statePath);
|
|
356
|
+
|
|
357
|
+
const migrated = migrateStateMd(content);
|
|
358
|
+
const changed = migrated !== content;
|
|
359
|
+
|
|
360
|
+
if (changed) {
|
|
361
|
+
atomicWrite(statePath, migrated);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { migrated: changed, message: changed ? 'Migration complete' : 'Already migrated (no-op)' };
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
output(result, raw, result.message);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Legacy commands (preserved for backwards compatibility) ──────────────────
|
|
371
|
+
|
|
107
372
|
/**
|
|
108
373
|
* Get current active spec, status, and next step from STATE.md.
|
|
374
|
+
* Legacy shim: returns single active spec (or last-touched when N>1).
|
|
109
375
|
* @param {string} cwd - Working directory
|
|
110
376
|
* @param {boolean} raw - Output raw string
|
|
111
377
|
*/
|
|
@@ -117,6 +383,25 @@ function cmdStateGet(cwd, raw) {
|
|
|
117
383
|
error('STATE.md not found at ' + statePath);
|
|
118
384
|
}
|
|
119
385
|
|
|
386
|
+
// New schema: read from Active Specifications table
|
|
387
|
+
if (content.includes('## Active Specifications')) {
|
|
388
|
+
const rows = parseActiveSpecsTable(content);
|
|
389
|
+
if (rows.length > 0) {
|
|
390
|
+
// "last touched" = most recently appended = last row
|
|
391
|
+
const last = rows[rows.length - 1];
|
|
392
|
+
const result = {
|
|
393
|
+
active_spec: last.id,
|
|
394
|
+
status: last.status,
|
|
395
|
+
next_step: last.nextStep,
|
|
396
|
+
};
|
|
397
|
+
output(result, raw, result.active_spec);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
output({ active_spec: null, status: null, next_step: null }, raw, '');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Legacy schema fallback
|
|
120
405
|
const activeSpec = extractActiveSpec(content);
|
|
121
406
|
const status = extractBoldField(content, 'Status');
|
|
122
407
|
const nextStep = extractBoldField(content, 'Next Step');
|
|
@@ -131,14 +416,15 @@ function cmdStateGet(cwd, raw) {
|
|
|
131
416
|
}
|
|
132
417
|
|
|
133
418
|
/**
|
|
134
|
-
*
|
|
419
|
+
* Legacy shim: update active spec, status, and optionally next step.
|
|
420
|
+
* Delegates to cmdStateAddActive for new schema; falls back to inline for legacy.
|
|
135
421
|
* @param {string} cwd - Working directory
|
|
136
|
-
* @param {string} id - Spec ID
|
|
422
|
+
* @param {string} id - Spec ID
|
|
137
423
|
* @param {string} status - New status
|
|
138
424
|
* @param {string} [nextStep] - Optional new next step
|
|
139
425
|
* @param {boolean} raw - Output raw string
|
|
140
426
|
*/
|
|
141
|
-
function cmdStateSetActive(cwd, id, status, nextStep, raw) {
|
|
427
|
+
async function cmdStateSetActive(cwd, id, status, nextStep, raw) {
|
|
142
428
|
const statePath = path.join(cwd, '.specflow', 'STATE.md');
|
|
143
429
|
const content = safeReadFile(statePath);
|
|
144
430
|
|
|
@@ -146,63 +432,70 @@ function cmdStateSetActive(cwd, id, status, nextStep, raw) {
|
|
|
146
432
|
error('STATE.md not found at ' + statePath);
|
|
147
433
|
}
|
|
148
434
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
435
|
+
// If new schema: delegate to add-active
|
|
436
|
+
if (content.includes('## Active Specifications')) {
|
|
437
|
+
return cmdStateAddActive(cwd, id, status, nextStep, raw);
|
|
438
|
+
}
|
|
153
439
|
|
|
154
|
-
|
|
155
|
-
|
|
440
|
+
// Legacy schema path: update in-place under lock
|
|
441
|
+
const result = await withStateLock(async () => {
|
|
442
|
+
const freshContent = safeReadFile(statePath) || content;
|
|
443
|
+
const lines = freshContent.split('\n');
|
|
444
|
+
const resultLines = [];
|
|
445
|
+
let inActiveSection = false;
|
|
446
|
+
let specIdReplaced = false;
|
|
156
447
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
result.push(lines[i]);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
448
|
+
for (let i = 0; i < lines.length; i++) {
|
|
449
|
+
const trimmed = lines[i].trim();
|
|
162
450
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
451
|
+
if (trimmed === '## Active Specification') {
|
|
452
|
+
inActiveSection = true;
|
|
453
|
+
resultLines.push(lines[i]);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
166
456
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
specIdReplaced = true;
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
457
|
+
if (inActiveSection && trimmed.startsWith('## ') && trimmed !== '## Active Specification') {
|
|
458
|
+
inActiveSection = false;
|
|
459
|
+
}
|
|
173
460
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
461
|
+
if (inActiveSection && !specIdReplaced && trimmed && !trimmed.startsWith('**') && !trimmed.startsWith('#')) {
|
|
462
|
+
resultLines.push(id);
|
|
463
|
+
specIdReplaced = true;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
178
466
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
} else {
|
|
183
|
-
result.push(lines[i]); // preserve existing
|
|
467
|
+
if (inActiveSection && trimmed.startsWith('**Status:**')) {
|
|
468
|
+
resultLines.push('**Status:** ' + status);
|
|
469
|
+
continue;
|
|
184
470
|
}
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
471
|
|
|
188
|
-
|
|
189
|
-
|
|
472
|
+
if (inActiveSection && trimmed.startsWith('**Next Step:**')) {
|
|
473
|
+
if (nextStep !== undefined && nextStep !== null) {
|
|
474
|
+
resultLines.push('**Next Step:** ' + nextStep);
|
|
475
|
+
} else {
|
|
476
|
+
resultLines.push(lines[i]);
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
190
480
|
|
|
191
|
-
|
|
481
|
+
resultLines.push(lines[i]);
|
|
482
|
+
}
|
|
192
483
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
484
|
+
atomicWrite(statePath, resultLines.join('\n'));
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
updated: true,
|
|
488
|
+
active_spec: id,
|
|
489
|
+
status: status,
|
|
490
|
+
next_step: nextStep || extractBoldField(resultLines.join('\n'), 'Next Step'),
|
|
491
|
+
};
|
|
492
|
+
});
|
|
199
493
|
|
|
200
|
-
output(
|
|
494
|
+
output(result, raw, 'updated');
|
|
201
495
|
}
|
|
202
496
|
|
|
203
497
|
/**
|
|
204
498
|
* Get the first actionable spec from the queue.
|
|
205
|
-
* "Actionable" = status is not "done" or "complete".
|
|
206
499
|
* @param {string} cwd - Working directory
|
|
207
500
|
* @param {boolean} raw - Output raw string
|
|
208
501
|
*/
|
|
@@ -236,6 +529,11 @@ function cmdQueueNext(cwd, raw) {
|
|
|
236
529
|
module.exports = {
|
|
237
530
|
cmdStateGet,
|
|
238
531
|
cmdStateSetActive,
|
|
532
|
+
cmdStateListActive,
|
|
533
|
+
cmdStateAddActive,
|
|
534
|
+
cmdStateRemoveActive,
|
|
535
|
+
cmdStateResolve,
|
|
536
|
+
cmdStateMigrate,
|
|
239
537
|
cmdQueueNext,
|
|
240
538
|
extractActiveSpec,
|
|
241
539
|
};
|