specflow-cc 1.20.1 → 1.22.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,36 @@ 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.22.0] - 2026-05-19
9
+
10
+ ### Added
11
+
12
+ - **`Recommendation:` line on `/sf:audit` and `/sf:review` output** — after every audit or review, the Next Step block now includes a deterministic `**Recommendation:** {action} — {reason}` line (e.g. `done — implementation is clean, ready to finalize`, `run --apply=minor — 2 non-blocking recommendations, apply inline`, `revise — 1 critical issue blocks execution`). Removes the "what next" guesswork when the result has only non-blocking findings. Emitted by `sf-spec-auditor` and `sf-impl-reviewer` agents via a new Step 7.5 that shells out to `node bin/sf-tools.cjs recommend`. STATE.md's canonical `Next Step` is unchanged — the Recommendation line is advisory in agent output only.
13
+ - **`--apply=minor` flag on `/sf:done` and `/sf:run`** — quick-fix path for non-blocking findings. `/sf:done --apply=minor` (review path): requires spec status `review` with only Minor findings; invokes `/sf:fix --internal` to apply each finding as an atomic commit; runs project test + lint gate; on success, proceeds to standard finalization. `/sf:run --apply=minor` (audit path): requires status `audited` with only Recommendations; invokes `/sf:revise --internal`; runs structural `spec validate` gate; on success, proceeds to standard execution. **No second audit/review cycle is invoked.** Both refuse with a clear error when Critical/Major findings exist. On gate failure: applied commits remain in git, but STATE.md status is unchanged (sole rollback signal).
14
+ - **`recommend` CLI subcommand** in `bin/sf-tools.cjs` — pure mapping module exposed via `node bin/sf-tools.cjs recommend --source <audit|review> --critical N --major N --minor N`. Emits JSON `{action, reason}` to stdout. Single source of truth for the recommendation truth-table consumed by both agents and `--apply=minor` callers.
15
+ - **`spec validate` CLI subcommand** in `bin/sf-tools.cjs` — lightweight structural validation (frontmatter parses, required fields present, `## Requirements` heading present). Exits 0 on success with no stdout; exits 1 with `Error: spec validation failed: {reason}` on stderr. Used by `/sf:run --apply=minor` as the post-revise integrity gate (distinct from content-driven `/sf:audit`).
16
+ - **`--internal` flag on `/sf:fix` and `/sf:revise`** — symmetric guard that suppresses the Step 8 `state add-active` STATE.md mutation. Lets `/sf:done --apply=minor` and `/sf:run --apply=minor` shell out to the existing fix/revise machinery without losing control of the lifecycle transition. Reusable pattern for future composite commands.
17
+ - `bin/lib/recommend.cjs` — pure 57-line `recommend({source, critical, major, minor})` → `{action, reason}` function; no I/O, no state mutation. Zero new runtime npm dependencies — Node.js built-ins only.
18
+ - 39 new tests across `test/recommend.test.cjs` (28 tests: truth-table coverage, CLI integration, error cases) and `test/spec-validate.test.cjs` (11 tests: success + all five failure modes). Full suite: 93/93 pass.
19
+
20
+ ### Fixed
21
+
22
+ - **Brittle hardcoded-spec test in spec-validate suite** — removed a test that referenced a live `SPEC-013.md` file, which broke the moment SPEC-013 was archived (the normal end of every spec's lifecycle). The success path is fully covered by an adjacent temp-fixture test, so the duplicate was removed rather than rewritten.
23
+
24
+ ## [1.21.0] - 2026-05-15
25
+
26
+ ### Added
27
+
28
+ - **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).
29
+ - **`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`.
30
+ - **`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.
31
+ - **`/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).
32
+ - **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.
33
+ - `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`.
34
+ - `templates/archive-summary.md` — canonical L1 template defining the summary structure; reviewed and stable.
35
+ - `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.
36
+ - 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.
37
+
8
38
  ## [1.20.1] - 2026-05-14
9
39
 
10
40
  ### Fixed
@@ -283,6 +283,31 @@ Append to specification's Review History:
283
283
  **Summary:** {Brief overall assessment}
284
284
  ```
285
285
 
