specflow-cc 1.20.0 → 1.21.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,35 @@ 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.21.0] - 2026-05-15
9
+
10
+ ### Added
11
+
12
+ - **L1 archive summary layer** — every archived spec now has a sibling `.specflow/archive/<SPEC-ID>.summary.md` file (~24 lines: goal, key decisions, key files, tests, completion date, link to full spec). Modelled on TencentDB Agent Memory's atomic-facts tier: agents read the summary first and drill down to the full archived spec only when the summary is insufficient. Measured against the existing 22-spec archive: ~94% token reduction when consulting completed-spec history (435-line average full spec → 24-line average summary; ~5.2k → ~0.3k tokens per spec).
13
+ - **`archive summarize <SPEC-ID>` CLI subcommand** in `bin/sf-tools.cjs` — parses an archived spec's frontmatter and `## Goal Analysis` / `## Completion` / `## Delta` sections and writes the `.summary.md` sibling via atomic temp-rename. Falls back to first paragraph of `## Context` for older specs lacking `## Goal Analysis`.
14
+ - **`archive backfill [--force]` CLI subcommand** — iterates `.specflow/archive/SPEC-*.md` and generates missing summaries. Idempotent by default (existing summaries are skipped, zero-diff on second run); `--force` regenerates everything.
15
+ - **`/sf:done` Step 8.5** — automatically generates the L1 summary for every newly archived spec. Non-fatal: summary failure logs a warning but does not abort archival (the full spec is already on disk and `archive backfill` can regenerate later).
16
+ - **Prefer-summary guidance in four agent prompts** — `sf-spec-auditor`, `sf-researcher`, `sf-spec-creator`, and `sf-spec-reviser` now read `<SPEC-ID>.summary.md` first when consulting completed-spec history. Graceful fallback: if no `.summary.md` exists (transitional state during rollout), the agent silently reads the full spec — no error, no warning.
17
+ - `bin/lib/archive-summary.cjs` — pure-Node parser/renderer/generator module (`parseArchivedSpec`, `renderSummary`, `generateSummary`); zero npm dependencies (only `fs`/`path` from stdlib); atomic temp-rename writes consistent with `bin/lib/core.cjs`.
18
+ - `templates/archive-summary.md` — canonical L1 template defining the summary structure; reviewed and stable.
19
+ - `scripts/measure-archive-tokens.cjs` — re-runnable measurement script that scans `.specflow/archive/`, computes average line counts and approximate tokens (lines × ~12 tokens/line), and prints a markdown-formatted ratio report so future contributors can detect regression in the L1 layer's compactness.
20
+ - 11 new tests in `test/archive-summary.test.cjs` covering parser correctness (extracts goal/decisions/keyFiles from fixture specs), older-style spec fallback (no `## Goal Analysis` → goal derived from `## Context`), renderer truncation caps (top 5 decisions, top 6 key files), generator atomic-write behaviour, backfill idempotency, `--force` regeneration, and `archive summarize` error paths.
21
+
22
+ ## [1.20.1] - 2026-05-14
23
+
24
+ ### Fixed
25
+
26
+ - **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.
27
+
28
+ ### Added
29
+
30
+ - **`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.
31
+ - 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).
32
+
33
+ ### Changed
34
+
35
+ - **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`.
36
+
8
37
  ## [1.20.0] - 2026-05-02
9
38
 
10
39
  ### Added
@@ -66,6 +66,8 @@ Use Glob and Grep to find:
66
66
  - Similar implementations
67
67
  - Configuration patterns
68
68
 
69
+ **Reading archived specs:** When the research touches completed specs in `.specflow/archive/`, prefer `<SPEC-ID>.summary.md` over `<SPEC-ID>.md`. The summary is 10–15 lines and surfaces the goal, key decisions, and touched files — sufficient for most research queries. Read the full spec only if the summary lacks the specific detail you need. If `.summary.md` does not exist (transitional state during rollout), fall back gracefully to the full spec.
70
+
69
71
  ## Step 4: External Research (if needed)
70
72
 
71
73
  For topics requiring external knowledge:
@@ -136,6 +136,8 @@ Read `.specflow/PROJECT.md` for:
136
136
  - Patterns (to check alignment)
137
137
  - Constraints (to verify compliance)
138
138
 
139
+ **Reading archived specs:** When you need to consult completed specs (e.g., to check pattern compliance or prior decisions), read `.specflow/archive/<SPEC-ID>.summary.md` first. The summary is 10–15 lines and surfaces the goal, key decisions, and touched files. Open the full `<SPEC-ID>.md` only when the summary does not contain the detail you need. If `.summary.md` does not exist (transitional state during rollout), fall back gracefully to the full spec.
140
+
139
141
  ## Step 3: Audit Dimensions
140
142
 
141
143
  Evaluate each dimension:
@@ -79,6 +79,8 @@ Read the discussion file (PRE-XXX.md or DISC-XXX.md) to understand:
79
79
  - Questions already answered
80
80
  - User preferences and constraints
81
81
 
82
+ **Reading archived specs:** When making assumptions informed by prior decisions, read `.specflow/archive/<SPEC-ID>.summary.md` rather than the full archived spec. The summary is 10–15 lines and surfaces the goal, key decisions, and touched files. Open the full `<SPEC-ID>.md` only when the summary does not contain the specific detail you need. If `.summary.md` does not exist (transitional state during rollout), fall back gracefully to the full spec.
83
+
82
84
  ## Step 2: Analyze Task
83
85
 
84
86
  Parse the user's task description:
@@ -58,6 +58,8 @@ Read `.specflow/STATE.md` to get:
58
58
 
59
59
  Read the full specification file.
60
60
 
61
+ **Reading archived specs:** When making assumptions informed by prior decisions, read `.specflow/archive/<SPEC-ID>.summary.md` rather than the full archived spec. The summary is 10–15 lines and surfaces the goal, key decisions, and touched files. Open the full `<SPEC-ID>.md` only when the summary does not contain the specific detail you need. If `.summary.md` does not exist (transitional state during rollout), fall back gracefully to the full spec.
62
+
61
63
  ## Step 2: Parse Latest Audit
62
64
 
63
65
  Find the most recent "Audit v[N]" section in Audit History.
@@ -176,7 +178,13 @@ For each deferred item:
176
178
  - TODO-{XXX} — {item description}
