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 +14 -0
- package/agents/researcher.md +2 -0
- package/agents/spec-auditor.md +2 -0
- package/agents/spec-creator.md +2 -0
- package/agents/spec-reviser.md +2 -0
- package/bin/lib/archive-summary.cjs +508 -0
- package/bin/sf-tools.cjs +13 -0
- package/commands/sf/done.md +11 -0
- package/package.json +1 -1
- package/templates/archive-summary.md +25 -0
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
|
package/agents/researcher.md
CHANGED
|
@@ -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:
|
package/agents/spec-auditor.md
CHANGED
|
@@ -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:
|
package/agents/spec-creator.md
CHANGED
|
@@ -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:
|
package/agents/spec-reviser.md
CHANGED
|
@@ -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
|
package/commands/sf/done.md
CHANGED
|
@@ -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
|
@@ -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
|