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 +30 -0
- package/agents/impl-reviewer.md +32 -0
- package/agents/researcher.md +2 -0
- package/agents/spec-auditor.md +34 -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/lib/recommend.cjs +57 -0
- package/bin/sf-tools.cjs +113 -0
- package/commands/sf/audit.md +9 -0
- package/commands/sf/done.md +92 -0
- package/commands/sf/fix.md +6 -0
- package/commands/sf/review.md +14 -1
- package/commands/sf/revise.md +9 -1
- package/commands/sf/run.md +71 -0
- package/package.json +1 -1
- package/templates/archive-summary.md +25 -0
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
|
package/agents/impl-reviewer.md
CHANGED
|
@@ -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:
|
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:
|
|
@@ -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)
|
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 };
|
|
@@ -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
|
package/commands/sf/audit.md
CHANGED
|
@@ -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:
|
package/commands/sf/done.md
CHANGED
|
@@ -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
|
package/commands/sf/fix.md
CHANGED
|
@@ -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
|
```
|
package/commands/sf/review.md
CHANGED
|
@@ -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:
|
|
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:
|
package/commands/sf/revise.md
CHANGED
|
@@ -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
|
|
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:
|
package/commands/sf/run.md
CHANGED
|
@@ -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
|
@@ -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
|