177
179
  ```
178
180
 
179
- **Important:** This step is mandatory. Every deferred item MUST produce a TODO. If TODO creation fails, report the failure do not silently skip.
181
+ 4. After the loop completes (at least one TODO created), refresh the INDEX.md cache so it reflects the newly-created files:
182
+
183
+ ```bash
184
+ node bin/sf-tools.cjs todo reindex
185
+ ```
186
+
187
+ **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
188
 
181
189
  ## Step 6: Update Frontmatter
182
190
 
@@ -245,6 +253,7 @@ Tip: `/clear` recommended — auditor needs fresh context
245
253
  - [ ] Revision Response recorded in Audit History
246
254
  - [ ] Deferred items (if any) created as individual `.specflow/todos/TODO-XXX.md` files
247
255
  - [ ] TODOs Created subsection appended to Response (if deferred items exist)
256
+ - [ ] INDEX.md refreshed via `node bin/sf-tools.cjs todo reindex` (if any TODO was created)
248
257
  - [ ] Frontmatter status updated
249
258
  - [ ] STATE.md updated
250
259
  - [ ] Clear summary of changes provided
@@ -0,0 +1,508 @@
1
+ /**
2
+ * bin/lib/archive-summary.cjs — L1 archive summary generator
3
+ *
4
+ * Exports:
5
+ * parseArchivedSpec(specPath) → structured summary object
6
+ * renderSummary(parsed, templatePath) → markdown string
7
+ * generateSummary(specPath, templatePath, outputPath) → { written, reason? }
8
+ *
9
+ * No npm dependencies — only fs, path from Node standard library.
10
+ * Caller is responsible for providing correct paths.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Internal helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Parse YAML-style frontmatter from a markdown file.
25
+ * Matches the simple key: value parsing in bin/lib/core.cjs.
26
+ * @param {string} content
27
+ * @returns {{ frontmatter: Object, body: string }}
28
+ */
29
+ function _parseFrontmatter(content) {
30
+ if (!content || typeof content !== 'string') {
31
+ return { frontmatter: {}, body: content || '' };
32
+ }
33
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
34
+ if (!fmMatch) {
35
+ return { frontmatter: {}, body: content };
36
+ }
37
+ const fm = {};
38
+ fmMatch[1].split('\n').forEach(line => {
39
+ const kv = line.match(/^([^:]+):\s*(.*)$/);
40
+ if (kv) {
41
+ fm[kv[1].trim()] = kv[2].trim();
42
+ }
43
+ });
44
+ return { frontmatter: fm, body: fmMatch[2] };
45
+ }
46
+
47
+ /**
48
+ * Extract a named section's content (between the heading and the next same/higher-level heading).
49
+ * @param {string} body - Full body text
50
+ * @param {string} headingText - Exact heading text (without # prefix)
51
+ * @param {number} level - Heading level (2 = ##, 3 = ###)
52
+ * @returns {string|null}
53
+ */
54
+ function _extractSection(body, headingText, level) {
55
+ const hashes = '#'.repeat(level);
56
+ // Match the heading line; content follows until the next heading of same or higher level
57
+ const headingRe = new RegExp(
58
+ `^${hashes}\\s+${headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
59
+ 'm'
60
+ );
61
+ const match = body.match(headingRe);
62
+ if (!match) return null;
63
+ const start = match.index + match[0].length;
64
+ // Next heading at same level or higher (fewer or equal hashes)
65
+ const stopRe = new RegExp(`^#{1,${level}}\\s`, 'm');
66
+ const rest = body.slice(start);
67
+ const stopMatch = rest.match(stopRe);
68
+ return stopMatch ? rest.slice(0, stopMatch.index).trim() : rest.trim();
69
+ }
70
+
71
+ /**
72
+ * Extract bullet list items from a markdown section string.
73
+ * Captures lines that start with `- ` (with optional leading spaces).
74
+ * @param {string} text
75
+ * @returns {string[]}
76
+ */
77
+ function _extractBullets(text) {
78
+ if (!text) return [];
79
+ return text
80
+ .split('\n')
81
+ .filter(l => /^\s*-\s+/.test(l))
82
+ .map(l => l.replace(/^\s*-\s+/, '').trim())
83
+ .filter(l => l.length > 0);
84
+ }
85
+
86
+ /**
87
+ * Get the first non-empty, non-heading paragraph from a text block.
88
+ * @param {string} text
89
+ * @returns {string}
90
+ */
91
+ function _firstParagraph(text) {
92
+ if (!text) return '';
93
+ const lines = text.split('\n');
94
+ const paragraphLines = [];
95
+ let inParagraph = false;
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ if (trimmed.startsWith('#')) continue; // skip headings
99
+ if (trimmed === '') {
100
+ if (inParagraph) break; // end of first paragraph
101
+ continue;
102
+ }
103
+ inParagraph = true;
104
+ paragraphLines.push(trimmed);
105
+ }
106
+ return paragraphLines.join(' ').trim();
107
+ }
108
+
109
+ /**
110
+ * Extract the title from the first # heading in the body.
111
+ * @param {string} body
112
+ * @returns {string}
113
+ */
114
+ function _extractTitle(body) {
115
+ const m = body.match(/^#\s+(.+)$/m);
116
+ return m ? m[1].trim() : '';
117
+ }
118
+
119
+ /**
120
+ * Extract test file references from text.
121
+ * Matches patterns like test/foo.test.cjs
122
+ * @param {string} text
123
+ * @returns {string[]}
124
+ */
125
+ function _extractTestRefs(text) {
126
+ if (!text) return [];
127
+ const matches = text.match(/test\/[^\s,)]+\.test\.cjs/g);
128
+ if (!matches) return [];
129
+ return [...new Set(matches)];
130
+ }
131
+
132
+ /**
133
+ * Extract the completion date from a Completion section.
134
+ * Looks for lines like: **Completed:** YYYY-MM-DD
135
+ * @param {string} completionSection
136
+ * @returns {string}
137
+ */
138
+ function _extractCompletedDate(completionSection) {
139
+ if (!completionSection) return '';
140
+ const m = completionSection.match(/\*\*Completed:\*\*\s*(\d{4}-\d{2}-\d{2})/);
141
+ return m ? m[1] : '';
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Public API
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Parse an archived spec file and return a structured summary object.
150
+ *
151
+ * Field extraction strategy:
152
+ * - goal: from ## Goal Analysis > ### Goal Statement; fallback to first paragraph of ## Context
153
+ * - decisions: from ## Completion > ### Patterns Established bullets;
154
+ * fallback to ## Delta ADDED/MODIFIED bullets (top 5)
155
+ * - keyFiles: from ## Completion > ### Key Files bullets; fallback to ## Delta ADDED bullets (top 6)
156
+ * - tests: test/*.test.cjs refs found in ### Key Files or elsewhere in ## Completion
157
+ * - completed: from ## Completion > **Completed:** date line
158
+ * - title: from first # heading
159
+ *
160
+ * @param {string} specPath - Absolute path to the archived spec file
161
+ * @returns {{
162
+ * specId: string,
163
+ * title: string,
164
+ * type: string,
165
+ * completed: string,
166
+ * goal: string,
167
+ * decisions: string[],
168
+ * keyFiles: Array<{path: string, purpose: string}>,
169
+ * tests: string[]
170
+ * }}
171
+ */
172
+ function parseArchivedSpec(specPath) {
173
+ const content = fs.readFileSync(specPath, 'utf8');
174
+ const { frontmatter, body } = _parseFrontmatter(content);
175
+
176
+ const specId = frontmatter.id || path.basename(specPath, '.md');
177
+ const type = frontmatter.type || 'feature';
178
+
179
+ // Title
180
+ const title = _extractTitle(body);
181
+
182
+ // --- Goal ---
183
+ let goal = '';
184
+ const goalAnalysisSection = _extractSection(body, 'Goal Analysis', 2);
185
+ if (goalAnalysisSection) {
186
+ const goalStatementSection = _extractSection(goalAnalysisSection, 'Goal Statement', 3);
187
+ if (goalStatementSection) {
188
+ goal = _firstParagraph(goalStatementSection);
189
+ }
190
+ }
191
+ if (!goal) {
192
+ // Fallback: first paragraph of ## Context
193
+ const contextSection = _extractSection(body, 'Context', 2);
194
+ goal = _firstParagraph(contextSection || body);
195
+ }
196
+ // Trim goal to a single sentence if possible (stop at first period followed by space or end)
197
+ const sentenceEnd = goal.match(/^([^.!?]+[.!?])/);
198
+ if (sentenceEnd && sentenceEnd[1].length >= 20) {
199
+ goal = sentenceEnd[1].trim();
200
+ }
201
+
202
+ // --- Key Decisions ---
203
+ let decisions = [];
204
+ const completionSection = _extractSection(body, 'Completion', 2);
205
+ if (completionSection) {
206
+ const patternsSection = _extractSection(completionSection, 'Patterns Established', 3);
207
+ if (patternsSection) {
208
+ decisions = _extractBullets(patternsSection);
209
+ }
210
+ }
211
+ if (decisions.length === 0) {
212
+ // Fallback: ADDED/MODIFIED bullets from ## Delta
213
+ const deltaSection = _extractSection(body, 'Delta', 2);
214
+ if (deltaSection) {
215
+ const addedSection = _extractSection(deltaSection, 'ADDED', 3);
216
+ const modifiedSection = _extractSection(deltaSection, 'MODIFIED', 3);
217
+ const addedBullets = _extractBullets(addedSection || '');
218
+ const modifiedBullets = _extractBullets(modifiedSection || '');
219
+ decisions = [...addedBullets, ...modifiedBullets];
220
+ }
221
+ }
222
+ // Cap at 5
223
+ decisions = decisions.slice(0, 5);
224
+
225
+ // --- Key Files ---
226
+ let keyFiles = [];
227
+ if (completionSection) {
228
+ const keyFilesSection = _extractSection(completionSection, 'Key Files', 3);
229
+ if (keyFilesSection) {
230
+ const bullets = _extractBullets(keyFilesSection);
231
+ keyFiles = bullets.map(b => {
232
+ // Format: `path/to/file.cjs` — purpose OR `path/to/file.cjs` purpose
233
+ const dashSplit = b.match(/^`?([^\s`]+)`?\s+[—–-]+\s+(.+)$/);
234
+ if (dashSplit) {
235
+ return { path: dashSplit[1], purpose: dashSplit[2].trim() };
236
+ }
237
+ // Fallback: first token is path, rest is purpose
238
+ const spaceIdx = b.indexOf(' ');
239
+ if (spaceIdx > 0) {
240
+ return { path: b.slice(0, spaceIdx).replace(/`/g, ''), purpose: b.slice(spaceIdx + 1).trim() };
241
+ }
242
+ return { path: b.replace(/`/g, ''), purpose: '' };
243
+ });
244
+ }
245
+ }
246
+ if (keyFiles.length === 0) {
247
+ // Fallback: ADDED bullets from Delta
248
+ const deltaSection = _extractSection(body, 'Delta', 2);
249
+ if (deltaSection) {
250
+ const addedSection = _extractSection(deltaSection, 'ADDED', 3);
251
+ const bullets = _extractBullets(addedSection || '');
252
+ keyFiles = bullets.map(b => {
253
+ const dashSplit = b.match(/^`?([^\s`]+)`?\s+[—–-]+\s+(.+)$/);
254
+ if (dashSplit) {
255
+ return { path: dashSplit[1], purpose: dashSplit[2].trim() };
256
+ }
257
+ const spaceIdx = b.indexOf(' ');
258
+ if (spaceIdx > 0) {
259
+ return { path: b.slice(0, spaceIdx).replace(/`/g, ''), purpose: b.slice(spaceIdx + 1).trim() };
260
+ }
261
+ return { path: b.replace(/`/g, ''), purpose: '' };
262
+ });
263
+ }
264
+ }
265
+ // Cap at 6
266
+ keyFiles = keyFiles.slice(0, 6);
267
+
268
+ // --- Tests ---
269
+ let tests = [];
270
+ if (completionSection) {
271
+ tests = _extractTestRefs(completionSection);
272
+ }
273
+ if (tests.length === 0) {
274
+ tests = _extractTestRefs(body);
275
+ }
276
+
277
+ // --- Completed date ---
278
+ const completed = _extractCompletedDate(completionSection || '') ||
279
+ frontmatter.completed || '';
280
+
281
+ return {
282
+ specId,
283
+ title,
284
+ type,
285
+ completed,
286
+ goal,
287
+ decisions,
288
+ keyFiles,
289
+ tests,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Render a summary markdown string from a parsed spec object and a template file.
295
+ * Substitutes placeholders in the template with actual values.
296
+ * Truncates decisions to top 5 and keyFiles to top 6 (already enforced by parser,
297
+ * but re-enforced here for callers who bypass parseArchivedSpec).
298
+ *
299
+ * @param {{ specId, title, type, completed, goal, decisions, keyFiles, tests }} parsed
300
+ * @param {string} templatePath - Absolute path to templates/archive-summary.md
301
+ * @returns {string} Rendered markdown string
302
+ */
303
+ function renderSummary(parsed, templatePath) {
304
+ const template = fs.readFileSync(templatePath, 'utf8');
305
+
306
+ const {
307
+ specId,
308
+ title,
309
+ type,
310
+ completed,
311
+ goal,
312
+ decisions,
313
+ keyFiles,
314
+ tests,
315
+ } = parsed;
316
+
317
+ // Cap arrays
318
+ const cappedDecisions = (decisions || []).slice(0, 5);
319
+ const cappedKeyFiles = (keyFiles || []).slice(0, 6);
320
+
321
+ // Build decisions bullet list
322
+ const decisionsBlock = cappedDecisions.length > 0
323
+ ? cappedDecisions.map(d => `- ${d}`).join('\n')
324
+ : '- (none recorded)';
325
+
326
+ // Build key files bullet list
327
+ const keyFilesBlock = cappedKeyFiles.length > 0
328
+ ? cappedKeyFiles.map(kf => {
329
+ const p = typeof kf === 'string' ? kf : kf.path;
330
+ const pu = typeof kf === 'string' ? '' : kf.purpose;
331
+ return pu ? `- ${p} — ${pu}` : `- ${p}`;
332
+ }).join('\n')
333
+ : '- (none recorded)';
334
+
335
+ // Tests field
336
+ const testsField = (tests && tests.length > 0) ? tests.join(', ') : 'none';
337
+
338
+ // Substitute all placeholders
339
+ let output = template
340
+ // Frontmatter fields (replace globally after first-pass)
341
+ .replace(/\{SPEC-ID\}/g, specId)
342
+ .replace('{full title}', title || specId)
343
+ .replace('{feature|refactor|bugfix}', type || 'feature')
344
+ .replace('{YYYY-MM-DD}', completed || 'unknown')
345
+ // Goal
346
+ .replace('{one-sentence goal extracted from Goal Statement or Context}', goal || '(not extracted)')
347
+ // Decisions block — replace the 3-line placeholder block
348
+ .replace(
349
+ /^- \{decision 1\}\n- \{decision 2\}\n- \{decision 3\}$/m,
350
+ decisionsBlock
351
+ )
352
+ // Key files block — replace the 2-line placeholder block
353
+ .replace(
354
+ /^- \{path 1\} — \{one-line purpose\}\n- \{path 2\} — \{one-line purpose\}$/m,
355
+ keyFilesBlock
356
+ )
357
+ // Tests
358
+ .replace('{test/foo.test.cjs, ...} or "none"', testsField)
359
+ // Related future specs
360
+ .replace('{list of SPEC-IDs that reference this, or "none yet"}', 'none yet');
361
+
362
+ return output;
363
+ }
364
+
365
+ /**
366
+ * Orchestrate parse → render → write for a single spec.
367
+ * Uses atomic temp-rename pattern consistent with bin/lib/core.cjs.
368
+ *
369
+ * @param {string} specPath - Path to source archived spec (.md)
370
+ * @param {string} templatePath - Path to templates/archive-summary.md
371
+ * @param {string} outputPath - Destination path for the summary (.summary.md)
372
+ * @returns {{ written: boolean, reason?: string }}
373
+ */
374
+ function generateSummary(specPath, templatePath, outputPath) {
375
+ let parsed;
376
+ try {
377
+ parsed = parseArchivedSpec(specPath);
378
+ } catch (e) {
379
+ return { written: false, reason: 'parse failed: ' + e.message };
380
+ }
381
+
382
+ let rendered;
383
+ try {
384
+ rendered = renderSummary(parsed, templatePath);
385
+ } catch (e) {
386
+ return { written: false, reason: 'render failed: ' + e.message };
387
+ }
388
+
389
+ // Atomic temp-rename write
390
+ const tmpPath = outputPath + '.tmp.' + process.pid;
391
+ try {
392
+ fs.writeFileSync(tmpPath, rendered, 'utf8');
393
+ fs.renameSync(tmpPath, outputPath);
394
+ } catch (e) {
395
+ // Clean up temp file if it exists
396
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
397
+ return { written: false, reason: 'write failed: ' + e.message };
398
+ }
399
+
400
+ return { written: true };
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // CLI command implementations
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * CLI handler for `archive summarize <SPEC-ID>`.
409
+ * Generates (or regenerates with --force) a .summary.md for one archived spec.
410
+ *
411
+ * @param {string} cwd - Working directory
412
+ * @param {string} specId - Spec ID (e.g. "SPEC-011")
413
+ * @param {{ force?: boolean }} [opts]
414
+ */
415
+ function cmdArchiveSummarize(cwd, specId, opts) {
416
+ const { force = false } = opts || {};
417
+ const archiveDir = path.join(cwd, '.specflow', 'archive');
418
+ const specPath = path.join(archiveDir, specId + '.md');
419
+ const outputPath = path.join(archiveDir, specId + '.summary.md');
420
+ const templatePath = path.join(cwd, 'templates', 'archive-summary.md');
421
+
422
+ // Validate spec exists in archive
423
+ if (!fs.existsSync(specPath)) {
424
+ process.stderr.write('Error: Spec not found in archive: ' + specPath + '\n');
425
+ process.exit(1);
426
+ }
427
+
428
+ // Skip if summary already exists and not --force
429
+ if (!force && fs.existsSync(outputPath)) {
430
+ process.stdout.write(JSON.stringify({ written: false, reason: 'already exists (use --force to overwrite)', path: outputPath }, null, 2) + '\n');
431
+ return;
432
+ }
433
+
434
+ const result = generateSummary(specPath, templatePath, outputPath);
435
+ if (!result.written) {
436
+ process.stderr.write('Error: Failed to generate summary: ' + result.reason + '\n');
437
+ process.exit(1);
438
+ }
439
+ process.stdout.write(JSON.stringify({ written: true, path: outputPath }, null, 2) + '\n');
440
+ }
441
+
442
+ /**
443
+ * CLI handler for `archive backfill [--force]`.
444
+ * Generates summary files for all archived specs that lack them.
445
+ * With --force, regenerates all summaries.
446
+ *
447
+ * @param {string} cwd - Working directory
448
+ * @param {{ force?: boolean }} [opts]
449
+ */
450
+ function cmdArchiveBackfill(cwd, opts) {
451
+ const { force = false } = opts || {};
452
+ const archiveDir = path.join(cwd, '.specflow', 'archive');
453
+ const templatePath = path.join(cwd, 'templates', 'archive-summary.md');
454
+
455
+ let files;
456
+ try {
457
+ files = fs.readdirSync(archiveDir);
458
+ } catch (e) {
459
+ process.stderr.write('Error: Cannot read archive directory: ' + archiveDir + '\n');
460
+ process.exit(1);
461
+ }
462
+
463
+ // Identify spec files (SPEC-*.md, not *.summary.md)
464
+ const specFiles = files
465
+ .filter(f => f.match(/^SPEC-[A-Z0-9-]+\.md$/) && !f.endsWith('.summary.md'))
466
+ .sort();
467
+
468
+ const results = [];
469
+ let written = 0;
470
+ let skipped = 0;
471
+ let failed = 0;
472
+
473
+ for (const file of specFiles) {
474
+ const specId = file.replace(/\.md$/, '');
475
+ const specPath = path.join(archiveDir, file);
476
+ const outputPath = path.join(archiveDir, specId + '.summary.md');
477
+
478
+ // Skip if summary exists and not --force
479
+ if (!force && fs.existsSync(outputPath)) {
480
+ skipped++;
481
+ results.push({ specId, written: false, reason: 'already exists' });
482
+ continue;
483
+ }
484
+
485
+ const result = generateSummary(specPath, templatePath, outputPath);
486
+ if (result.written) {
487
+ written++;
488
+ results.push({ specId, written: true, path: outputPath });
489
+ } else {
490
+ failed++;
491
+ results.push({ specId, written: false, reason: result.reason });
492
+ }
493
+ }
494
+
495
+ process.stdout.write(JSON.stringify({
496
+ total: specFiles.length,
497
+ written,
498
+ skipped,
499
+ failed,
500
+ results,
501
+ }, null, 2) + '\n');
502
+
503
+ if (failed > 0) {
504
+ process.exit(1);
505
+ }
506
+ }
507
+
508
+ module.exports = { parseArchivedSpec, renderSummary, generateSummary, cmdArchiveSummarize, cmdArchiveBackfill };
package/bin/lib/todo.cjs CHANGED
@@ -295,8 +295,10 @@ function cmdTodoReindex(cwd, raw) {
295
295
  const lines = [
296
296
  '# To-Do Index',
297
297
  '',
298
- '> Auto-generated from individual TODO files. Do not edit manually.',
299
- '> Regenerate with `/sf:todos`.',
298
+ '> Cache of individual TODO files. Refreshed when `/sf:todos` runs OR when an',
299
+ '> INDEX-mutating command explicitly invokes the regen helper',
300
+ '> (`node bin/sf-tools.cjs todo reindex`). Do not edit manually — changes will',
301
+ '> be overwritten on the next regen.',
300
302
  '',
301
303
  '| # | ID | Title | Priority | Status | Created |',
302
304
  '|---|-----|-------|----------|--------|---------|',
@@ -324,9 +326,79 @@ function cmdTodoReindex(cwd, raw) {
324
326
  output({ reindexed: todos.length, path: indexPath }, raw, `Reindexed ${todos.length} TODOs → INDEX.md`);
325
327
  }
326
328
 
329
+ /**
330
+ * Check whether INDEX.md is stale relative to TODO-*.md files.
331
+ *
332
+ * Stale = the set of TODO-XXX IDs on disk diverges from the set of TODO-XXX IDs
333
+ * listed in INDEX.md (file deleted but still in INDEX, or file present but missing).
334
+ *
335
+ * NOTE: Eliminated TODOs (`status: eliminated`) still appear in `/sf:todos --all`
336
+ * regenerated INDEX.md output, so they are NOT filtered here — both sides see them.
337
+ *
338
+ * Output JSON: { stale, missing_from_index, extra_in_index, index_exists }
339
+ * - missing_from_index: file exists on disk but not in INDEX.md
340
+ * - extra_in_index: ID listed in INDEX.md but no file on disk
341
+ *
342
+ * @param {string} cwd - Working directory
343
+ * @param {boolean} raw - Output mode
344
+ */
345
+ function cmdTodoCheckStale(cwd, raw) {
346
+ const todosDir = path.join(cwd, '.specflow', 'todos');
347
+ const indexPath = path.join(todosDir, 'INDEX.md');
348
+
349
+ // Collect IDs from disk
350
+ const diskIds = new Set();
351
+ try {
352
+ for (const f of fs.readdirSync(todosDir)) {
353
+ const m = f.match(/^(TODO-\d+)\.md$/);
354
+ if (m) diskIds.add(m[1]);
355
+ }
356
+ } catch (e) {
357
+ // todos dir missing — treat as empty
358
+ }
359
+
360
+ // Collect IDs referenced in INDEX.md (parse only the table rows)
361
+ const indexIds = new Set();
362
+ const indexContent = safeReadFile(indexPath);
363
+ const indexExists = indexContent !== null;
364
+
365
+ if (indexContent) {
366
+ // Match TODO-XXX in pipe-table cells: "| N | TODO-001 | ..."
367
+ const regex = /\|\s*\d+\s*\|\s*(TODO-\d+)\s*\|/g;
368
+ let m;
369
+ while ((m = regex.exec(indexContent)) !== null) {
370
+ indexIds.add(m[1]);
371
+ }
372
+ }
373
+
374
+ const missingFromIndex = [...diskIds].filter(id => !indexIds.has(id)).sort();
375
+ const extraInIndex = [...indexIds].filter(id => !diskIds.has(id)).sort();
376
+
377
+ // If INDEX.md does not exist but there are TODO files, INDEX is stale.
378
+ // If INDEX.md does not exist and no TODO files, not stale (nothing to track).
379
+ const stale =
380
+ missingFromIndex.length > 0 ||
381
+ extraInIndex.length > 0 ||
382
+ (!indexExists && diskIds.size > 0);
383
+
384
+ output(
385
+ {
386
+ stale,
387
+ index_exists: indexExists,
388
+ todo_count: diskIds.size,
389
+ index_count: indexIds.size,
390
+ missing_from_index: missingFromIndex,
391
+ extra_in_index: extraInIndex,
392
+ },
393
+ raw,
394
+ stale ? 'STALE' : 'FRESH'
395
+ );
396
+ }
397
+
327
398
  module.exports = {
328
399
  cmdTodoLoad,
329
400
  cmdTodoList,
330
401
  cmdTodoNextId,
331
402
  cmdTodoReindex,
403
+ cmdTodoCheckStale,
332
404
  };
package/bin/sf-tools.cjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * todo list [--all] List all TODOs sorted by priority
14
14
  * todo next-id Next available TODO-XXX number
15
15
  * todo reindex Regenerate INDEX.md from TODO files
16
+ * todo check-stale Report drift between TODO-*.md and INDEX.md
16
17
  * queue next First actionable spec from queue
17
18
  * state get Current active spec, status, next step (legacy shim)
18
19
  * state set-active <id> <status> [next] Update active spec in STATE.md (legacy shim)
@@ -21,6 +22,8 @@
21
22
  * state remove-active <id> Remove one row from Active Specifications table
22
23
  * state resolve [id] Resolve active spec; emit JSON contract
23
24
  * state migrate One-shot idempotent migration to new schema
25
+ * archive summarize <SPEC-ID> Generate L1 summary for one archived spec
26
+ * archive backfill [--force] Generate missing summaries for all archived specs
24
27
  * resolve-model <agent-type> Model for agent by current profile
25
28
  * verify-structure Check .specflow/ integrity
26
29
  * generate-slug <text> Text to URL-safe slug
@@ -40,9 +43,10 @@ const {
40
43
  cmdQueueNext,
41
44
  } = require('./lib/state.cjs');
42
45
  const { cmdSpecLoad, cmdSpecList, cmdSpecNextId } = require('./lib/spec.cjs');
43
- const { cmdTodoLoad, cmdTodoList, cmdTodoNextId, cmdTodoReindex } = require('./lib/todo.cjs');
46
+ const { cmdTodoLoad, cmdTodoList, cmdTodoNextId, cmdTodoReindex, cmdTodoCheckStale } = require('./lib/todo.cjs');
44
47
  const { cmdResolveModel } = require('./lib/config.cjs');
45
48
  const { cmdVerifyStructure } = require('./lib/verify.cjs');
49
+ const { cmdArchiveSummarize, cmdArchiveBackfill } = require('./lib/archive-summary.cjs');
46
50
 
47
51
  const cwd = process.cwd();
48
52
  const args = process.argv.slice(2);
@@ -75,6 +79,7 @@ const COMMANDS = {
75
79
  'todo list': () => cmdTodoList(cwd, raw, { showAll: flags.all ?? false }),
76
80
  'todo next-id': () => cmdTodoNextId(cwd, raw),
77
81
  'todo reindex': () => cmdTodoReindex(cwd, raw),
82
+ 'todo check-stale': () => cmdTodoCheckStale(cwd, raw),
78
83
  'queue next': () => cmdQueueNext(cwd, raw),
79
84
 
80
85
  // Legacy shims (backwards compatible)
@@ -112,6 +117,14 @@ const COMMANDS = {
112
117
  .catch(e => error(e.message));
113
118
  },
114
119
 
120
+ 'archive summarize': () => {
121
+ if (!filteredArgs[2]) error('Missing SPEC-ID. Usage: archive summarize <SPEC-ID>');
122
+ cmdArchiveSummarize(cwd, filteredArgs[2], { force: flags.force });
123
+ },
124
+ 'archive backfill': () => {
125
+ cmdArchiveBackfill(cwd, { force: flags.force });
126
+ },
127
+
115
128
  'resolve-model': () => {
116
129
  if (!filteredArgs[1]) error('Missing agent type. Usage: resolve-model <agent-type>');
117
130
  cmdResolveModel(cwd, filteredArgs[1], raw);
@@ -136,6 +149,7 @@ Commands:
136
149
  todo list [--all] List TODOs sorted by priority (--all includes eliminated)
137
150
  todo next-id Next available TODO-XXX number
138
151
  todo reindex Regenerate INDEX.md from TODO files
152
+ todo check-stale Report drift between TODO-*.md and INDEX.md
139
153
  queue next First actionable spec from queue table
140
154
  state get Current active spec, status, next step (legacy shim)
141
155
  state set-active <id> <status> [next] Update active spec, status, next step (legacy shim)
@@ -144,6 +158,8 @@ Commands:
144
158
  state remove-active <id> Remove one row (under advisory lock)
145
159
  state resolve [SPEC-ID] Resolve active spec; emit JSON contract
146
160
  state migrate One-shot idempotent migration to new schema
161
+ archive summarize <SPEC-ID> Generate L1 summary for one archived spec
162
+ archive backfill [--force] Generate missing summaries for all archived specs
147
163
  resolve-model <agent-type> Resolve model for agent by current profile
148
164
  verify-structure Check .specflow/ directory integrity
149
165
  generate-slug <text> Convert text to URL-safe slug
@@ -318,6 +318,16 @@ Move spec to archive:
318
318
  mv .specflow/specs/SPEC-XXX.md .specflow/archive/
319
319
  ```
320
320
 
321
+ ## Step 8.5: Generate L1 Summary
322
+
323
+ Generate a compact summary of the just-archived spec for agent consumption:
324
+
325
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs archive summarize SPEC-XXX
326
+
327
+ On success, `.specflow/archive/SPEC-XXX.summary.md` exists.
328
+
329
+ If the command fails (parser cannot extract required fields), log a warning to the completion summary but do NOT abort archival — the full spec is already archived and the summary can be regenerated later via `node ~/.claude/specflow-cc/bin/sf-tools.cjs archive backfill`.
330
+
321
331
  ## Step 9: Update STATE.md
322
332
 
323
333
  ### Remove from Active Specifications Table
@@ -457,6 +467,7 @@ git commit -m "docs(sf): complete SPEC-XXX"
457
467
  - [ ] Decisions extracted (if any)
458
468
  - [ ] Source TODO file deleted (if `source:` field exists in spec and file exists in todos/)
459
469
  - [ ] Spec moved to archive
470
+ - [ ] L1 summary file created at .specflow/archive/SPEC-XXX.summary.md (or warning logged)
460
471
  - [ ] STATE.md updated (cleared active, removed from queue)
461
472
  - [ ] Final commit created
462
473
  - [ ] Clear completion summary shown
@@ -13,7 +13,7 @@ Migrate an existing monolithic `.specflow/todos/TODO.md` to the new per-file for
13
13
  This is a one-time migration command. After migration:
14
14
  - `TODO.md` is renamed to `TODO.md.bak` (NOT deleted — safety net)
15
15
  - Each TODO becomes its own `TODO-XXX.md` file
16
- - `INDEX.md` is generated from the new files
16
+ - `INDEX.md` is regenerated from the new files via the shared `todo reindex` helper
17
17
  - All other commands will use the new per-file format automatically
18
18
 
19
19
  Use `--dry-run` to preview the migration without writing any files.
@@ -173,24 +173,14 @@ created: {YYYY-MM-DD}
173
173
 
174
174
  ## Step 7: Generate INDEX.md
175
175
 
176
- Write `.specflow/todos/INDEX.md` with all migrated TODOs, sorted by priority then date:
176
+ Invoke the shared regen helper to build `.specflow/todos/INDEX.md` from the migrated files:
177
177
 
178
- ```markdown
179
- # To-Do Index
180
-
181
- > Auto-generated from individual TODO files. Do not edit manually.
182
- > Regenerate with `/sf:todos`.
183
-
184
- | # | ID | Title | Priority | Status | Created |
185
- |---|-----|-------|----------|--------|---------|
186
- {one row per TODO, sorted by priority then created date}
187
-
188
- **Total:** {N} items ({high} high, {medium} medium, {low} low, {unset} unset)
189
-
190
- ---
191
- *Last regenerated: {YYYY-MM-DD HH:MM}*
178
+ ```bash
179
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
192
180
  ```
193
181
 
182
+ Do NOT write INDEX.md inline — the helper is the single source of truth for its layout (see `templates/todo-index.md`).
183
+
194
184
  ## Step 8: Rename Legacy TODO.md
195
185
 
196
186
  ```bash
@@ -245,7 +235,7 @@ Migrated {N} TODOs from TODO.md to per-file format.
245
235
  - [ ] Individual TODO-XXX.md files created for each block
246
236
  - [ ] Each file has valid YAML frontmatter (id, title, priority, status, created)
247
237
  - [ ] Title derived from description (first sentence, ~80 chars)
248
- - [ ] INDEX.md generated from migrated files
238
+ - [ ] INDEX.md regenerated via `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex`
249
239
  - [ ] TODO.md renamed to TODO.md.bak (NOT deleted)
250
240
  - [ ] Clear migration summary shown
251
241
  - [ ] Cleanup instructions provided
@@ -171,6 +171,14 @@ rm .specflow/todos/TODO-{XXX}.md
171
171
 
172
172
  **Important:** Only remove after confirmed spec creation. No "Last updated" lines to update.
173
173
 
174
+ 3. **Refresh INDEX.md** via the shared regen helper so the cache no longer references the removed file:
175
+
176
+ ```bash
177
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
178
+ ```
179
+
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
+
174
182
  ## Step 8: Display Result
175
183
 
176
184
  **IMPORTANT:** Output the following directly as formatted text, NOT wrapped in a markdown code block:
@@ -232,7 +240,12 @@ Use `/sf:new "{todo description}"` logic:
232
240
 
233
241
  ### Remove Todo
234
242
 
235
- Delete the file `.specflow/todos/TODO-{XXX}.md`.
243
+ Delete the file `.specflow/todos/TODO-{XXX}.md`, then refresh INDEX.md:
244
+
245
+ ```bash
246
+ rm .specflow/todos/TODO-{XXX}.md
247
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
248
+ ```
236
249
 
237
250
  </fallback>
238
251
 
@@ -246,5 +259,6 @@ Delete the file `.specflow/todos/TODO-{XXX}.md`.
246
259
  - [ ] Priority inherited from todo
247
260
  - [ ] TODO-XXX.md file deleted (not edited — whole file removed)
248
261
  - [ ] Deletion verified (file no longer exists)
262
+ - [ ] INDEX.md refreshed via `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex`
249
263
  - [ ] Clear result with next step
250
264
  </success_criteria>
@@ -123,7 +123,12 @@ If input matches pattern `{ID}={priority}`:
123
123
 
124
124
  1. Validate priority (high | medium | low)
125
125
  2. **If ID is a spec (SPEC-XXX):** Update `priority:` in frontmatter of `.specflow/specs/SPEC-XXX.md` using the Edit tool
126
- 3. **If ID is a TODO (TODO-XXX):** Read `.specflow/todos/TODO-XXX.md`, update `priority:` line in frontmatter using the Edit tool
126
+ 3. **If ID is a TODO (TODO-XXX):**
127
+ a. Read `.specflow/todos/TODO-XXX.md`, update `priority:` line in frontmatter using the Edit tool
128
+ b. Refresh INDEX.md so the cached priority column matches:
129
+ ```bash
130
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
131
+ ```
127
132
  4. Display confirmation
128
133
  5. Return to Step 3
129
134
 
@@ -203,6 +208,7 @@ Use `/sf:next` to work on highest priority task.
203
208
  - [ ] Reorder command works
204
209
  - [ ] Technical order suggestion available
205
210
  - [ ] TODO priority updated in individual file frontmatter (not TODO.md)
211
+ - [ ] INDEX.md refreshed via `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex` after any TODO priority change
206
212
  - [ ] STATE.md Queue updated after spec reorder
207
213
  - [ ] Clear feedback on changes
208
214
  </success_criteria>
@@ -511,8 +511,12 @@ After recording the Response, if any items were marked "Deferred":
511
511
  Origin: {SPEC-XXX} Response v{N}. {reason for deferral}
512
512
  ```
513
513
  3. Append "**TODOs Created:**" subsection to the Response in Audit History listing created TODO IDs
514
+ 4. After the loop completes (at least one TODO created), refresh INDEX.md:
515
+ ```bash
516
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
517
+ ```
514
518
 
515
- **This step is mandatory.** Every "Deferred" decision MUST produce a corresponding TODO-XXX.md file.
519
+ **This step is mandatory.** Every "Deferred" decision MUST produce a corresponding TODO-XXX.md file AND the reindex helper MUST run if any TODO was created — otherwise INDEX.md silently drifts out of sync with `todos/`.
516
520
 
517
521
  ### Update Status
518
522
 
@@ -532,6 +536,7 @@ node bin/sf-tools.cjs state add-active SPEC-XXX auditing /sf:audit
532
536
  - [ ] Changes applied correctly
533
537
  - [ ] Response recorded in Audit History
534
538
  - [ ] Deferred items (if any) created as individual TODO-XXX.md files in `.specflow/todos/`
539
+ - [ ] INDEX.md refreshed via `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex` (if any TODO was created)
535
540
  - [ ] Spec frontmatter status updated
536
541
  - [ ] STATE.md updated
537
542
  - [ ] Clear summary of changes shown
@@ -103,6 +103,30 @@ Likely cause: Spec numbering bug - archive was not checked when generating ID.
103
103
  Fix: Rename the spec in specs/ to next available ID.
104
104
  ```
105
105
 
106
+ ### TODO Index Freshness
107
+
108
+ Compare the set of TODO files on disk to the IDs listed in `.specflow/todos/INDEX.md`:
109
+
110
+ ```bash
111
+ node bin/sf-tools.cjs todo check-stale
112
+ ```
113
+
114
+ Parse the JSON response. If `stale: true`, add a warning to the Warnings section:
115
+
116
+ ```
117
+ INDEX.md stale — run /sf:todos (or `node bin/sf-tools.cjs todo reindex`).
118
+ {If missing_from_index is non-empty:}
119
+ Missing from INDEX.md (TODO file exists on disk but not listed):
120
+ {comma-separated list}
121
+ {If extra_in_index is non-empty:}
122
+ Stale entries in INDEX.md (listed but TODO file no longer exists):
123
+ {comma-separated list}
124
+ {If !index_exists and todo_count > 0:}
125
+ INDEX.md does not exist yet but {todo_count} TODO file(s) are on disk.
126
+ ```
127
+
128
+ This is a safety net — every command that mutates `todos/` is supposed to call the regen helper itself, but external edits, manual `rm`, or a missed call will surface here. Do NOT auto-fix from `/sf:status`; only report. The user runs `/sf:todos` (or the helper directly) to clear the warning.
129
+
106
130
  ## Step 5: Determine Next Action
107
131
 
108
132
  Based on current status:
@@ -208,6 +232,7 @@ Based on state, provide additional guidance:
208
232
  - [ ] STATE.md loaded
209
233
  - [ ] PROJECT.md info extracted
210
234
  - [ ] Statistics calculated
235
+ - [ ] TODO index freshness checked via `node bin/sf-tools.cjs todo check-stale` (warning surfaced if stale)
211
236
  - [ ] Current position displayed
212
237
  - [ ] Queue shown
213
238
  - [ ] Recommended next step clear
@@ -8,7 +8,7 @@ allowed-tools:
8
8
  ---
9
9
 
10
10
  <purpose>
11
- Display all to-do items from the backlog, sorted by priority. Reads individual TODO-XXX.md files (or legacy TODO.md for backward compatibility). Writes an auto-generated INDEX.md after display. Provides quick access to convert items to specifications.
11
+ Display all to-do items from the backlog, sorted by priority. Reads individual TODO-XXX.md files (or legacy TODO.md for backward compatibility). Refreshes the INDEX.md cache via the shared regen helper after display. Provides quick access to convert items to specifications.
12
12
  </purpose>
13
13
 
14
14
  <context>
@@ -109,27 +109,15 @@ From the list:
109
109
 
110
110
  ## Step 6: Regenerate INDEX.md
111
111
 
112
- After displaying the list, write `.specflow/todos/INDEX.md` using the Write tool.
112
+ After displaying the list, regenerate `.specflow/todos/INDEX.md` by invoking the shared helper:
113
113
 
114
- Use the format from `templates/todo-index.md`:
115
-
116
- ```markdown
117
- # To-Do Index
118
-
119
- > Auto-generated from individual TODO files. Do not edit manually.
120
- > Regenerate with `/sf:todos`.
121
-
122
- | # | ID | Title | Priority | Status | Created |
123
- |---|-----|-------|----------|--------|---------|
124
- {rows from sorted list — one row per TODO}
125
-
126
- **Total:** {N} items ({high} high, {medium} medium, {low} low, {unset} unset)
127
-
128
- ---
129
- *Last regenerated: {YYYY-MM-DD HH:MM}*
114
+ ```bash
115
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
130
116
  ```
131
117
 
132
- **Important:** INDEX.md is a display cache only. Never edit it manually it is regenerated here each time `/sf:todos` runs.
118
+ The helper scans `.specflow/todos/TODO-*.md`, sorts the rows the same way Step 2 does, and writes INDEX.md in the format defined by `templates/todo-index.md`. It is the single source of truth for INDEX layout do NOT write INDEX.md manually here.
119
+
120
+ **Important:** INDEX.md is a cache. Other commands that mutate `todos/` (`/sf:todo`, `/sf:plan`, `/sf:done`, `/sf:triage`, `/sf:revise`, `/sf:priority`, `/sf:migrate-todos`, and the `sf-spec-reviser` agent) must call the same helper after their mutation so the cache stays consistent between `/sf:todos` invocations. `/sf:status` runs `todo check-stale` and warns if drift is detected.
133
121
 
134
122
  </workflow>
135
123
 
@@ -142,6 +130,6 @@ Use the format from `templates/todo-index.md`:
142
130
  - [ ] `--all` flag shows eliminated items visually distinct
143
131
  - [ ] Statistics shown (total, by priority)
144
132
  - [ ] Clear actions provided
145
- - [ ] INDEX.md written to `.specflow/todos/INDEX.md` after display
146
- - [ ] INDEX.md contains "Do not edit manually" notice
133
+ - [ ] INDEX.md regenerated via `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex` after display
134
+ - [ ] INDEX.md header describes it as a cache refreshed by `/sf:todos` or the regen helper
147
135
  </success_criteria>
@@ -188,6 +188,16 @@ created: {YYYY-MM-DD}
188
188
 
189
189
  Do NOT append to TODO.md. Do NOT update any "Last updated" lines. Each finding gets its own separate TODO-XXX.md file.
190
190
 
191
+ ### 6.3 Refresh INDEX.md
192
+
193
+ After all selected TODO files have been written, regenerate the cache once:
194
+
195
+ ```bash
196
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex
197
+ ```
198
+
199
+ This is mandatory whenever the triage loop creates at least one TODO. Skipping it leaves INDEX.md missing the just-created entries until the next `/sf:todos` run.
200
+
191
201
  ## Step 7: Display Results
192
202
 
193
203
  **IMPORTANT:** Output the following directly as formatted text, NOT wrapped in a markdown code block:
@@ -255,5 +265,6 @@ Run /sf:triage again to review findings.
255
265
  - [ ] Each file has valid YAML frontmatter (id, title, priority, status, created)
256
266
  - [ ] Priority preserved from scan
257
267
  - [ ] Source reference included in notes (scan date, files, problem)
268
+ - [ ] INDEX.md refreshed via `node ~/.claude/specflow-cc/bin/sf-tools.cjs todo reindex` (skip only if zero TODOs created)
258
269
  - [ ] Clear summary of created TODOs
259
270
  </success_criteria>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specflow-cc",
3
- "version": "1.20.0",
3
+ "version": "1.21.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"
@@ -0,0 +1,25 @@
1
+ ---
2
+ spec_id: {SPEC-ID}
3
+ title: {full title}
4
+ type: {feature|refactor|bugfix}
5
+ completed: {YYYY-MM-DD}
6
+ ---
7
+
8
+ # {SPEC-ID} Summary
9
+
10
+ **Goal:** {one-sentence goal extracted from Goal Statement or Context}
11
+
12
+ **Key Decisions:**
13
+ - {decision 1}
14
+ - {decision 2}
15
+ - {decision 3}
16
+
17
+ **Key Files:**
18
+ - {path 1} — {one-line purpose}
19
+ - {path 2} — {one-line purpose}
20
+
21
+ **Tests:** {test/foo.test.cjs, ...} or "none"
22
+
23
+ **Full Spec:** [.specflow/archive/{SPEC-ID}.md](./{SPEC-ID}.md)
24
+
25
+ **Related Future Specs:** none yet
@@ -1,7 +1,9 @@
1
1
  # To-Do Index
2
2
 
3
- > Auto-generated from individual TODO files. Do not edit manually.
4
- > Regenerate with `/sf:todos`.
3
+ > Cache of individual TODO files. Refreshed when `/sf:todos` runs OR when an
4
+ > INDEX-mutating command explicitly invokes the regen helper
5
+ > (`node bin/sf-tools.cjs todo reindex`). Do not edit manually — changes will
6
+ > be overwritten on the next regen.
5
7
 
6
8
  | # | ID | Title | Priority | Status | Created |
7
9
  |---|-----|-------|----------|--------|---------|