specflow-cc 1.22.2 → 1.23.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 +11 -0
- package/bin/lib/todo.cjs +91 -14
- package/commands/sf/done.md +9 -0
- package/commands/sf/plan.md +13 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ 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.23.0] - 2026-05-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Strict TODO frontmatter validation in `cmdTodoReindex` (SPEC-015)** — the indexing chokepoint at [`bin/lib/todo.cjs`](bin/lib/todo.cjs) now enforces a `REQUIRED_TODO_FIELDS = ['id', 'title', 'created']` invariant per TODO file. Files missing any required field (or lacking a frontmatter block entirely) are no longer silently defaulted into the index — they are surfaced as `MALFORMED: <reason>` sentinel rows in `INDEX.md`, sorted after well-formed entries by `fileId` ascending. Each malformed file emits a per-file warning to stderr, the `**Total:**` summary line gains a `… X malformed` component, and `node bin/sf-tools.cjs todo reindex` exits non-zero (`process.exitCode = 1` after `output()` flushes) so callers can gate on it. Well-formed runs remain byte-identical to the legacy format. Validation lives at the call site, not in shared `parseFrontmatter()` — `cmdTodoLoad` / `cmdTodoList` keep their tolerance. 6 new tests in `tests/todo-index.test.cjs` cover the four malformed shapes, malformed-YAML, and a regression guard for legacy parity.
|
|
13
|
+
- **`todo check-stale` exit gate (TODO-029)** — `cmdTodoCheckStale` in [`bin/lib/todo.cjs`](bin/lib/todo.cjs) now sets `process.exitCode = 1` when `INDEX.md` drifts from disk (ghost rows or missing entries). Previously it only reported drift via JSON/text output and always exited 0, so callers could not gate on it.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Ghost-row drift after `/sf:done` and `/sf:plan` finalize (TODO-029)** — the LLM-orchestrated `rm .specflow/todos/{source}.md` + `node bin/sf-tools.cjs todo reindex` sequence in [`commands/sf/done.md`](commands/sf/done.md) Step 7.5 and [`commands/sf/plan.md`](commands/sf/plan.md) Step 7 (plus its inline fallback) had no enforced atomicity — any inversion, interruption, or skipped step left `INDEX.md` with a row pointing at a deleted file, and the only enforcement was `/sf:status` running `check-stale` reactively, days later. Both command files now invoke `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo check-stale` immediately after the `rm` + `reindex` block and treat non-zero exit as a finalization failure (re-run reindex once; if still stale, halt and surface `extra_in_index` / `missing_from_index` to the user). Surfaced in a downstream project (`~/Projects/topgun`) where SPEC-275 finalization left TODO-406 as a ghost in `INDEX.md`, then `/sf:plan TODO-406` operated on it.
|
|
18
|
+
|
|
8
19
|
## [1.22.2] - 2026-05-22
|
|
9
20
|
|
|
10
21
|
### Fixed
|
package/bin/lib/todo.cjs
CHANGED
|
@@ -15,6 +15,13 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { output, error, safeReadFile, parseFrontmatter } = require('./core.cjs');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Required YAML frontmatter fields for each TODO file.
|
|
20
|
+
* When any of these is absent or blank, the reindex records the file as MALFORMED
|
|
21
|
+
* rather than silently defaulting to empty values (which would hide drift).
|
|
22
|
+
*/
|
|
23
|
+
const REQUIRED_TODO_FIELDS = ['id', 'title', 'created'];
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Priority sort order (lower number = higher priority in sort).
|
|
20
27
|
*/
|
|
@@ -284,23 +291,55 @@ function cmdTodoReindex(cwd, raw) {
|
|
|
284
291
|
const parsed = parseFrontmatter(content);
|
|
285
292
|
const fm = parsed.frontmatter;
|
|
286
293
|
|
|
294
|
+
// Determine which required fields are absent or blank.
|
|
295
|
+
const missing = REQUIRED_TODO_FIELDS.filter(k => !fm[k] || String(fm[k]).trim() === '');
|
|
296
|
+
|
|
297
|
+
if (missing.length > 0) {
|
|
298
|
+
// Distinguish "no frontmatter block at all" (fm has no keys) from "some
|
|
299
|
+
// fields present but specific ones missing".
|
|
300
|
+
const hasAnyKey = Object.keys(fm).length > 0;
|
|
301
|
+
const reason = hasAnyKey
|
|
302
|
+
? 'missing fields: ' + missing.join(', ')
|
|
303
|
+
: 'no frontmatter block';
|
|
304
|
+
|
|
305
|
+
process.stderr.write('warn: ' + file + ' — ' + reason + '\n');
|
|
306
|
+
|
|
307
|
+
todos.push({
|
|
308
|
+
malformed: true,
|
|
309
|
+
fileId: file.replace('.md', ''),
|
|
310
|
+
reason,
|
|
311
|
+
});
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
287
315
|
// Strip surrounding quotes from title (YAML may preserve them)
|
|
288
|
-
let title = fm.title
|
|
316
|
+
let title = String(fm.title);
|
|
289
317
|
if ((title.startsWith('"') && title.endsWith('"')) || (title.startsWith("'") && title.endsWith("'"))) {
|
|
290
318
|
title = title.slice(1, -1);
|
|
291
319
|
}
|
|
292
320
|
|
|
293
321
|
todos.push({
|
|
294
|
-
id: fm.id
|
|
322
|
+
id: fm.id,
|
|
295
323
|
title,
|
|
296
324
|
priority: fm.priority || '—',
|
|
297
325
|
status: fm.status || 'open',
|
|
298
|
-
created: fm.created
|
|
326
|
+
created: fm.created,
|
|
299
327
|
});
|
|
300
328
|
}
|
|
301
329
|
|
|
302
|
-
// Sort by priority
|
|
330
|
+
// Sort: well-formed records by priority then created date; malformed records
|
|
331
|
+
// always come after all well-formed ones, ordered by fileId ascending.
|
|
303
332
|
todos.sort((a, b) => {
|
|
333
|
+
const am = a.malformed ? 1 : 0;
|
|
334
|
+
const bm = b.malformed ? 1 : 0;
|
|
335
|
+
if (am !== bm) return am - bm; // well-formed before malformed
|
|
336
|
+
if (a.malformed && b.malformed) {
|
|
337
|
+
// Both malformed: sort by fileId ascending
|
|
338
|
+
if (a.fileId < b.fileId) return -1;
|
|
339
|
+
if (a.fileId > b.fileId) return 1;
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
// Both well-formed: priority then date
|
|
304
343
|
const pa = priorityKey(a.priority);
|
|
305
344
|
const pb = priorityKey(b.priority);
|
|
306
345
|
if (pa !== pb) return pa - pb;
|
|
@@ -309,13 +348,21 @@ function cmdTodoReindex(cwd, raw) {
|
|
|
309
348
|
return 0;
|
|
310
349
|
});
|
|
311
350
|
|
|
312
|
-
// Count by priority
|
|
351
|
+
// Count by priority — malformed records are excluded from priority breakdown.
|
|
313
352
|
const counts = { high: 0, medium: 0, low: 0, unset: 0 };
|
|
353
|
+
let malformedCount = 0;
|
|
314
354
|
for (const t of todos) {
|
|
315
|
-
if (t.
|
|
316
|
-
|
|
317
|
-
else if (t.priority === '
|
|
318
|
-
|
|
355
|
+
if (t.malformed) {
|
|
356
|
+
malformedCount++;
|
|
357
|
+
} else if (t.priority === 'high') {
|
|
358
|
+
counts.high++;
|
|
359
|
+
} else if (t.priority === 'medium') {
|
|
360
|
+
counts.medium++;
|
|
361
|
+
} else if (t.priority === 'low') {
|
|
362
|
+
counts.low++;
|
|
363
|
+
} else {
|
|
364
|
+
counts.unset++;
|
|
365
|
+
}
|
|
319
366
|
}
|
|
320
367
|
|
|
321
368
|
// Build INDEX.md
|
|
@@ -333,13 +380,28 @@ function cmdTodoReindex(cwd, raw) {
|
|
|
333
380
|
|
|
334
381
|
for (let i = 0; i < todos.length; i++) {
|
|
335
382
|
const t = todos[i];
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
383
|
+
if (t.malformed) {
|
|
384
|
+
// Render the MALFORMED sentinel row; truncate the reason if very long.
|
|
385
|
+
let marker = 'MALFORMED: ' + t.reason;
|
|
386
|
+
if (marker.length > 50) marker = marker.slice(0, 50) + '...';
|
|
387
|
+
lines.push(`| ${i + 1} | ${t.fileId} | ${marker} | — | — | — |`);
|
|
388
|
+
} else {
|
|
389
|
+
let title = t.title;
|
|
390
|
+
if (title.length > 50) title = title.slice(0, 50) + '...';
|
|
391
|
+
lines.push(`| ${i + 1} | ${t.id} | ${title} | ${t.priority} | ${t.status} | ${t.created} |`);
|
|
392
|
+
}
|
|
339
393
|
}
|
|
340
394
|
|
|
341
395
|
lines.push('');
|
|
342
|
-
|
|
396
|
+
// Malformed records count toward N items total; they are excluded only from
|
|
397
|
+
// the priority breakdown (H/M/L/unset). When all TODOs are well-formed the
|
|
398
|
+
// summary line is byte-identical to the legacy format so downstream parsers
|
|
399
|
+
// of well-formed runs stay compatible.
|
|
400
|
+
if (malformedCount > 0) {
|
|
401
|
+
lines.push(`**Total:** ${todos.length} items (${counts.high} high, ${counts.medium} medium, ${counts.low} low, ${counts.unset} unset, ${malformedCount} malformed)`);
|
|
402
|
+
} else {
|
|
403
|
+
lines.push(`**Total:** ${todos.length} items (${counts.high} high, ${counts.medium} medium, ${counts.low} low, ${counts.unset} unset)`);
|
|
404
|
+
}
|
|
343
405
|
lines.push('');
|
|
344
406
|
lines.push('---');
|
|
345
407
|
const now = new Date();
|
|
@@ -350,7 +412,15 @@ function cmdTodoReindex(cwd, raw) {
|
|
|
350
412
|
const indexPath = path.join(todosDir, 'INDEX.md');
|
|
351
413
|
fs.writeFileSync(indexPath, lines.join('\n'), 'utf8');
|
|
352
414
|
|
|
353
|
-
|
|
415
|
+
// Signal callers that drift was found — set exit code non-zero AFTER writing
|
|
416
|
+
// INDEX.md (so the file is on disk) and BEFORE the output() call (so JSON
|
|
417
|
+
// still flushes). Do NOT call process.exit() here; Node will use exitCode
|
|
418
|
+
// when the event loop drains.
|
|
419
|
+
if (malformedCount > 0) {
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
output({ reindexed: todos.length, malformed: malformedCount, path: indexPath }, raw, `Reindexed ${todos.length} TODOs → INDEX.md`);
|
|
354
424
|
}
|
|
355
425
|
|
|
356
426
|
/**
|
|
@@ -408,6 +478,13 @@ function cmdTodoCheckStale(cwd, raw) {
|
|
|
408
478
|
extraInIndex.length > 0 ||
|
|
409
479
|
(!indexExists && diskIds.size > 0);
|
|
410
480
|
|
|
481
|
+
// Exit non-zero when stale so callers can use this as a gate after
|
|
482
|
+
// delete-and-reindex sequences (per SPEC TODO-029). Set exitCode before
|
|
483
|
+
// output() so JSON still flushes; do not call process.exit() directly.
|
|
484
|
+
if (stale) {
|
|
485
|
+
process.exitCode = 1;
|
|
486
|
+
}
|
|
487
|
+
|
|
411
488
|
output(
|
|
412
489
|
{
|
|
413
490
|
stale,
|
package/commands/sf/done.md
CHANGED
|
@@ -400,6 +400,15 @@ rm .specflow/todos/{source}.md
|
|
|
400
400
|
node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
|
|
401
401
|
```
|
|
402
402
|
|
|
403
|
+
Then run the stale-check exit gate. The `rm` + `reindex` sequence is LLM-orchestrated and has no machine-enforced atomicity — any inversion, interruption, or skipped step leaves INDEX.md with a ghost row. Treat non-zero exit as a **finalization failure**: re-run `todo reindex`, and if still stale, surface the drift to the user before continuing.
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
node ~/.claude/specflow-cc/bin/sf-tools.cjs todo check-stale
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
- **Exit 0 (FRESH):** proceed to Step 8.
|
|
410
|
+
- **Exit 1 (STALE):** re-run `todo reindex` once; if `check-stale` still exits non-zero, halt finalization and report `extra_in_index` / `missing_from_index` from the JSON output so the user can repair the drift manually.
|
|
411
|
+
|
|
403
412
|
3. **If NOT_FOUND (backward compatibility):** Also check legacy format — look in `.specflow/todos/TODO.md` for the referenced ID. If found there, remove the block using the Edit tool.
|
|
404
413
|
|
|
405
414
|
No "Last updated" lines to update in per-file format.
|
package/commands/sf/plan.md
CHANGED
|
@@ -179,6 +179,15 @@ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
|
|
|
179
179
|
|
|
180
180
|
This is mandatory — skipping it leaves INDEX.md listing a TODO that no longer exists on disk, which trips the `/sf:status` freshness check and breaks downstream consumers.
|
|
181
181
|
|
|
182
|
+
4. **Run the stale-check exit gate.** The `rm` + `reindex` sequence is LLM-orchestrated; this surfaces drift immediately instead of deferring it to the next `/sf:status`.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
node ~/.claude/specflow-cc/bin/sf-tools.cjs todo check-stale
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- **Exit 0 (FRESH):** proceed to Step 8.
|
|
189
|
+
- **Exit 1 (STALE):** re-run `todo reindex` once; if `check-stale` still exits non-zero, halt the conversion and report `extra_in_index` / `missing_from_index` from the JSON output so the user can repair the drift manually.
|
|
190
|
+
|
|
182
191
|
## Step 8: Display Result
|
|
183
192
|
|
|
184
193
|
**IMPORTANT:** Output the following directly as formatted text, NOT wrapped in a markdown code block:
|
|
@@ -240,13 +249,16 @@ Use `/sf:new "{todo description}"` logic:
|
|
|
240
249
|
|
|
241
250
|
### Remove Todo
|
|
242
251
|
|
|
243
|
-
Delete the file `.specflow/todos/TODO-{XXX}.md`, then refresh INDEX.md:
|
|
252
|
+
Delete the file `.specflow/todos/TODO-{XXX}.md`, then refresh INDEX.md and run the stale-check exit gate:
|
|
244
253
|
|
|
245
254
|
```bash
|
|
246
255
|
rm .specflow/todos/TODO-{XXX}.md
|
|
247
256
|
node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
|
|
257
|
+
node ~/.claude/specflow-cc/bin/sf-tools.cjs todo check-stale
|
|
248
258
|
```
|
|
249
259
|
|
|
260
|
+
Treat non-zero exit from `check-stale` as a failure: re-run `todo reindex`, and if still stale, surface the drift to the user before continuing.
|
|
261
|
+
|
|
250
262
|
</fallback>
|
|
251
263
|
|
|
252
264
|
<success_criteria>
|