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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ All notable changes to SpecFlow will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.20.1] - 2026-05-14
9
+
10
+ ### Fixed
11
+
12
+ - **INDEX.md staleness across all TODO-mutating paths** — `1.19.0` wired the `todo reindex` helper into `/sf:todo` and `/sf:done`, but every other command that mutates `.specflow/todos/` (`/sf:plan` `rm`, `/sf:triage` create, `/sf:revise` deferred-TODO creation, `/sf:priority` priority edits, `/sf:migrate-todos`, and the `sf-spec-reviser` agent) still left INDEX.md silently out of sync. All of these now invoke `node bin/sf-tools.cjs todo reindex` after the mutation. `/sf:todos` no longer writes INDEX.md inline — it delegates to the same helper, making the reindex routine the single source of truth for INDEX layout.
13
+
14
+ ### Added
15
+
16
+ - **`todo check-stale` CLI subcommand** in `bin/sf-tools.cjs` — compares the set of `TODO-*.md` files on disk to the set of IDs in `INDEX.md` and returns `{stale, index_exists, todo_count, index_count, missing_from_index, extra_in_index}`. Used by `/sf:status` as a safety-net freshness check: if any drift is detected (external edits, manual `rm`, a missed helper call), `/sf:status` surfaces an "INDEX.md stale" warning naming the specific divergences. No auto-fix — the user re-runs `/sf:todos` or the helper.
17
+ - 9 new tests in `tests/todo-index.test.cjs` covering reindex idempotency, header content, drop-after-delete, and all `check-stale` scenarios (fresh, extra-in-index, missing-from-index, no-INDEX-with-files, empty-both, raw output).
18
+
19
+ ### Changed
20
+
21
+ - **INDEX.md header text** rewritten to describe actual behaviour. Old wording ("Auto-generated from individual TODO files. Do not edit manually. Regenerate with `/sf:todos`.") implied a self-maintaining file. New wording: "Cache of individual TODO files. Refreshed when `/sf:todos` runs OR when an INDEX-mutating command explicitly invokes the regen helper (`node bin/sf-tools.cjs todo reindex`). Do not edit manually — changes will be overwritten on the next regen." Applied in both `bin/lib/todo.cjs` (the source of truth) and `templates/todo-index.md`.
22
+
23
+ ## [1.20.0] - 2026-05-02
24
+
25
+ ### Added
26
+
27
+ - **Parallel specification execution** — STATE.md now supports multiple active specifications in a `## Active Specifications` table (multi-row registry), enabling concurrent work across separate Claude Code sessions
28
+ - Run two specs in parallel: open a second session and either `/sf:next` or `/sf:run SPEC-XXX` — each session resolves its own target
29
+ - Single-spec workflows are unchanged: when only one spec is active, no command requires a SPEC-ID argument
30
+ - **Advisory file-rename lock** — new `bin/lib/lock.cjs` exposes `withStateLock(fn)` to serialize STATE.md read-modify-write across concurrent processes. Uses Node's built-in `fs.openSync(path, 'wx')` + atomic-rename pattern — no native `flock(2)`, no shell-out, no new runtime dependencies. Per-process reentrancy via module-scope `_depth` counter; cross-process exclusivity via the `wx` flag
31
+ - Includes EPERM-aware stale-PID detection (`isProcessAlive` treats EPERM as alive per POSIX semantics)
32
+ - **Centralized spec resolution** — new `bin/lib/resolve.cjs` and `node bin/sf-tools.cjs state resolve [SPEC-ID]` CLI. All 15 spec-touching commands now resolve their target spec through this single helper. Returns one of four JSON shapes:
33
+ - `{action: "use", id: "SPEC-XXX"}` — N=1 implicit or explicit-match
34
+ - `{action: "ask", options: [...]}` — N>1 with no SPEC-ID provided (commands present an `AskUserQuestion` picker)
35
+ - `{action: "error", code: "NO_ACTIVE_SPEC"}` — N=0
36
+ - `{action: "error", code: "SPEC_NOT_ACTIVE"}` — explicit SPEC-ID not in active table
37
+ - **Idempotent legacy migration** — new `bin/lib/migrate-state.cjs` upgrades old `Active Specification` / `Status` / `Next Step` triples to the new `## Active Specifications` table. Handles both heading-style and bullet-style legacy fixtures. Invoked on `/sf:health` entry; second run is zero-diff
38
+ - **New `state` subcommands** — `state list-active`, `state add-active <id> <status> <next>`, `state remove-active <id>`, `state resolve [id?]`, `state migrate`. Legacy `state get` and `state set-active` shims preserved for backwards compatibility
39
+ - **`/sf:autopilot` N>1 guard** — autopilot fails fast with an explicit message when more than one spec is active and no SPEC-ID is provided (no auto-pick, no `AskUserQuestion`). The `--all` flag does NOT override this guard; multi-spec autopilot iteration is intentionally out of scope
40
+ - 38 new tests across `test/lock.test.cjs`, `test/resolve.test.cjs`, `test/migrate.test.cjs`, `test/integration.test.cjs` — covering reentrancy, concurrent-write convergence (two child processes), all 5 resolution scenarios, both legacy formats, and end-to-end CLI flows. Full suite: 43 tests pass under Node 22's default parallel runner
41
+
42
+ ### Changed
43
+
44
+ - **All 15 spec-touching commands** now use `state resolve` instead of inline STATE.md parsing: `audit`, `autopilot`, `discuss`, `done`, `fix`, `health`, `help`, `pause`, `review`, `revise`, `run`, `show`, `split`, `status`, `verify`. STATE.md mutations route through `state add-active` / `state remove-active`
45
+ - **`templates/state.md`** — replaced single-spec `Active Specification` / `Status` / `Next Step` block with `## Active Specifications` table (`| SPEC-ID | Status | Next Step |`)
46
+ - **All STATE.md writes** — every mutator in `bin/lib/state.cjs` now wraps writes in `withStateLock(...)`. The grep contract `writeFile*(STATE.md, ...)` outside `bin/lib/lock.cjs` returns zero hits; this is enforced by a top-of-file comment in `lock.cjs`
47
+
48
+ ### Fixed
49
+
50
+ - **Atomic STATE.md writes** — pre-existing torn-file risk addressed: STATE.md writes now use the temp-file + rename pattern, eliminating partial reads under concurrent access (commit `e674656`)
51
+
52
+ ### Migration notes
53
+
54
+ Existing projects with the legacy STATE.md schema are migrated automatically on the next `/sf:health` invocation. No manual steps required. Single-spec workflows continue to work without any user-visible change.
55
+
8
56
  ## [1.19.0] - 2026-04-12