286
+ ## Step 7.5: Emit Recommendation
287
+
288
+ Using the Critical, Major, and Minor counts determined in Step 5:
289
+
290
+ 1. Shell out to obtain the recommendation:
291
+ ```
292
+ node bin/sf-tools.cjs recommend --source review --critical <N> --major <M> --minor <K>
293
+ ```
294
+
295
+ 2. Parse the JSON response: `{ "action": "...", "reason": "..." }`
296
+
297
+ 3. In the REVIEW RESULT output block (within the "Next Step" section), emit:
298
+ ```
299
+ **Recommendation:** {action} — {reason}
300
+ ```
301
+
302
+ 4. Also append the same line to the Review History entry (in Step 7) below the existing fields:
303
+ ```
304
+ **Recommendation:** {action}
305
+ ```
306
+
307
+ **Verb mapping per source:** For `source=review`, blocker verb is `fix` (matches STATE.md canonical `/sf:fix`); non-blocker verbs are `done` / `done --apply=minor`. This asymmetry is deliberate: Recommendation verbs align with the existing per-path canonical commands.
308
+
309
+ **Note:** STATE.md Next Step (Step 8) continues to use the canonical command (`/sf:done`, `/sf:fix`) without any `--apply=minor` suffix. The Recommendation line is advisory and appears only in agent output and review history.
310
+
286
311
  ## Step 8: Update STATE.md
287
312
 
288
313
  - If APPROVED: Status → "done", Next Step → "/sf:done"
@@ -344,14 +369,21 @@ Output directly as formatted text (not wrapped in a code block):
344
369
  ## Next Step
345
370
 
346
371
  {If APPROVED with NO minor issues:}
372
+ **Recommendation:** {action} — {reason}
373
+
347
374
  `/sf:done` — finalize and archive
348
375
 
349
376
  {If APPROVED WITH minor issues:}
377
+ **Recommendation:** {action} — {reason}
378
+
350
379
  Choose one:
351
380
  • `/sf:done` — finalize as-is (minor issues are optional)
352
381
  • `/sf:fix` — address minor issues first
382
+ • `/sf:done --apply=minor` — apply minor fixes inline and finalize in one step
353
383
 
354
384
  {If CHANGES_REQUESTED:}
385
+ **Recommendation:** {action} — {reason}
386
+
355
387
  `/sf:fix` — address issues
356
388
 
357
389
  Options:
@@ -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:
@@ -768,6 +770,30 @@ N+1. [recommendation]
768
770
  **Comment:** [Brief positive note about spec quality]
