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 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 || file.replace('.md', ''),
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 (high > medium > low > unset), then by created date
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.priority === 'high') counts.high++;
316
- else if (t.priority === 'medium') counts.medium++;
317
- else if (t.priority === 'low') counts.low++;
318
- else counts.unset++;
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
- let title = t.title;
337
- if (title.length > 50) title = title.slice(0, 50) + '...';
338
- lines.push(`| ${i + 1} | ${t.id} | ${title} | ${t.priority} | ${t.status} | ${t.created} |`);
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
- lines.push(`**Total:** ${todos.length} items (${counts.high} high, ${counts.medium} medium, ${counts.low} low, ${counts.unset} unset)`);
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
- output({ reindexed: todos.length, path: indexPath }, raw, `Reindexed ${todos.length} TODOs INDEX.md`);
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,
@@ -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.
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specflow-cc",
3
- "version": "1.22.2",
3
+ "version": "1.23.0",
4
4
  "description": "Spec-driven development system for Claude Code — quality-first workflow with explicit audit cycles",
5
5
  "bin": {
6
6
  "specflow-cc": "bin/install.js"