9
57
 
10
58
  ### Added
package/README.md CHANGED
@@ -64,6 +64,7 @@ SpecFlow fixes this with **Fresh Context Auditing**:
64
64
  | **Quality Control** | Self-correction (biased) | Independent Auditor (unbiased) |
65
65
  | **Verification** | "Looks good to me" | Verified against contract |
66
66
  | **Execution** | Linear, single agent | Atomic waves, parallel agents |
67
+ | **Concurrency** | One task at a time | Multiple specs in parallel sessions |
67
68
  | **Result** | Hidden bugs ship | Issues caught before code exists |
68
69
 
69
70
  > The auditor has no memory of your conversation. It only sees the spec. If the spec says "use the flux capacitor API" — the auditor asks "what is that?"
@@ -433,7 +434,7 @@ Use `max` for maximum quality everywhere, `quality` for critical features, `budg
433
434
  ```
434
435
  .specflow/
435
436
  ├── PROJECT.md # Project context (patterns, conventions)
436
- ├── STATE.md # Current state and queue
437
+ ├── STATE.md # Multi-active state and queue (parallel-safe)
437
438
  ├── config.json # Settings
438
439
  ├── specs/ # Active specifications
439
440
  ├── research/ # Research documents
@@ -465,6 +466,13 @@ Use `max` for maximum quality everywhere, `quality` for critical features, `budg
465
466
  # Or skip manual steps — run everything autonomously
466
467
  /sf:autopilot # Process active spec end-to-end
467
468
  /sf:autopilot --all # Process entire queue
469
+
470
+ # Parallel sessions (since 1.20.0)
471
+ # Session A:
472
+ /sf:run SPEC-042 # Work on one spec
473
+ # Session B (separate Claude Code window, same project):
474
+ /sf:run SPEC-043 # Work on a different spec concurrently
475
+ # STATE.md is locked per-write — no torn files, both rows visible
468
476
  ```