769
771
  ```
770
772
 
773
+ ## Step 7.5: Emit Recommendation
774
+
775
+ Using the Critical count and Recommendations count determined in Step 5:
776
+
777
+ 1. Shell out to obtain the recommendation:
778
+ ```
779
+ node bin/sf-tools.cjs recommend --source audit --critical <N> --minor <M>
780
+ ```
781
+ Note: The CLI flag is `--minor` even though the auditor uses the label "Recommendations" — this is intentional for parser symmetry across audit/review sources.
782
+
783
+ 2. Parse the JSON response: `{ "action": "...", "reason": "..." }`
784
+
785
+ 3. In the AUDIT RESULT output block (within the "Next Step" section), emit:
786
+ ```
787
+ **Recommendation:** {action} — {reason}
788
+ ```
789
+
790
+ 4. Also append the same line to the Audit History entry (in Step 7) below the existing fields:
791
+ ```
792
+ **Recommendation:** {action}
793
+ ```
794
+
795
+ **Note:** STATE.md Next Step (Step 8) continues to use the canonical command (`/sf:run`, `/sf:revise`, `/sf:split`) without any `--apply=minor` suffix. The Recommendation line is advisory and appears only in agent output and audit history.
796
+
771
797
  ## Step 8: Update STATE.md
772
798
 
773
799
  Update status:
@@ -824,6 +850,8 @@ Output directly as formatted text (not wrapped in a code block):
824
850
 
825
851
  ### Next Step
826
852
 
853
+ **Recommendation:** {action} — {reason}
854
+
827
855
  Choose one:
828
856
  - `/sf:run --parallel` — execute with subagent orchestration
829
857
  - `/sf:split` — decompose into smaller specs
@@ -844,6 +872,8 @@ Choose one:
844
872
 
845
873
  ### Next Step
846
874
 
875
+ **Recommendation:** {action} — {reason}
876
+
847
877
  `/sf:revise` — address critical issues
848
878
 
849
879
  ---
@@ -856,6 +886,8 @@ Choose one:
856
886
 
857
887
  ### Next Step
858
888
 
889
+ **Recommendation:** {action} — {reason}
890
+
859
891
  `/sf:run` — implement specification
860
892
 
861
893
  Tip: `/clear` recommended — executor needs fresh context for implementation
@@ -875,6 +907,8 @@ N+1. [recommendation]
875
907
 
876
908
  ### Next Steps
877
909
 
910
+ **Recommendation:** {action} — {reason}
911
+
878
912
  Choose one:
879
913
  - `/sf:run` — implement specification as-is
880
914
  - `/sf:revise` — apply optional recommendations first ({N} items)
@@ -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 };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * bin/lib/recommend.cjs — Pure mapping module for audit/review recommendations.
3
+ *
4
+ * Exports: recommend({ source, critical, major, minor }) → { action, reason }
5
+ *
6
+ * No I/O, no state mutation, fully unit-testable.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ /**
12
+ * Map severity counts and source to a recommended action and human-readable reason.
13
+ *
14
+ * @param {object} opts
15
+ * @param {string} opts.source - 'audit' or 'review'
16
+ * @param {number} [opts.critical=0] - Number of critical findings
17
+ * @param {number} [opts.major=0] - Number of major findings (ignored for source 'audit')
18
+ * @param {number} [opts.minor=0] - Number of minor findings / recommendations
19
+ * @returns {{ action: string, reason: string }}
20
+ */
21
+ function recommend({ source, critical = 0, major = 0, minor = 0 } = {}) {
22
+ if (source !== 'audit' && source !== 'review') {
23
+ throw new Error(`Unknown source: ${source}. Expected 'audit' or 'review'.`);
24
+ }
25
+
26
+ if (
27
+ !Number.isInteger(critical) || critical < 0 ||
28
+ !Number.isInteger(major) || major < 0 ||
29
+ !Number.isInteger(minor) || minor < 0
30
+ ) {
31
+ throw new Error('Counts must be non-negative integers.');
32
+ }
33
+
34
+ if (source === 'audit') {
35
+ if (critical >= 1) {
36
+ return { action: 'revise', reason: `${critical} critical issue(s) block execution` };
37
+ }
38
+ if (minor >= 1) {
39
+ return { action: 'run --apply=minor', reason: `${minor} non-blocking recommendation(s), apply inline` };
40
+ }
41
+ return { action: 'run', reason: 'spec is clean, ready for execution' };
42
+ }
43
+
44
+ // source === 'review'
45
+ if (critical >= 1) {
46
+ return { action: 'fix', reason: `${critical} critical finding(s) block finalize` };
47
+ }
48
+ if (major >= 1) {
49
+ return { action: 'fix', reason: `${major} major finding(s) block finalize` };
50
+ }
51
+ if (minor >= 1) {
52
+ return { action: 'done --apply=minor', reason: `${minor} minor finding(s), apply inline before finalize` };
53
+ }
54
+ return { action: 'done', reason: 'implementation is clean, ready to finalize' };
55
+ }
56
+
57
+ module.exports = { recommend };
package/bin/sf-tools.cjs CHANGED
@@ -9,6 +9,7 @@
9
9
  * spec load <id> Parse spec file, return frontmatter + body
10
10
  * spec list List all specs
11
11
  * spec next-id Next available SPEC-XXX number
12
+ * spec validate <id> Validate spec frontmatter and required headings
12
13
  * todo load <id> Parse TODO file, return frontmatter + body
13
14
  * todo list [--all] List all TODOs sorted by priority
14
15
  * todo next-id Next available TODO-XXX number
@@ -22,6 +23,9 @@
22
23
  * state remove-active <id> Remove one row from Active Specifications table
23
24
  * state resolve [id] Resolve active spec; emit JSON contract
24
25
  * state migrate One-shot idempotent migration to new schema
26
+ * archive summarize <SPEC-ID> Generate L1 summary for one archived spec
27
+ * archive backfill [--force] Generate missing summaries for all archived specs
28
+ * recommend Map severity counts to recommended action
25
29
  * resolve-model <agent-type> Model for agent by current profile
26
30
  * verify-structure Check .specflow/ integrity
27
31
  * generate-slug <text> Text to URL-safe slug
@@ -44,6 +48,8 @@ const { cmdSpecLoad, cmdSpecList, cmdSpecNextId } = require('./lib/spec.cjs');
44
48
  const { cmdTodoLoad, cmdTodoList, cmdTodoNextId, cmdTodoReindex, cmdTodoCheckStale } = require('./lib/todo.cjs');
45
49
  const { cmdResolveModel } = require('./lib/config.cjs');
46
50
  const { cmdVerifyStructure } = require('./lib/verify.cjs');
51
+ const { cmdArchiveSummarize, cmdArchiveBackfill } = require('./lib/archive-summary.cjs');
52
+ const { recommend } = require('./lib/recommend.cjs');
47
53
 
48
54
  const cwd = process.cwd();
49
55
  const args = process.argv.slice(2);
@@ -69,6 +75,49 @@ const COMMANDS = {
69
75
  },
70
76
  'spec list': () => cmdSpecList(cwd, raw),
71
77
  'spec next-id': () => cmdSpecNextId(cwd, raw),
78
+ 'spec validate': () => {
79
+ const specId = filteredArgs[2];
80
+ if (!specId) {
81
+ process.stderr.write('Error: spec validation failed: missing spec ID. Usage: spec validate <SPEC-XXX>\n');
82
+ process.exit(1);
83
+ }
84
+ const { safeReadFile, parseFrontmatter } = require('./lib/core.cjs');
85
+ const specPath = require('path').join(cwd, '.specflow', 'specs', specId + '.md');
86
+ const content = safeReadFile(specPath);
87
+ if (content === null) {
88
+ process.stderr.write(`Error: spec validation failed: spec file not found at ${specPath}\n`);
89
+ process.exit(1);
90
+ }
91
+ let frontmatter;
92
+ try {
93
+ const parsed = parseFrontmatter(content);
94
+ frontmatter = parsed.frontmatter;
95
+ if (!frontmatter || typeof frontmatter !== 'object') {
96
+ throw new Error('invalid frontmatter');
97
+ }
98
+ } catch (e) {
99
+ process.stderr.write('Error: spec validation failed: invalid or missing frontmatter\n');
100
+ process.exit(1);
101
+ }
102
+ // Require ---...--- block to exist (parseFrontmatter returns empty obj if absent)
103
+ if (!content.match(/^---\r?\n[\s\S]*?\r?\n---/)) {
104
+ process.stderr.write('Error: spec validation failed: invalid or missing frontmatter\n');
105
+ process.exit(1);
106
+ }
107
+ const required = ['id', 'type', 'status', 'priority'];
108
+ for (const field of required) {
109
+ if (!frontmatter[field]) {
110
+ process.stderr.write(`Error: spec validation failed: missing frontmatter field '${field}'\n`);
111
+ process.exit(1);
112
+ }
113
+ }
114
+ if (!content.match(/^## Requirements/m)) {
115
+ process.stderr.write("Error: spec validation failed: missing required heading '## Requirements'\n");
116
+ process.exit(1);
117
+ }
118
+ // Success: no stdout, exit 0
119
+ process.exit(0);
120
+ },
72
121
  'todo load': () => {
73
122
  if (!filteredArgs[2]) error('Missing TODO ID. Usage: todo load <id>');
74
123
  cmdTodoLoad(cwd, filteredArgs[2], raw);
@@ -114,6 +163,66 @@ const COMMANDS = {
114
163
  .catch(e => error(e.message));
115
164
  },
116
165
 
166
+ 'archive summarize': () => {
167
+ if (!filteredArgs[2]) error('Missing SPEC-ID. Usage: archive summarize <SPEC-ID>');
168
+ cmdArchiveSummarize(cwd, filteredArgs[2], { force: flags.force });
169
+ },
170
+ 'archive backfill': () => {
171
+ cmdArchiveBackfill(cwd, { force: flags.force });
172
+ },
173
+
174
+ 'recommend': () => {
175
+ // Parse --source, --critical, --major, --minor from filteredArgs
176
+ // Flags take the form: --source audit or --critical 2 etc.
177
+ const flagValues = {};
178
+ for (let i = 1; i < filteredArgs.length; i++) {
179
+ const a = filteredArgs[i];
180
+ if (a.startsWith('--')) {
181
+ const key = a.slice(2);
182
+ const val = filteredArgs[i + 1];
183
+ if (val !== undefined && !val.startsWith('--')) {
184
+ flagValues[key] = val;
185
+ i++; // skip value token
186
+ } else {
187
+ flagValues[key] = true;
188
+ }
189
+ }
190
+ }
191
+
192
+ const source = flagValues['source'];
193
+ if (!source || source === true) {
194
+ process.stderr.write('Error: --source is required (audit|review)\n');
195
+ process.exit(1);
196
+ }
197
+
198
+ // Parse integer counts with validation
199
+ function parseCount(flagName) {
200
+ const raw = flagValues[flagName];
201
+ if (raw === undefined || raw === true) return 0;
202
+ const n = Number(raw);
203
+ if (!Number.isInteger(n) || n < 0) {
204
+ process.stderr.write(`Error: --${flagName} must be a non-negative integer\n`);
205
+ process.exit(1);
206
+ }
207
+ return n;
208
+ }
209
+
210
+ const critical = parseCount('critical');
211
+ const major = parseCount('major');
212
+ const minor = parseCount('minor');
213
+
214
+ let result;
215
+ try {
216
+ result = recommend({ source, critical, major, minor });
217
+ } catch (e) {
218
+ process.stderr.write('Error: ' + e.message + '\n');
219
+ process.exit(1);
220
+ }
221
+
222
+ process.stdout.write(JSON.stringify(result) + '\n');
223
+ process.exit(0);
224
+ },
225
+
117
226
  'resolve-model': () => {
118
227
  if (!filteredArgs[1]) error('Missing agent type. Usage: resolve-model <agent-type>');
119
228
  cmdResolveModel(cwd, filteredArgs[1], raw);
@@ -134,6 +243,7 @@ Commands:
134
243
  spec load <id> Parse spec file, return frontmatter + body
135
244
  spec list List all specs from .specflow/specs/
136
245
  spec next-id Next available SPEC-XXX number
246
+ spec validate <id> Validate spec frontmatter and required headings
137
247
  todo load <id> Parse TODO file, return frontmatter + body
138
248
  todo list [--all] List TODOs sorted by priority (--all includes eliminated)
139
249
  todo next-id Next available TODO-XXX number
@@ -147,6 +257,9 @@ Commands:
147
257
  state remove-active <id> Remove one row (under advisory lock)
148
258
  state resolve [SPEC-ID] Resolve active spec; emit JSON contract
149
259
  state migrate One-shot idempotent migration to new schema
260
+ archive summarize <SPEC-ID> Generate L1 summary for one archived spec
261
+ archive backfill [--force] Generate missing summaries for all archived specs
262
+ recommend Map severity counts to recommended action
150
263
  resolve-model <agent-type> Resolve model for agent by current profile
151
264
  verify-structure Check .specflow/ directory integrity
152
265
  generate-slug <text> Convert text to URL-safe slug
@@ -290,6 +290,8 @@ After the agent updates STATE.md, check if rotation is needed:
290
290
 
291
291
  ## Next Step
292
292
 
293
+ **Recommendation:** run — spec is clean, ready for execution
294
+
293
295
  `/sf:run` — implement specification
294
296
 
295
297
  Tip: `/clear` recommended — executor needs fresh context for implementation
@@ -297,6 +299,8 @@ Tip: `/clear` recommended — executor needs fresh context for implementation
297
299
 
298
300
  ### If APPROVED (with optional recommendations):
299
301
 
302
+ The `Recommendation:` line is emitted by the auditor agent (Step 7.5 in `agents/spec-auditor.md`) using `node bin/sf-tools.cjs recommend --source audit --critical 0 --minor N`. The STATE.md Next Step remains `/sf:run` (without the `--apply=minor` suffix) — the suffix is advisory here only.
303
+
300
304
  ```
