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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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.0] - 2026-05-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **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
|
|
13
|
+
- Run two specs in parallel: open a second session and either `/sf:next` or `/sf:run SPEC-XXX` — each session resolves its own target
|
|
14
|
+
- Single-spec workflows are unchanged: when only one spec is active, no command requires a SPEC-ID argument
|
|
15
|
+
- **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
|
|
16
|
+
- Includes EPERM-aware stale-PID detection (`isProcessAlive` treats EPERM as alive per POSIX semantics)
|
|
17
|
+
- **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:
|
|
18
|
+
- `{action: "use", id: "SPEC-XXX"}` — N=1 implicit or explicit-match
|
|
19
|
+
- `{action: "ask", options: [...]}` — N>1 with no SPEC-ID provided (commands present an `AskUserQuestion` picker)
|
|
20
|
+
- `{action: "error", code: "NO_ACTIVE_SPEC"}` — N=0
|
|
21
|
+
- `{action: "error", code: "SPEC_NOT_ACTIVE"}` — explicit SPEC-ID not in active table
|
|
22
|
+
- **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
|
|
23
|
+
- **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
|
|
24
|
+
- **`/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
|
|
25
|
+
- 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
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **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`
|
|
30
|
+
- **`templates/state.md`** — replaced single-spec `Active Specification` / `Status` / `Next Step` block with `## Active Specifications` table (`| SPEC-ID | Status | Next Step |`)
|
|
31
|
+
- **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`
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **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`)
|
|
36
|
+
|
|
37
|
+
### Migration notes
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
8
41
|
## [1.19.0] - 2026-04-12
|
|
9
42
|
|
|
10
43
|
### 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 #
|
|
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
|
---
|
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
|
};
|
package/bin/lib/lock.cjs
ADDED
|
@@ -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 };
|