469
477
 
470
478
  ---
@@ -176,7 +176,13 @@ For each deferred item:
176
176
  - TODO-{XXX} — {item description}
177
177
  ```
178
178
 
179
- **Important:** This step is mandatory. Every deferred item MUST produce a TODO. If TODO creation fails, report the failure do not silently skip.
179
+ 4. After the loop completes (at least one TODO created), refresh the INDEX.md cache so it reflects the newly-created files:
180
+
181
+ ```bash
182
+ node bin/sf-tools.cjs todo reindex
183
+ ```
184
+
185
+ **Important:** Both substeps are mandatory. Every deferred item MUST produce a TODO, and if any TODO is created the reindex helper MUST run before reporting completion. Skipping the reindex leaves `.specflow/todos/INDEX.md` missing the just-created entries, which the `/sf:status` freshness check will then flag. If TODO creation fails, report the failure — do not silently skip.
180
186
 
181
187
  ## Step 6: Update Frontmatter
182
188
 
@@ -245,6 +251,7 @@ Tip: `/clear` recommended — auditor needs fresh context
245
251
  - [ ] Revision Response recorded in Audit History
246
252
  - [ ] Deferred items (if any) created as individual `.specflow/todos/TODO-XXX.md` files
247
253
  - [ ] TODOs Created subsection appended to Response (if deferred items exist)
254
+ - [ ] INDEX.md refreshed via `node bin/sf-tools.cjs todo reindex` (if any TODO was created)
248
255
  - [ ] Frontmatter status updated
249
256
  - [ ] STATE.md updated
250
257
  - [ ] Clear summary of changes provided
package/bin/lib/core.cjs CHANGED
@@ -94,6 +94,31 @@ function parseFrontmatter(content) {
94
94
  return { frontmatter, body };
95
95
  }
96
96
 
97
+ /**
98
+ * Atomically write a file via tmp + rename. Prevents torn writes if the
99
+ * process is interrupted mid-write, and prevents readers (e.g. the statusline
100
+ * hook) from observing a half-written file when a command writes concurrently.
101
+ *
102
+ * Note: this does NOT prevent the read-modify-write race between two writers
103
+ * (both read old state, both write, second wins). For SpecFlow's interactive
104
+ * single-session model that race is rare; preventing torn files is the goal.
105
+ *
106
+ * @param {string} filePath - Absolute path to write
107
+ * @param {string} content - File contents (utf8)
108
+ */
109
+ function atomicWrite(filePath, content) {
110
+ const dir = path.dirname(filePath);
111
+ const base = path.basename(filePath);
112
+ const tmpPath = path.join(dir, '.' + base + '.tmp.' + process.pid + '.' + Date.now());
113
+ try {
114
+ fs.writeFileSync(tmpPath, content, 'utf8');
115
+ fs.renameSync(tmpPath, filePath);
116
+ } catch (e) {
117
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
118
+ throw e;
119
+ }
120
+ }
121
+
97
122
  /**
98
123
  * Generate a URL-safe slug from text.
99
124
  * Lowercase, replace spaces/special chars with hyphens, collapse multiples, trim edges.
@@ -115,6 +140,7 @@ module.exports = {
115
140
  output,
116
141
  error,
117
142
  safeReadFile,
143
+ atomicWrite,
118
144
  parseFrontmatter,
119
145
  generateSlug,
120
146
  };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * bin/lib/lock.cjs — Advisory file-rename lock for STATE.md mutations
3
+ *
4
+ * IMPORTANT: AC 14 grep contract
5
+ * All fs.writeFileSync / fs.writeFile calls targeting .specflow/STATE.md MUST go
6
+ * through withStateLock() in THIS file. Verified by:
7
+ * grep -rn 'writeFileSync.*STATE\.md\|writeFile.*STATE\.md' \
8
+ * $(find bin -name '*.cjs') | grep -v lock.cjs
9
+ * must return zero hits. If this file is renamed, update that grep pattern everywhere
10
+ * it is referenced (CI checks, SPEC-011 acceptance criteria).
11
+ *
12
+ * Exports: withStateLock(asyncFn) → Promise<*>
13
+ *
14
+ * Lock mechanics:
15
+ * - Lock file: .specflow/.state.lock
16
+ * - Acquisition: fs.openSync(lockPath, 'wx') — exclusive create (atomic on POSIX + macOS + WSL)
17
+ * - On EEXIST: read lock file PID, send signal 0 to check liveness, clear stale lock if dead
18
+ * - Retry with exponential backoff, max 5 seconds total
19
+ * - Release: fs.unlinkSync(lockPath)
20
+ *
21
+ * Reentrancy (within one Node process):
22
+ * - Module-scope _depth counter tracks nest level
23
+ * - Outer call acquires OS lock and increments _depth to 1
24
+ * - Inner (reentrant) calls from the same process increment _depth and skip OS re-acquire
25
+ * - On release, decrement _depth; OS lock released only when _depth reaches 0
26
+ * - This is single-Node-process only — no worker_threads support
27
+ * - Cross-process exclusion is provided by the OS exclusive-create
28
+ *
29
+ * No new runtime dependencies — uses only built-in `fs` and `process`.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+
37
+ // Module-scope reentrancy counter (single-process only)
38
+ let _depth = 0;
39
+ let _lockFd = null;
40
+
41
+ const LOCK_TIMEOUT_MS = 5000;
42
+ const RETRY_BASE_MS = 10;
43
+ const RETRY_MAX_MS = 200;
44
+
45
+ /**
46
+ * Resolve the lock file path relative to cwd.
47
+ * @returns {string}
48
+ */
49
+ function getLockPath() {
50
+ return path.join(process.cwd(), '.specflow', '.state.lock');
51
+ }
52
+
53
+ /**
54
+ * Write PID to the lock file descriptor.
55
+ * @param {number} fd - Open file descriptor
56
+ */
57
+ function writePid(fd) {
58
+ const pidStr = String(process.pid);
59
+ fs.writeSync(fd, pidStr);
60
+ }
61
+
62
+ /**
63
+ * Read the PID from an existing lock file.
64
+ * Returns null if file cannot be read or doesn't contain a valid PID.
65
+ * @param {string} lockPath
66
+ * @returns {number|null}
67
+ */
68
+ function readLockPid(lockPath) {
69
+ try {
70
+ const content = fs.readFileSync(lockPath, 'utf8').trim();
71
+ const pid = parseInt(content, 10);
72
+ return isNaN(pid) ? null : pid;
73
+ } catch (_) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check if a process with the given PID is alive using signal 0.
80
+ * Returns true if alive, false if dead or inaccessible.
81
+ * @param {number} pid
82
+ * @returns {boolean}
83
+ */
84
+ function isProcessAlive(pid) {
85
+ try {
86
+ process.kill(pid, 0);
87
+ return true;
88
+ } catch (e) {
89
+ // EPERM = process exists but is owned by another user — still alive
90
+ if (e.code === 'EPERM') return true;
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Attempt to clear a stale lock file.
97
+ * Safe to call even if the file no longer exists.
98
+ * @param {string} lockPath
99
+ */
100
+ function clearStaleLock(lockPath) {
101
+ try {
102
+ fs.unlinkSync(lockPath);
103
+ } catch (_) {
104
+ // Ignore — another process may have cleared it already
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Acquire the advisory file-rename lock.
110
+ * Blocks (via async polling) until acquired or timeout exceeded.
111
+ * @param {string} lockPath
112
+ * @returns {Promise<number>} Resolved file descriptor
113
+ * @throws {Error} If lock cannot be acquired within timeout
114
+ */
115
+ async function acquireLock(lockPath) {
116
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
117
+ let retryMs = RETRY_BASE_MS;
118
+
119
+ while (true) {
120
+ try {
121
+ // Exclusive create — atomic on POSIX, macOS, and WSL
122
+ const fd = fs.openSync(lockPath, 'wx');
123
+ writePid(fd);
124
+ return fd;
125
+ } catch (e) {
126
+ if (e.code !== 'EEXIST') {
127
+ throw e;
128
+ }
129
+
130
+ // Lock file exists — check for stale lock
131
+ const ownerPid = readLockPid(lockPath);
132
+ if (ownerPid !== null && !isProcessAlive(ownerPid)) {
133
+ // Owner is dead — clear stale lock and retry immediately
134
+ clearStaleLock(lockPath);
135
+ continue;
136
+ }
137
+
138
+ // Lock is held by a live process — check timeout
139
+ if (Date.now() >= deadline) {
140
+ throw new Error(
141
+ `withStateLock: could not acquire .state.lock within ${LOCK_TIMEOUT_MS}ms ` +
142
+ `(held by PID ${ownerPid ?? 'unknown'})`
143
+ );
144
+ }
145
+
146
+ // Exponential backoff
147
+ await sleep(retryMs);
148
+ retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Release the advisory lock.
155
+ * @param {string} lockPath
156
+ * @param {number} fd - File descriptor to close
157
+ */
158
+ function releaseLock(lockPath, fd) {
159
+ try {
160
+ fs.closeSync(fd);
161
+ } catch (_) {}
162
+ try {
163
+ fs.unlinkSync(lockPath);
164
+ } catch (_) {}
165
+ }
166
+
167
+ /**
168
+ * Simple promise-based sleep.
169
+ * @param {number} ms
170
+ * @returns {Promise<void>}
171
+ */
172
+ function sleep(ms) {
173
+ return new Promise(resolve => setTimeout(resolve, ms));
174
+ }
175
+
176
+ /**
177
+ * Execute asyncFn inside the advisory STATE.md lock.
178
+ *
179
+ * Reentrant within one Node process: if this process already holds the lock
180
+ * (_depth > 0), the inner call simply executes without re-acquiring.
181
+ *
182
+ * @param {Function} asyncFn - Async function to execute under lock
183
+ * @returns {Promise<*>} Result of asyncFn
184
+ */
185
+ async function withStateLock(asyncFn) {
186
+ const lockPath = getLockPath();
187
+
188
+ if (_depth > 0) {
189
+ // Already holding the lock in this process — reentrant call
190
+ _depth++;
191
+ try {
192
+ return await asyncFn();
193
+ } finally {
194
+ _depth--;
195
+ }
196
+ }
197
+
198
+ // Outer call — acquire OS lock
199
+ _lockFd = await acquireLock(lockPath);
200
+ _depth = 1;
201
+
202
+ try {
203
+ return await asyncFn();
204
+ } finally {
205
+ _depth--;
206
+ if (_depth === 0) {
207
+ releaseLock(lockPath, _lockFd);
208
+ _lockFd = null;
209
+ }
210
+ }
211
+ }
212
+
213
+ module.exports = { withStateLock };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * bin/lib/migrate-state.cjs — One-shot idempotent STATE.md schema migration
3
+ *
4
+ * Exports: migrateStateMd(content) → string
5
+ *
6
+ * Transforms legacy STATE.md formats to the new `## Active Specifications` table schema.
7
+ *
8
+ * Handles TWO known legacy formats:
9
+ *
10
+ * Format A (real STATE.md — heading-style):
11
+ * ## Active Specification
12
+ *
13
+ * SPEC-XXX
14
+ *
15
+ * **Status:** running
16
+ * **Next Step:** /sf:review
17
+ *
18
+ * Format B (templates/state.md — bullet-style):
19
+ * - **Active Specification:** [none | SPEC-XXX]
20
+ * - **Status:** ...
21
+ * - **Next Step:** ...
22
+ *
23
+ * Idempotent: if `## Active Specifications` (plural, table) already present,
24
+ * returns content unchanged.
25
+ *
26
+ * The caller is responsible for writing the result back to disk (under withStateLock).
27
+ * Invoked from:
28
+ * - `node bin/sf-tools.cjs state migrate` (explicit)
29
+ * - `/sf:health` on entry (implicit)
30
+ * - `node bin/sf-tools.cjs state list-active` (lazy safety net on first read)
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /**
36
+ * Build the new Active Specifications table section, optionally seeded with
37
+ * one row if we detected a non-"none" spec ID.
38
+ *
39
+ * @param {string|null} specId - Detected legacy spec ID, or null/empty/"none"
40
+ * @param {string|null} status - Legacy status value
41
+ * @param {string|null} nextStep - Legacy next step value
42
+ * @returns {string} New section text (no trailing newline)
43
+ */
44
+ function buildNewSection(specId, status, nextStep) {
45
+ const hasSpec = specId && specId.toLowerCase() !== 'none' && specId !== '—' && specId !== '-';
46
+
47
+ let table =
48
+ '## Active Specifications\n' +
49
+ '\n' +
50
+ '| SPEC-ID | Status | Next Step |\n' +
51
+ '|---------|--------|-----------|';
52
+
53
+ if (hasSpec) {
54
+ const rowStatus = status || 'running';
55
+ const rowNext = nextStep || '/sf:review';
56
+ table += '\n| ' + specId + ' | ' + rowStatus + ' | ' + rowNext + ' |';
57
+ }
58
+
59
+ return table;
60
+ }
61
+
62
+ /**
63
+ * Migrate a STATE.md from legacy format to the new Active Specifications table.
64
+ *
65
+ * Pure function — does NOT read or write files.
66
+ *
67
+ * @param {string} content - Current STATE.md file content
68
+ * @returns {string} Migrated content (unchanged if already new format)
69
+ */
70
+ function migrateStateMd(content) {
71
+ if (!content || typeof content !== 'string') {
72
+ return content || '';
73
+ }
74
+
75
+ // Idempotency check: if new schema already present, return unchanged
76
+ if (content.includes('## Active Specifications')) {
77
+ return content;
78
+ }
79
+
80
+ const lines = content.split('\n');
81
+
82
+ // ─── Format A detection: "## Active Specification" heading-style ──────────
83
+ // Pattern:
84
+ // ## Active Specification
85
+ // <blank>
86
+ // SPEC-XXX (or "—" / empty)
87
+ // <blank>
88
+ // **Status:** ...
89
+ // **Next Step:** ...
90
+
91
+ const formatAIdx = lines.findIndex(l => l.trim() === '## Active Specification');
92
+
93
+ if (formatAIdx !== -1) {
94
+ return migrateFormatA(lines, formatAIdx);
95
+ }
96
+
97
+ // ─── Format B detection: bullet-style "## Current Position" or inline bullets ─
98
+ // Pattern:
99
+ // - **Active Specification:** [none | SPEC-XXX]
100
+ // - **Status:** ...
101
+ // - **Next Step:** ...
102
+
103
+ const bulletIdx = lines.findIndex(l => /^\s*-\s+\*\*Active Specification:\*\*/.test(l));
104
+
105
+ if (bulletIdx !== -1) {
106
+ return migrateFormatB(lines, bulletIdx);
107
+ }
108
+
109
+ // Unknown format — return unchanged (safe fallback)
110
+ return content;
111
+ }
112
+
113
+ /**
114
+ * Migrate Format A (heading-style) STATE.md.
115
+ *
116
+ * @param {string[]} lines
117
+ * @param {number} headingIdx - Line index of "## Active Specification"
118
+ * @returns {string}
119
+ */
120
+ function migrateFormatA(lines, headingIdx) {
121
+ let specId = null;
122
+ let status = null;
123
+ let nextStep = null;
124
+
125
+ // Scan forward from heading to collect values (stop at next ## or end)
126
+ let sectionEnd = lines.length;
127
+ for (let i = headingIdx + 1; i < lines.length; i++) {
128
+ const trimmed = lines[i].trim();
129
+
130
+ if (trimmed.startsWith('## ') && i !== headingIdx) {
131
+ sectionEnd = i;
132
+ break;
133
+ }
134
+
135
+ // Spec ID line: non-empty, not a bold field, not a comment
136
+ if (trimmed && !trimmed.startsWith('**') && !trimmed.startsWith('#') && !trimmed.startsWith('<!--')) {
137
+ if (!specId) specId = trimmed;
138
+ }
139
+
140
+ // Bold field: **Status:** value
141
+ const statusMatch = trimmed.match(/^\*\*Status:\*\*\s*(.+)$/);
142
+ if (statusMatch) status = statusMatch[1].trim();
143
+
144
+ const nextMatch = trimmed.match(/^\*\*Next Step:\*\*\s*(.+)$/);
145
+ if (nextMatch) nextStep = nextMatch[1].trim();
146
+ }
147
+
148
+ const newSection = buildNewSection(specId, status, nextStep);
149
+
150
+ // Replace lines from headingIdx to sectionEnd-1 with new section
151
+ const before = lines.slice(0, headingIdx);
152
+ const after = lines.slice(sectionEnd);
153
+
154
+ // Preserve a blank line before the next section if it exists
155
+ const combined = [...before, ...newSection.split('\n'), '', ...after];
156
+
157
+ return combined.join('\n');
158
+ }
159
+
160
+ /**
161
+ * Migrate Format B (bullet-style) STATE.md.
162
+ *
163
+ * @param {string[]} lines
164
+ * @param {number} bulletIdx - Line index of the "- **Active Specification:**" bullet
165
+ * @returns {string}
166
+ */
167
+ function migrateFormatB(lines, bulletIdx) {
168
+ let specId = null;
169
+ let status = null;
170
+ let nextStep = null;
171
+
172
+ // Extract spec ID from the same line: - **Active Specification:** SPEC-XXX
173
+ const activeLine = lines[bulletIdx];
174
+ const activeMatch = activeLine.match(/\*\*Active Specification:\*\*\s*(.+)$/);
175
+ if (activeMatch) specId = activeMatch[1].trim().replace(/^\[|\]$/g, ''); // strip [...] if present
176
+
177
+ // Look ahead for Status and Next Step bullets (within the same section)
178
+ // Also locate all three bullet lines so we can remove them
179
+ const bulletLines = [bulletIdx];
180
+
181
+ for (let i = bulletIdx + 1; i < Math.min(bulletIdx + 6, lines.length); i++) {
182
+ const trimmed = lines[i].trim();
183
+
184
+ if (trimmed.startsWith('## ')) break; // next section
185
+
186
+ const statusMatch = trimmed.match(/^-\s+\*\*Status:\*\*\s*(.+)$/);
187
+ if (statusMatch) {
188
+ status = statusMatch[1].trim().replace(/^\[|\]$/g, '');
189
+ bulletLines.push(i);
190
+ }
191
+
192
+ const nextMatch = trimmed.match(/^-\s+\*\*Next Step:\*\*\s*(.+)$/);
193
+ if (nextMatch) {
194
+ nextStep = nextMatch[1].trim().replace(/^\[|\]$/g, '');
195
+ bulletLines.push(i);
196
+ }
197
+ }
198
+
199
+ const newSection = buildNewSection(specId, status, nextStep);
200
+
201
+ // Check if there's a parent heading like "## Current Position" immediately before
202
+ // the bullet lines that we should also replace/remove
203
+ let sectionStart = bulletIdx;
204
+ let headingToRemove = null;
205
+
206
+ for (let i = bulletIdx - 1; i >= 0 && i >= bulletIdx - 3; i--) {
207
+ const trimmed = lines[i].trim();
208
+ if (trimmed === '## Current Position') {
209
+ headingToRemove = i;
210
+ sectionStart = i;
211
+ break;
212
+ }
213
+ }
214
+
215
+ // Remove: [parent heading if any] + blank lines + bullet lines
216
+ const linesToRemove = new Set(bulletLines);
217
+ if (headingToRemove !== null) linesToRemove.add(headingToRemove);
218
+
219
+ // Also remove blank lines immediately surrounding the heading
220
+ if (headingToRemove !== null) {
221
+ if (headingToRemove > 0 && lines[headingToRemove - 1].trim() === '') {
222
+ linesToRemove.add(headingToRemove - 1);
223
+ }
224
+ if (headingToRemove + 1 < lines.length && lines[headingToRemove + 1].trim() === '') {
225
+ linesToRemove.add(headingToRemove + 1);
226
+ }
227
+ }
228
+
229
+ // Insert new section at sectionStart position
230
+ const before = lines.slice(0, sectionStart).filter((_, idx) => !linesToRemove.has(idx));
231
+ const removedBefore = lines.slice(0, sectionStart);
232
+ const keptBefore = removedBefore.filter((_, idx) => !linesToRemove.has(idx));
233
+
234
+ const maxBullet = Math.max(...bulletLines);
235
+ const after = lines.slice(maxBullet + 1);
236
+
237
+ const combined = [...keptBefore, ...newSection.split('\n'), '', ...after];
238
+
239
+ return combined.join('\n');
240
+ }
241
+
242
+ module.exports = { migrateStateMd };