301
305
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
302
306
  AUDIT PASSED
@@ -318,8 +322,11 @@ Tip: `/clear` recommended — executor needs fresh context for implementation
318
322
 
319
323
  ## Next Step
320
324
 
325
+ **Recommendation:** run --apply=minor — {N} non-blocking recommendation(s), apply inline
326
+
321
327
  Choose one:
322
328
  • `/sf:run` — implement specification as-is
329
+ • `/sf:run --apply=minor` — apply recommendations inline then execute
323
330
  • `/sf:revise` — apply optional recommendations first ({N} items)
324
331
 
325
332
  Tip: `/clear` recommended before `/sf:run` — executor needs fresh context
@@ -352,6 +359,8 @@ Tip: `/clear` recommended before `/sf:run` — executor needs fresh context
352
359
 
353
360
  ## Next Step
354
361
 
362
+ **Recommendation:** revise — {N} critical issue(s) block execution
363
+
355
364
  `/sf:revise` — address critical issues
356
365
 
357
366
  Options:
@@ -59,6 +59,87 @@ Parse the JSON response:
59
59
  Options: {id — title (status)} for each entry
60
60
  ```
61
61
 
62
+ ## Step 2.5: Handle `--apply=minor` Flag
63
+
64
+ **Check if `--apply=minor` was passed in the invocation arguments.**
65
+
66
+ **If `--apply=minor` is NOT present:** Continue to Step 3 (existing behavior unchanged).
67
+
68
+ **If `--apply=minor` IS present:**
69
+
70
+ ### 2.5.a Verify Status Precondition
71
+
72
+ Confirm the resolved spec has `status == "review"` in its frontmatter.
73
+
74
+ If status is NOT `review`:
75
+ ```
76
+ Error: --apply=minor requires status 'review' (current: {status})
77
+ ```
78
+ Exit 1. No state mutation.
79
+
80
+ ### 2.5.b Parse Severity Counts from Latest Review History
81
+
82
+ Read the spec file and find the most recent `### Review v[N]` entry in Review History.
83
+
84
+ Extract Critical, Major, and Minor counts from that entry.
85
+
86
+ Run:
87
+ ```bash
88
+ node bin/sf-tools.cjs recommend --source review --critical N --major M --minor K
89
+ ```
90
+
91
+ Parse the JSON response.
92
+
93
+ If `action != "done --apply=minor"`:
94
+ ```
95
+ Error: --apply=minor cannot be used when Critical or Major findings exist (found {N} Critical, {M} Major). Run /sf:fix instead.
96
+ ```
97
+ Exit 1. No state mutation.
98
+
99
+ ### 2.5.c Apply Minor Fixes via `/sf:fix` Machinery
100
+
101
+ Parse the latest Review History entry and extract the numbered list of Minor findings (the sequential numbers as they appear in the Minor section, e.g. `"4,5,7"`).
102
+
103
+ Invoke existing `/sf:fix` machinery passing the numbered target list and `--internal` flag (so `/sf:fix` Step 8 does NOT mutate STATE.md — the caller owns the status transition):
104
+ ```
105
+ /sf:fix SPEC-XXX "{N,M,K}" --internal
106
+ ```
107
+
108
+ This reuses `/sf:fix`'s existing per-fix atomic commit behavior. Do NOT duplicate fix logic.
109
+
110
+ ### 2.5.d Test Gate
111
+
112
+ Detect and run the project test command:
113
+
114
+ 1. If `package.json` exists and has `scripts.test` → run `npm test`
115
+ 2. Else if `test/` directory exists → run `node --test test/`
116
+ 3. Else → note `No test command detected; proceeding without test gate.`
117
+
118
+ If test command exits non-zero:
119
+ - Print captured stdout+stderr
120
+ - Leave STATE.md status as `review` (no transition)
121
+ - Exit 1
122
+ - Note: Fix commits remain in git history; user can manually `git revert` or run full `/sf:fix` cycle.
123
+
124
+ ### 2.5.e Lint Gate
125
+
126
+ Detect and run lint:
127
+
128
+ 1. If `package.json` exists and has `scripts.lint` → run `npm run lint`
129
+ 2. Else if `.eslintrc*` or `eslint.config.*` present → run `npx eslint .`
130
+ 3. Else → skip silently
131
+
132
+ If lint exits non-zero:
133
+ - Same abort semantics as 2.5.d (print output, leave STATUS as `review`, exit 1)
134
+
135
+ ### 2.5.f On Gate Success: Continue to Finalization
136
+
137
+ On all gates passing: continue into existing Step 3+ finalization path (update spec frontmatter status → "done", archive, generate L1 summary per SPEC-012).
138
+
139
+ All STATE.md mutations use Read+Write per SPEC-004 (not Bash/awk/sed).
140
+
141
+ ---
142
+
62
143
  ## Step 3: Load Specification
