specflow-cc 1.19.0 → 1.20.1

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,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: cmdStateGet(), cmdStateSetActive(), cmdQueueNext()
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; // next section
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
- * Update active spec, status, and optionally next step in STATE.md.
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 (e.g., "SPEC-007")
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
- const lines = content.split('\n');
150
- const result = [];
151
- let inActiveSection = false;
152
- let specIdReplaced = false;
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
- for (let i = 0; i < lines.length; i++) {
155
- const trimmed = lines[i].trim();
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
- if (trimmed === '## Active Specification') {
158
- inActiveSection = true;
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
- if (inActiveSection && trimmed.startsWith('## ') && trimmed !== '## Active Specification') {
164
- inActiveSection = false;
165
- }
451
+ if (trimmed === '## Active Specification') {
452
+ inActiveSection = true;
453
+ resultLines.push(lines[i]);
454
+ continue;
455
+ }
166
456
 
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
- }
457
+ if (inActiveSection && trimmed.startsWith('## ') && trimmed !== '## Active Specification') {
458
+ inActiveSection = false;
459
+ }
173
460
 
174
- if (inActiveSection && trimmed.startsWith('**Status:**')) {
175
- result.push('**Status:** ' + status);
176
- continue;
177
- }
461
+ if (inActiveSection && !specIdReplaced && trimmed && !trimmed.startsWith('**') && !trimmed.startsWith('#')) {
462
+ resultLines.push(id);
463
+ specIdReplaced = true;
464
+ continue;
465
+ }
178
466
 
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
467
+ if (inActiveSection && trimmed.startsWith('**Status:**')) {
468
+ resultLines.push('**Status:** ' + status);
469
+ continue;
184
470
  }
185
- continue;
186
- }
187
471
 
188
- result.push(lines[i]);
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
- fs.writeFileSync(statePath, result.join('\n'), 'utf8');
481
+ resultLines.push(lines[i]);
482
+ }
192
483
 
193
- const resultObj = {
194
- updated: true,
195
- active_spec: id,
196
- status: status,
197
- next_step: nextStep || extractBoldField(result.join('\n'), 'Next Step'),
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(resultObj, raw, 'updated');
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
  };