specflow-cc 1.20.1 → 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,20 @@ 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
+
8
22
  ## [1.20.1] - 2026-05-14
9
23
 
10
24
  ### Fixed
@@ -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.
@@ -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/sf-tools.cjs CHANGED
@@ -22,6 +22,8 @@
22
22
  * state remove-active <id> Remove one row from Active Specifications table
23
23
  * state resolve [id] Resolve active spec; emit JSON contract
24
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
25
27
  * resolve-model <agent-type> Model for agent by current profile
26
28
  * verify-structure Check .specflow/ integrity
27
29
  * generate-slug <text> Text to URL-safe slug
@@ -44,6 +46,7 @@ const { cmdSpecLoad, cmdSpecList, cmdSpecNextId } = require('./lib/spec.cjs');
44
46
  const { cmdTodoLoad, cmdTodoList, cmdTodoNextId, cmdTodoReindex, cmdTodoCheckStale } = require('./lib/todo.cjs');
45
47
  const { cmdResolveModel } = require('./lib/config.cjs');
46
48
  const { cmdVerifyStructure } = require('./lib/verify.cjs');
49
+ const { cmdArchiveSummarize, cmdArchiveBackfill } = require('./lib/archive-summary.cjs');
47
50
 
48
51
  const cwd = process.cwd();
49
52
  const args = process.argv.slice(2);
@@ -114,6 +117,14 @@ const COMMANDS = {
114
117
  .catch(e => error(e.message));
115
118
  },
116
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
+
117
128
  'resolve-model': () => {
118
129
  if (!filteredArgs[1]) error('Missing agent type. Usage: resolve-model <agent-type>');
119
130
  cmdResolveModel(cwd, filteredArgs[1], raw);
@@ -147,6 +158,8 @@ Commands:
147
158
  state remove-active <id> Remove one row (under advisory lock)
148
159
  state resolve [SPEC-ID] Resolve active spec; emit JSON contract
149
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
150
163
  resolve-model <agent-type> Resolve model for agent by current profile
151
164
  verify-structure Check .specflow/ directory integrity
152
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specflow-cc",
3
- "version": "1.20.1",
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