63
144
 
64
145
  Read the active spec file: `.specflow/specs/SPEC-XXX.md`
@@ -318,6 +399,16 @@ Move spec to archive:
318
399
  mv .specflow/specs/SPEC-XXX.md .specflow/archive/
319
400
  ```
320
401
 
402
+ ## Step 8.5: Generate L1 Summary
403
+
404
+ Generate a compact summary of the just-archived spec for agent consumption:
405
+
406
+ node ~/.claude/specflow-cc/bin/sf-tools.cjs archive summarize SPEC-XXX
407
+
408
+ On success, `.specflow/archive/SPEC-XXX.summary.md` exists.
409
+
410
+ 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`.
411
+
321
412
  ## Step 9: Update STATE.md
322
413
 
323
414
  ### Remove from Active Specifications Table
@@ -457,6 +548,7 @@ git commit -m "docs(sf): complete SPEC-XXX"
457
548
  - [ ] Decisions extracted (if any)
458
549
  - [ ] Source TODO file deleted (if `source:` field exists in spec and file exists in todos/)
459
550
  - [ ] Spec moved to archive
551
+ - [ ] L1 summary file created at .specflow/archive/SPEC-XXX.summary.md (or warning logged)
460
552
  - [ ] STATE.md updated (cleared active, removed from queue)
461
553
  - [ ] Final commit created
462
554
  - [ ] Clear completion summary shown
@@ -15,6 +15,8 @@ allowed-tools:
15
15
 
16
16
  <purpose>
17
17
  Fix the implementation based on review feedback. Can apply all fixes, specific numbered items, or custom fixes described by user. Creates atomic commits for each fix.
18
+
19
+ Accepts an `--internal` flag: when present, Step 8 (STATE.md mutation) is suppressed. Used by `/sf:done --apply=minor` to apply minor fixes inline without advancing the spec lifecycle status prematurely.
18
20
  </purpose>
19
21
 
20
22
  <context>
@@ -175,6 +177,10 @@ Append to Review History:
175
177
 
176
178
  ## Step 8: Update STATE.md
177
179
 
180
+ **If `--internal` flag was passed:** SKIP this step entirely. Do NOT mutate STATE.md or spec frontmatter status. The caller (`/sf:done --apply=minor`) owns the status transition and needs the status to remain `review` until its test+lint gate passes.
181
+
182
+ **If `--internal` is NOT set (normal invocation):**
183
+
178
184
  ```bash
179
185
  node bin/sf-tools.cjs state add-active SPEC-XXX review /sf:review
180
186
  ```
@@ -158,6 +158,8 @@ After the agent updates STATE.md, check if rotation is needed:
158
158
 
159
159
  ### If APPROVED (no minor issues):
160
160
 
161
+ The `Recommendation:` line is emitted by the reviewer agent (Step 7.5 in `agents/impl-reviewer.md`) using `node bin/sf-tools.cjs recommend --source review --critical 0 --major 0 --minor 0`. The STATE.md Next Step remains `/sf:done` (canonical).
162
+
161
163
  ```
162
164
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
163
165
  REVIEW PASSED
@@ -185,11 +187,15 @@ After the agent updates STATE.md, check if rotation is needed:
185
187
 
186
188
  ## Next Step
187
189
 
190
+ **Recommendation:** done — implementation is clean, ready to finalize
191
+
188
192
  `/sf:done` — finalize and archive specification
189
193
  ```
190
194
 
191
195
  ### If APPROVED (with minor suggestions):
192
196
 
197
+ The `Recommendation:` line uses action `done --apply=minor` when only Minor findings exist. STATE.md Next Step stays `/sf:done` (canonical; the `--apply=minor` suffix is advisory here only).
198
+
193
199
  ```
194
200
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
195
201
  REVIEW PASSED
@@ -218,13 +224,18 @@ After the agent updates STATE.md, check if rotation is needed:
218
224
 
219
225
  ## Next Step
220
226
 
227
+ **Recommendation:** done --apply=minor — {N} minor finding(s), apply inline before finalize
228
+
221
229
  Choose one:
222
230
  • `/sf:done` — finalize and archive as-is
223
- • `/sf:fix` — apply minor suggestions first ({N} items)
231
+ • `/sf:done --apply=minor` — apply minor fixes inline and finalize in one step
232
+ • `/sf:fix` — apply minor suggestions first ({N} items) then finalize
224
233
  ```
225
234
 
226
235
  ### If CHANGES_REQUESTED:
227
236
 
237
+ The `Recommendation:` line uses action `fix` when Critical or Major findings exist (STATE.md Next Step is `/sf:fix`).
238
+
228
239
  ```
229
240
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
230
241
  REVIEW: CHANGES REQUESTED
@@ -262,6 +273,8 @@ Choose one:
262
273
 
263
274
  ## Next Step
264
275
 
276
+ **Recommendation:** fix — {N} critical/major finding(s) block finalize
277
+
265
278
  `/sf:fix` — address the issues
266
279
 
267
280
  Options:
@@ -14,6 +14,8 @@ allowed-tools:
14
14
 
15
15
  <purpose>
16
16
  Revise the active specification based on audit feedback. Can apply all comments, specific numbered items, or custom changes described by user.
17
+
18
+ Accepts an `--internal` flag: when present, Step 8 STATE.md mutation (status → `auditing`, Next Step → `/sf:audit`) is suppressed. Used by `/sf:run --apply=minor` to apply Recommendations inline without advancing the spec lifecycle status prematurely.
17
19
  </purpose>
18
20
 
19
21
  <context>
@@ -330,7 +332,9 @@ Apply the specified changes and record the revision response.
330
332
 
331
333
  ## Step 8: Handle Agent Response
332
334
 
333
- The agent will:
335
+ **If `--internal` flag was passed:** The agent applies revisions and records Response v[N] in Audit History, but DOES NOT update status to "auditing" and DOES NOT update STATE.md. Return to caller after revisions are applied.
336
+
337
+ **If `--internal` is NOT set (normal invocation),** the agent will:
334
338
  1. Parse the latest audit
335
339
  2. Apply requested revisions
336
340
  3. Record Response v[N] in Audit History
@@ -520,6 +524,10 @@ After recording the Response, if any items were marked "Deferred":
520
524
 
521
525
  ### Update Status
522
526
 
527
+ **If `--internal` flag was passed:** SKIP this step entirely. Do NOT mutate STATE.md or spec frontmatter status. The caller (`/sf:run --apply=minor`) owns the status transition and needs the status to remain `audited` until its structural-validate gate passes.
528
+
529
+ **If `--internal` is NOT set (normal invocation):**
530
+
523
531
  In spec frontmatter: `status: auditing`
524
532
 
525
533
  In STATE.md:
@@ -66,6 +66,77 @@ Parse the JSON response:
66
66
 
67
67
  Read the active spec file: `.specflow/specs/SPEC-XXX.md`
68
68
 
69
+ ## Step 3.5: Handle `--apply=minor` Flag
70
+
71
+ **Check if `--apply=minor` was passed in the invocation arguments.**
72
+
73
+ **If `--apply=minor` is NOT present:** Continue to Step 4 (existing behavior unchanged).
74
+
75
+ **If `--apply=minor` IS present:**
76
+
77
+ ### 3.5.a Verify Status Precondition
78
+
79
+ Confirm the resolved spec has `status == "audited"` in its frontmatter.
80
+
81
+ If status is NOT `audited`:
82
+ ```
83
+ Error: --apply=minor requires status 'audited' (current: {status})
84
+ ```
85
+ Exit 1. No state mutation.
86
+
87
+ ### 3.5.b Parse Severity Counts from Latest Audit History
88
+
89
+ Read the spec file and find the most recent `### Audit v[N]` entry in Audit History.
90
+
91
+ Extract Critical count and Recommendations count from that entry. Map Recommendations count to `--minor` (per R2 CLI contract).
92
+
93
+ Run:
94
+ ```bash
95
+ node bin/sf-tools.cjs recommend --source audit --critical N --minor M
96
+ ```
97
+
98
+ Parse the JSON response.
99
+
100
+ If `action != "run --apply=minor"`:
101
+ ```
102
+ Error: --apply=minor requires only Recommendations (found {N} Critical). Run /sf:revise instead.
103
+ ```
104
+ Exit 1. No state mutation.
105
+
106
+ ### 3.5.c Apply Recommendations via `/sf:revise` Machinery
107
+
108
+ Parse the latest Audit History Recommendations list and extract numbered items as a comma-separated string (e.g. `"2,3,5"` — the sequence numbers as they appear in the Recommendations section).
109
+
110
+ Invoke existing `/sf:revise` machinery passing the numbered target list and `--internal` flag (so `/sf:revise` Step 8 does NOT mutate STATE.md — the caller owns the status transition; status must remain `audited` until the structural-validate gate passes):
111
+ ```
112
+ /sf:revise SPEC-XXX "{N,M,K}" --internal
113
+ ```
114
+
115
+ This reuses `/sf:revise`'s existing per-item commit behavior. Do NOT duplicate revise logic.
116
+
117
+ ### 3.5.d Structural Validation Gate
118
+
119
+ Run spec structural validation:
120
+ ```bash
121
+ node bin/sf-tools.cjs spec validate SPEC-XXX
122
+ ```
123
+
124
+ This is the exact gate specified in R2.5: verifies frontmatter parses, required fields present (`id`, `type`, `status`, `priority`), and `## Requirements` heading present. No fallback path.
125
+
126
+ If `spec validate` exits non-zero:
127
+ - Print error output
128
+ - Leave STATE.md status as `audited` (no transition)
129
+ - Exit 1
130
+ - Note: Revise commits remain in git history; user can manually `git revert` or run full `/sf:revise` cycle. STATE.md status is the sole rollback signal.
131
+
132
+ ### 3.5.e On Gate Success: Continue to Execution
133
+
134
+ On validation passing: skip Steps 4–7 (audit status check, mode determination, pre-execution summary, model profile, status update) and proceed directly to Step 8 (Spawn Executor Agent) with mode="orchestrated" (or "single" based on the spec's Implementation Tasks section — same logic as Step 4.5).
135
+
136
+ All STATE.md mutations use Read+Write per SPEC-004 (not Bash/awk/sed).
137
+
138
+ ---
139
+
69
140
  ## Step 4: Check Audit Status
70
141
 
71
142
  **If status is "audited":**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specflow-cc",
3
- "version": "1.20.1",
3
+ "version": "1.22.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