@dzhechkov/p-replicator 1.13.1 → 1.13.2

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.
Files changed (27) hide show
  1. package/.dz-manifest.json +65 -13
  2. package/README.md +19 -1
  3. package/package.json +3 -3
  4. package/sbom.json +142 -12
  5. package/scripts/check-pipeline-gaps.sh +510 -21
  6. package/src/utils.js +1 -0
  7. package/templates/.claude/commands/feature.md +43 -1
  8. package/templates/.claude/hooks/check-review-contract.cjs +205 -0
  9. package/templates/.claude/hooks/statusline.cjs +1 -1
  10. package/templates/.claude/rules/replicate-pipeline.md +4 -3
  11. package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
  12. package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
  13. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
  14. package/tests/e2e/feature-contour.test.js +176 -0
  15. package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
  16. package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
  17. package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
  18. package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
  19. package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
  20. package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
  21. package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
  22. package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
  23. package/tests/snapshot/baseline.json +9 -7
  24. package/tests/unit/check-review-contract.test.js +181 -0
  25. package/tests/unit/honest-failure-rules.test.js +18 -3
  26. package/tests/unit/sync-templates-guard.test.js +46 -3
  27. package/tests/unit/traceability-completion-gate.test.js +267 -0
@@ -133,6 +133,17 @@ Run validation against INVEST + SMART + BDD scenario completeness.
133
133
 
134
134
  Output: `docs/features/<feature>/validation-report.md`
135
135
 
136
+ The report carries the `Spec revision:` line and the `## Criterion scenarios` table (AC id → BDD
137
+ scenario) in the exact format of
138
+ `.claude/skills/requirements-validator/references/feature-report-contracts.md`. Then run:
139
+
140
+ ```bash
141
+ bash "$CHECK_PIPELINE_GAPS" "${CLAUDE_PROJECT_DIR:-.}" --report-revision --criterion-scenarios
142
+ ```
143
+
144
+ Same exit semantics as the Phase 1 gate: `0` advances; `1` (stale revision, AC id without a scenario
145
+ row) returns to Phase 2; `2` stops.
146
+
136
147
  ### Phase 3: IMPLEMENT (parallel agents)
137
148
 
138
149
  1. Read SPARC docs from Phase 1
@@ -178,11 +189,39 @@ substantive, post-launch file with a terminal status. Narrative/chat/silence is
178
189
  invalid receipt MUST block merge/completion. Full rule and bounded exception:
179
190
  `.claude/rules/swarm-file-evidence.md`.
180
191
 
192
+ #### Threshold tests pin the number (layer-2 rule)
193
+
194
+ A test of a THRESHOLD asserts the literal number; a test of a DERIVED value calls the production
195
+ implementation. A test that computes its expectation from the production constant is green for any
196
+ value and proves nothing.
197
+
198
+ #### Blocking criterion-coverage gate
199
+
200
+ `05_completion.md` MUST carry the `## Criterion coverage` table (AC id → test file → test title; format
201
+ in `feature-report-contracts.md`). At the end of Phase 3 run:
202
+
203
+ ```bash
204
+ bash "$CHECK_PIPELINE_GAPS" "${CLAUDE_PROJECT_DIR:-.}" --completion
205
+ ```
206
+
207
+ `0` advances; `1` (an AC without a test, a row whose file or title does not exist) returns to
208
+ Phase 3; `2` stops. Never downgrade either non-zero status to a warning.
209
+
181
210
  ### Phase 4: REVIEW (brutal-honesty-review)
182
211
 
183
212
  Read: `.claude/skills/brutal-honesty-review/SKILL.md`
184
213
 
185
- Findings classified by severity. Critical (blocker | high) MUST be fixed.
214
+ Specification contract: the review receives `01_specification.md` + `validation-report.md` (a review
215
+ not given the specification is incomplete by construction and says so) and writes
216
+ `docs/features/<feature>/review-report.md` — `Reviewer family:` (disclosure) and `Spec revision:` in the
217
+ first 20 lines, an AC-by-AC `## Spec conformance` table — per `feature-report-contracts.md`. Then run:
218
+
219
+ ```bash
220
+ node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/check-review-contract.cjs" "${CLAUDE_PROJECT_DIR:-.}" "<feature>"
221
+ ```
222
+
223
+ `0` advances; `1` (named contract gaps) returns to Phase 4; `2` stops. Findings classified by
224
+ severity. Critical (blocker | high) MUST be fixed.
186
225
 
187
226
  ## Final Steps
188
227
 
@@ -196,8 +235,11 @@ Skip per-phase user confirmations. Auto-decisions:
196
235
  - Phase 1: proceed if all docs exist
197
236
  - Phase 1 traceability: proceed only when `check-pipeline-gaps.sh` exits `0`; preserve exit `1`/`2`
198
237
  - Phase 2: proceed if 🟢 or 🟡; auto-retry once on 🔴
238
+ - Phase 2 report revision + criterion scenarios: proceed only when `--report-revision --criterion-scenarios` exits `0`; preserve exit `1`/`2`
199
239
  - Phase 3: proceed if tests + lint + build green
240
+ - Phase 3 criterion coverage: proceed only when `--completion` exits `0`; preserve exit `1`/`2`
200
241
  - Phase 4: auto-fix `high` if straightforward; halt on `blocker`
242
+ - Phase 4 review contract: proceed only when `check-review-contract.cjs` exits `0`; preserve exit `1`/`2`
201
243
 
202
244
  ## Related
203
245
 
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Validate the feature review's binding to the specification it judged.
6
+ *
7
+ * Usage: node check-review-contract.cjs <project-root> <feature-slug>
8
+ * Exit 0: contract passes; 1: named contract gaps; 2: inputs could not be established.
9
+ */
10
+
11
+ const crypto = require('node:crypto');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const AC_ID_SOURCE = 'AC-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*-[0-9]+';
16
+ const AC_HEADING = new RegExp('^###\\s+(' + AC_ID_SOURCE + ')(?:\\s|$)');
17
+ const FAMILIES = new Set(['claude', 'codex', 'human', 'unknown']);
18
+ const VERDICTS = new Set(['met', 'not met', 'unverifiable']);
19
+
20
+ function say(line) { process.stdout.write(line + '\n'); }
21
+
22
+ function cannotCheck(reason) {
23
+ say('NOT-ESTABLISHED review contract: ' + reason);
24
+ process.exit(2);
25
+ }
26
+
27
+ function isInside(root, candidate) {
28
+ const relative = path.relative(root, candidate);
29
+ return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep)
30
+ && !path.isAbsolute(relative);
31
+ }
32
+
33
+ function projectRoot(input) {
34
+ let root;
35
+ try { root = fs.realpathSync(path.resolve(input)); } catch (error) {
36
+ cannotCheck('project root is missing or unreadable: ' + ((error && error.message) || error));
37
+ }
38
+ let stat;
39
+ try { stat = fs.statSync(root); } catch (error) {
40
+ cannotCheck('project root cannot be inspected: ' + ((error && error.message) || error));
41
+ }
42
+ if (!stat.isDirectory()) cannotCheck('project root is not a directory: ' + root);
43
+ return root;
44
+ }
45
+
46
+ function safeRead(root, relative, label) {
47
+ const candidate = path.resolve(root, relative);
48
+ if (!isInside(root, candidate)) cannotCheck(label + ' escapes the project root: ' + relative);
49
+
50
+ let current = root;
51
+ const parts = path.relative(root, candidate).split(path.sep);
52
+ for (let index = 0; index < parts.length; index++) {
53
+ current = path.join(current, parts[index]);
54
+ let stat;
55
+ try { stat = fs.lstatSync(current); } catch (error) {
56
+ cannotCheck(label + ' is missing or unreadable: ' + current + ' ('
57
+ + ((error && error.message) || error) + ')');
58
+ }
59
+ if (stat.isSymbolicLink()) cannotCheck(label + ' is a symlink: ' + current);
60
+ if (index < parts.length - 1 && !stat.isDirectory()) {
61
+ cannotCheck(label + ' has a non-directory path component: ' + current);
62
+ }
63
+ if (index === parts.length - 1) {
64
+ if (!stat.isFile()) cannotCheck(label + ' is not a regular file: ' + current);
65
+ if ((stat.mode & 0o444) === 0) cannotCheck(label + ' is unreadable: ' + current);
66
+ }
67
+ }
68
+
69
+ let real;
70
+ try { real = fs.realpathSync(candidate); } catch (error) {
71
+ cannotCheck(label + ' cannot be resolved: ' + ((error && error.message) || error));
72
+ }
73
+ if (!isInside(root, real)) cannotCheck(label + ' resolves outside the project root: ' + real);
74
+
75
+ try { return fs.readFileSync(real); } catch (error) {
76
+ cannotCheck(label + ' is unreadable: ' + ((error && error.message) || error));
77
+ }
78
+ }
79
+
80
+ function specificationIds(buffer) {
81
+ const lines = buffer.toString('utf8').split(/\n/).map((line) => line.replace(/\r$/, ''));
82
+ const ids = [];
83
+ let fence = '';
84
+ for (let index = 0; index < lines.length; index++) {
85
+ const line = lines[index];
86
+ const marker = line.trimStart().match(/^(```|~~~)/);
87
+ if (marker) {
88
+ if (!fence) fence = marker[1][0];
89
+ else if (marker[1][0] === fence) fence = '';
90
+ continue;
91
+ }
92
+ if (fence) continue;
93
+ if (!/^###\s+AC-/.test(line)) continue;
94
+ const match = line.match(AC_HEADING);
95
+ if (!match) cannotCheck('malformed AC heading at specification line ' + (index + 1));
96
+ ids.push(match[1]);
97
+ }
98
+ if (fence) cannotCheck('specification has an unclosed fenced code block');
99
+ const duplicate = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
100
+ if (duplicate.length) cannotCheck('specification has duplicate AC id: ' + duplicate.join(', '));
101
+ return ids;
102
+ }
103
+
104
+ function reportRows(lines, gaps) {
105
+ const section = lines.findIndex((line) => line.trim() === '## Spec conformance');
106
+ if (section < 0) {
107
+ gaps.push('GAP Spec conformance section missing');
108
+ return [];
109
+ }
110
+
111
+ let header = -1;
112
+ for (let index = section + 1; index < lines.length; index++) {
113
+ if (/^##\s+/.test(lines[index])) break;
114
+ if (lines[index].trim() === '| Criterion | Verdict | Evidence |') {
115
+ header = index;
116
+ break;
117
+ }
118
+ }
119
+ if (header < 0 || !/^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|$/.test((lines[header + 1] || '').trim())) {
120
+ gaps.push('GAP Spec conformance table missing or malformed');
121
+ return [];
122
+ }
123
+
124
+ const rows = [];
125
+ for (let index = header + 2; index < lines.length; index++) {
126
+ const line = lines[index];
127
+ if (!line.trim()) break;
128
+ if (/^##?\s+/.test(line)) break;
129
+ if (!line.trimStart().startsWith('|')) break;
130
+ const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$/);
131
+ if (!match) cannotCheck('malformed Spec conformance row at report line ' + (index + 1));
132
+ rows.push({ id: match[1].trim(), verdict: match[2].trim(), evidence: match[3].trim() });
133
+ }
134
+ return rows;
135
+ }
136
+
137
+ function validateReport(specification, report, slug) {
138
+ const ids = specificationIds(specification);
139
+ const idSet = new Set(ids);
140
+ const lines = report.toString('utf8').split(/\n/).map((line) => line.replace(/\r$/, ''));
141
+ const firstTwenty = lines.slice(0, 20);
142
+ const gaps = [];
143
+
144
+ const familyLine = firstTwenty.find((line) => /^Reviewer family:/.test(line));
145
+ if (familyLine === undefined) gaps.push('GAP Reviewer family line missing');
146
+ else {
147
+ const family = familyLine.replace(/^Reviewer family:\s*/, '').trim();
148
+ if (!FAMILIES.has(family)) gaps.push('GAP Reviewer family invalid value=' + (family || '<empty>'));
149
+ }
150
+
151
+ const revisionLines = firstTwenty.filter((line) => /^Spec revision:/.test(line));
152
+ if (revisionLines.length !== 1) {
153
+ cannotCheck('first 20 report lines require exactly one Spec revision line');
154
+ }
155
+ const revision = revisionLines[0].match(/^Spec revision: sha256:([a-f0-9]{64})$/);
156
+ if (!revision) cannotCheck('malformed Spec revision line in first 20 report lines');
157
+ const actual = crypto.createHash('sha256').update(specification).digest('hex');
158
+ if (revision[1] !== actual) {
159
+ gaps.push('GAP Spec revision mismatch report=' + revision[1].slice(0, 12)
160
+ + ' specification=' + actual.slice(0, 12));
161
+ }
162
+
163
+ const rows = reportRows(lines, gaps);
164
+ const counts = new Map();
165
+ for (const row of rows) counts.set(row.id, (counts.get(row.id) || 0) + 1);
166
+
167
+ for (const id of ids) {
168
+ if (!counts.has(id)) gaps.push('GAP ' + id + ' has no Spec conformance row');
169
+ }
170
+ for (const row of rows) {
171
+ if (!idSet.has(row.id)) gaps.push('GAP ' + row.id + ' row id is not in the specification');
172
+ if ((counts.get(row.id) || 0) > 1 && rows.findIndex((item) => item.id === row.id) === rows.indexOf(row)) {
173
+ gaps.push('GAP ' + row.id + ' duplicate Spec conformance row');
174
+ }
175
+ if (!VERDICTS.has(row.verdict)) {
176
+ gaps.push('GAP ' + row.id + ' verdict invalid value=' + (row.verdict || '<empty>'));
177
+ }
178
+ if ((row.verdict === 'met' || row.verdict === 'not met') && !row.evidence) {
179
+ gaps.push('GAP ' + row.id + ' evidence empty for verdict=' + row.verdict);
180
+ }
181
+ }
182
+
183
+ if (gaps.length) {
184
+ for (const gap of gaps) say(gap);
185
+ process.exit(1);
186
+ }
187
+ say('PASS review-contract feature=' + slug + ' AC-ids=' + ids.length + ' rows=' + rows.length);
188
+ process.exit(0);
189
+ }
190
+
191
+ function main() {
192
+ const args = process.argv.slice(2);
193
+ if (args.length !== 2) cannotCheck('usage: node check-review-contract.cjs <project-root> <feature-slug>');
194
+ const [rootInput, slug] = args;
195
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) cannotCheck('malformed feature slug: ' + slug);
196
+ const root = projectRoot(rootInput);
197
+ const base = path.join('docs', 'features', slug);
198
+ const specification = safeRead(root, path.join(base, '01_specification.md'), 'specification');
199
+ const report = safeRead(root, path.join(base, 'review-report.md'), 'review report');
200
+ validateReport(specification, report, slug);
201
+ }
202
+
203
+ try { main(); } catch (error) {
204
+ cannotCheck('internal error: ' + String((error && error.message) || error));
205
+ }
@@ -245,7 +245,7 @@ function parseExpectedToolkit() {
245
245
  commandsExpected: 11,
246
246
  agentsExpected: 4, // pre-shipped only (project agents are extra)
247
247
  rulesExpected: 13, // pre-shipped only (project rules are extra)
248
- hooksExpected: 24, // 4 event hooks + statusline + state-update + writer + 14 checks + 1 capture
248
+ hooksExpected: 25, // 4 event hooks + statusline + state-update + writer + 15 checks + 1 capture
249
249
  };
250
250
  }
251
251
 
@@ -215,14 +215,14 @@ are project-agnostic and can be enhanced (read by Phase 3) but never recreated.
215
215
  [`incoming-webhooks`](incoming-webhooks.md), [`long-running-job`](long-running-job.md),
216
216
  [`model-call-cost`](model-call-cost.md)
217
217
 
218
- **Hooks (24 files in `.claude/hooks/`, cross-platform Node).** Only four are wired to an
218
+ **Hooks (25 files in `.claude/hooks/`, cross-platform Node).** Only four are wired to an
219
219
  event in `.claude/settings.json`; the rest are utilities you invoke deliberately, and the
220
220
  difference matters — a hook of this package is NON-BLOCKING by contract and can only print.
221
221
 
222
222
  *Wired to an event (4):* `session-insights.cjs` (SessionStart) · `autocommit-roadmap.cjs`,
223
223
  `autocommit-insights.cjs`, `autocommit-plans.cjs` (Stop)
224
224
 
225
- *Invoked deliberately, wired to nothing (20):* `statusline.cjs` (a statusLine, not a hook) ·
225
+ *Invoked deliberately, wired to nothing (21):* `statusline.cjs` (a statusLine, not a hook) ·
226
226
  `state-update.cjs` (argv utility) · `write-insight.cjs` (harvest carrier writer) ·
227
227
  `check-ports.cjs` (docker-ports Правило №0, exits 0/1/2) ·
228
228
  `check-docs-complete.cjs` (are the Phase-1 documents written, exits 0/1/2) ·
@@ -234,7 +234,8 @@ difference matters — a hook of this package is NON-BLOCKING by contract and ca
234
234
  `check-webhook-contract.cjs` (is the incoming webhook signed, deduplicated by a named repeat key and
235
235
  safe against reordering, exits 0/1/2)
236
236
  `check-job-contract.cjs` (does long-running work have a handle, three states and a resuming retry, exits 0/1/2)
237
- `check-model-cost.cjs` (does every external model call name a binding spend ceiling, exits 0/1/2)
237
+ `check-model-cost.cjs` (does every external model call name a binding spend ceiling, exits 0/1/2) ·
238
+ `check-review-contract.cjs` (does review-report.md answer every AC id and name the spec revision it judged, exits 0/1/2)
238
239
  `check-canon.cjs` (before a WRITING fan-out: is the shared canon named and pinned, exits 0/1/2)
239
240
  `check-file-ownership.cjs` (one writer per file, and a split-born file owned at creation, exits 0/1/2)
240
241
  `check-source-version.cjs` (does every edit and verdict declare the source version it was built on, exits 0/1/2)
@@ -65,11 +65,14 @@ Always flag these terms and suggest specific replacements:
65
65
  **Score <50 = BLOCKED from development.** Provide rewrite suggestions.
66
66
 
67
67
  **Blocking floor — the weakest link decides, never the average.** Independently of the score, a
68
- requirement is BLOCKED if `Testable = 0` (no acceptance criteria) or `Completeness = 0` (rubric "No
69
- AC"). A story can total 72/100 with neither, and 72 reads as "minor fixes" in the table above. A non-zero `Testable` or `Completeness` REQUIRES quoting the acceptance criteria it scores — no quote
70
- means 0, because the agent that scores is the agent the floor binds. The
71
- floor is closed at those two: `Measurable` and `Traceability` are deliberately excluded — see
72
- `references/scoring-system.md` "Blocking floor" for the worked case and the reason.
68
+ requirement is BLOCKED if `Testable = 0` (no acceptance criteria), `Completeness = 0` (rubric "No
69
+ AC"), or `Traceability = 0` (no AC has a named scenario in the Criterion scenarios table). A story can
70
+ total 72/100 with no acceptance criteria, and 72 reads as "minor fixes" in the table above. A
71
+ non-zero `Testable` or `Completeness` REQUIRES quoting the acceptance criteria it scores; a non-zero
72
+ `Traceability` REQUIRES quoting the Criterion scenarios table or referencing its document and heading.
73
+ No artifact means 0, because the agent that scores is the agent the floor binds. The floor is closed
74
+ at those three; `Measurable` is deliberately excluded — see `references/scoring-system.md` →
75
+ "Blocking floor" for the worked cases and the reason.
73
76
 
74
77
  ## Output Format
75
78
 
@@ -77,6 +80,7 @@ floor is closed at those two: `Measurable` and `Traceability` are deliberately e
77
80
 
78
81
  ```markdown
79
82
  # Requirements Testability Analysis
83
+ Spec revision: sha256:<digest of 01_specification.md>
80
84
 
81
85
  ## Summary
82
86
  - Stories analyzed: X
@@ -90,6 +94,11 @@ floor is closed at those two: `Measurable` and `Traceability` are deliberately e
90
94
  | US-001 | ... | 92/100 | 6/6 ✓ | 5/5 ✓ | READY |
91
95
  | US-002 | ... | 45/100 | 3/6 ✗ | 2/5 ✗ | BLOCKED |
92
96
 
97
+ ## Criterion scenarios
98
+ | Criterion | Scenario |
99
+ |-----------|----------|
100
+ | AC-example-1 | Successful example flow |
101
+
93
102
  ## Detailed Analysis: US-002 (BLOCKED)
94
103
 
95
104
  ### INVEST Analysis
@@ -110,6 +119,10 @@ floor is closed at those two: `Measurable` and `Traceability` are deliberately e
110
119
  - Add AC: "Given X, when Y, then Z within 200ms"
111
120
  ```
112
121
 
122
+ Compute the revision line from the specification bytes with
123
+ `sha256sum docs/features/<f>/01_specification.md`. The `## Criterion scenarios` table is the artifact
124
+ the Traceability score keys on: it maps each AC id to a named scenario.
125
+
113
126
  ### Security Acceptance Criteria (scoring: +5 present / -10 missing, see Scoring Bonus below)
114
127
 
115
128
  When requirements involve authentication, data storage, external APIs, or multi-tenancy,
@@ -0,0 +1,67 @@
1
+ # Feature report contracts (`/feature`, Phases 2–4)
2
+
3
+ Byte-level formats that the packaged gates enforce. Read this when WRITING one of the three reports;
4
+ the gates are the machine half: `scripts/check-pipeline-gaps.sh` (`--report-revision`,
5
+ `--criterion-scenarios`, `--completion`) and `.claude/hooks/check-review-contract.cjs`. Exit codes
6
+ everywhere: `0` pass · `1` named gap(s), one line each · `2` could not establish (missing, unreadable,
7
+ symlinked or malformed input). Neither non-zero status is ever a warning.
8
+
9
+ ## Shared keys
10
+
11
+ - **AC ids** are `### AC-<slug>-<n>` headings in `docs/features/<f>/01_specification.md`
12
+ (`(FR|NFR|AC)-[A-Za-z0-9]+(-[A-Za-z0-9]+)*-[0-9]+`; fenced code blocks are ignored). FR/NFR stay under
13
+ the Phase 1 specification↔pseudocode gate; the three contracts below key on **AC-** ids only.
14
+ - **Spec revision line** — `Spec revision: sha256:<64 lowercase hex>`, the SHA-256 of the BYTES of
15
+ `01_specification.md` (`sha256sum docs/features/<f>/01_specification.md`), within the first 20 lines
16
+ of `validation-report.md` and of `review-report.md`. A report is bound to the revision it judged;
17
+ editing the specification afterwards makes the report stale by construction.
18
+
19
+ ## validation-report.md (Phase 2)
20
+
21
+ ```markdown
22
+ # Requirements Testability Analysis
23
+ Spec revision: sha256:<digest of 01_specification.md>
24
+
25
+ ## Criterion scenarios
26
+ | Criterion | Scenario |
27
+ |-----------|----------|
28
+ | AC-example-1 | Successful example flow |
29
+ ```
30
+
31
+ `## Criterion scenarios` maps every AC id to the named BDD scenario that covers it — the artifact the
32
+ `Traceability` blocking floor keys on (`references/scoring-system.md`). It is NOT the algorithm-level
33
+ `## Scenario Coverage` block that `sparc-prd-mini` writes into `02_pseudocode.md`. An AC id without a
34
+ row, an undeclared id, or an empty scenario cell is a named gap; a missing table cannot be established.
35
+
36
+ ## 05_completion.md (Phase 3)
37
+
38
+ ```markdown
39
+ ## Criterion coverage
40
+ | Criterion | Test file | Test title |
41
+ |-----------|-----------|------------|
42
+ | AC-example-1 | tests/example.test.js | rejects an invalid request |
43
+ ```
44
+
45
+ One row per AC id. `Test file` is relative to the project root and must be a regular file inside it
46
+ (absolute, `..`-escaping and symlinked paths are refused); `Test title` must occur verbatim in that
47
+ file. Both directions compare: an AC id without a row and a row whose id is not in the specification
48
+ are gaps. A table of names is not evidence — the gate opens the file.
49
+
50
+ ## review-report.md (Phase 4)
51
+
52
+ ```markdown
53
+ Reviewer family: claude|codex|human|unknown
54
+ Spec revision: sha256:<64 lowercase hex>
55
+
56
+ ## Spec conformance
57
+ | Criterion | Verdict | Evidence |
58
+ |-----------|---------|----------|
59
+ | AC-example-1 | met | tests/example.test.js — rejects an invalid request |
60
+ ```
61
+
62
+ Input contract: the review receives `01_specification.md` and `validation-report.md`; a review that
63
+ was not given the specification is incomplete by construction and must say so instead of grading code
64
+ quality alone. Output contract: both header lines within the first 20 lines (`Reviewer family:` is a
65
+ DISCLOSURE — the package refuses silence but cannot verify which family reviewed); every AC id exactly
66
+ once in `## Spec conformance`; `Verdict` is exactly `met`, `not met` or `unverifiable` (lowercase);
67
+ a `met` or `not met` row carries non-empty evidence; a `not met` row ideally carries a reproducer.
@@ -31,7 +31,7 @@ Total Score = INVEST Score (50%) + SMART Score (30%) + Quality Score (20%)
31
31
 
32
32
  | Criterion | Weight | Points | Calculation |
33
33
  |-----------|--------|--------|-------------|
34
- | Traceability | 10% | 0-10 | 10 if linked to tests, 5 if partial, 0 if none |
34
+ | Traceability | 10% | 0-10 | 10 if every AC id has a named scenario in the Criterion scenarios table; 5 if some do (the uncovered AC ids MUST be listed by name); 0 if none do, or the block is absent ⇒ the floor applies |
35
35
  | Completeness | 10% | 0-10 | See completeness rubric below |
36
36
 
37
37
  ### Completeness Rubric
@@ -56,12 +56,13 @@ Total Score = INVEST Score (50%) + SMART Score (30%) + Quality Score (20%)
56
56
  ### Blocking floor (overrides the total, both directions)
57
57
 
58
58
  **The weakest link decides, never the average.** A requirement is BLOCKED — whatever its total says —
59
- if either of these is zero:
59
+ if any of these is zero:
60
60
 
61
61
  | Criterion | Zero means | Why it vetoes |
62
62
  |-----------|-----------|---------------|
63
63
  | `Testable` (INVEST, 8) | no acceptance criteria exist | the gate exists to block untestable requirements |
64
64
  | `Completeness` (Quality, 10) | "No AC" on the rubric above | nothing states what "done" is |
65
+ | `Traceability` (Quality, 10) | no AC has a named scenario in the Criterion scenarios table | a story can otherwise pass validation with no scenario or test on any criterion |
65
66
 
66
67
  Without this floor the total alone lets an untestable requirement through. Worked case: a story with
67
68
  NO acceptance criteria and NO test links loses `Testable` 8, `Completeness` 10 and `Traceability` 10,
@@ -76,10 +77,18 @@ criteria AND is bound by the floor, so a score is not evidence of anything — r
76
77
  > the actual text, with its document and heading. `Testable = 4` means the AC exist and are vague:
77
78
  > quote them. **No quote ⇒ the score is 0 ⇒ the floor applies.**
78
79
 
79
- **The floor is CLOSED at these two.** `Measurable` and `Traceability` are deliberately NOT on it: a
80
- requirement with no number is often correct ("the user can export the report as PDF" is specific,
81
- testable and unmeasurable), so vetoing on `Measurable` would turn a false pass into a false block.
82
- Widening this list needs the same kind of worked case as the one above.
80
+ > A non-zero `Traceability` REQUIRES the Criterion scenarios table (AC id named scenario) quoted,
81
+ > or referenced by document and heading. **No table the score is 0 the floor applies.**
82
+
83
+ Measured worked case: story FR-009 had 27 criteria with no IDs, more than 10 of them uncovered by
84
+ any scenario, and passed validation. Its validation report 3.1 was written against document revision
85
+ 2; only a human noticed. With no named AC-id → scenario artifact, `Traceability = 0` and the floor
86
+ blocks that contour instead of letting its total hide the missing coverage.
87
+
88
+ **The floor is CLOSED at these three.** `Measurable` is deliberately NOT on it: a requirement with no
89
+ number is often correct ("the user can export the report as PDF" is specific, testable and
90
+ unmeasurable), so vetoing on `Measurable` would turn a false pass into a false block. Widening this
91
+ list needs the same kind of worked case as the ones above.
83
92
 
84
93
  ## Quality Gate Rules
85
94
 
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // Assert lifecycle contract content, not a template hash: a sha256 pin detects any change but
4
+ // cannot distinguish a defect from an intentional edit that preserves every required gate.
5
+
6
+ const { describe, test } = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+ const crypto = require('node:crypto');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync } = require('node:child_process');
13
+
14
+ const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
15
+ const FIXTURE = path.join(PACKAGE_ROOT, 'tests', 'fixtures', 'feature-contour');
16
+ const CHECKER = path.join(PACKAGE_ROOT, 'scripts', 'check-pipeline-gaps.sh');
17
+ const REVIEW_HOOK = path.join(
18
+ PACKAGE_ROOT, 'templates', '.claude', 'hooks', 'check-review-contract.cjs',
19
+ );
20
+ const FEATURE_TEMPLATE = path.join(
21
+ PACKAGE_ROOT, 'templates', '.claude', 'commands', 'feature.md',
22
+ );
23
+
24
+ // The fixture ships only the consumer's OWN files (docs/, tests/). The two shipped templates the
25
+ // checker reads for its role maps are materialised from templates/ at run time, byte-identical:
26
+ // a stub copy inside the fixture would be a third copy of a canonical skill and trip no-skill-drift.
27
+ const TEMPLATE_FILES = [
28
+ ['commands', 'feature.md'],
29
+ ['skills', 'sparc-prd-mini', 'SKILL.md'],
30
+ ];
31
+
32
+ function project(t) {
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'feature-contour-e2e-'));
34
+ fs.cpSync(FIXTURE, root, { recursive: true });
35
+ for (const parts of TEMPLATE_FILES) {
36
+ const target = path.join(root, '.claude', ...parts);
37
+ fs.mkdirSync(path.dirname(target), { recursive: true });
38
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'templates', '.claude', ...parts), target);
39
+ }
40
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
41
+ return root;
42
+ }
43
+
44
+ function execute(command, args, cwd) {
45
+ const result = spawnSync(command, args, { cwd, encoding: 'utf8', timeout: 10000 });
46
+ return {
47
+ status: result.status,
48
+ signal: result.signal,
49
+ output: `${result.stdout || ''}${result.stderr || ''}`,
50
+ };
51
+ }
52
+
53
+ function checker(root, flags = [
54
+ '--traceability', '--completion', '--report-revision', '--criterion-scenarios',
55
+ ]) {
56
+ return execute('bash', [CHECKER, root, ...flags], root);
57
+ }
58
+
59
+ function hook(root) {
60
+ return execute(process.execPath, [REVIEW_HOOK, root, 'demo-gate'], root);
61
+ }
62
+
63
+ function replace(root, relative, transform) {
64
+ const target = path.join(root, relative);
65
+ fs.writeFileSync(target, transform(fs.readFileSync(target, 'utf8')));
66
+ }
67
+
68
+ function phase(text, number, nextNumber) {
69
+ const start = text.indexOf(`### Phase ${number}:`);
70
+ assert.notEqual(start, -1, `Phase ${number} heading missing`);
71
+ const end = nextNumber === undefined ? text.length : text.indexOf(`### Phase ${nextNumber}:`, start);
72
+ assert.notEqual(end, -1, `Phase ${nextNumber} heading missing`);
73
+ return text.slice(start, end);
74
+ }
75
+
76
+ describe('feature contour consumer contract', () => {
77
+ test('green fixture binds specification, validation, completion, review, and executable evidence', (t) => {
78
+ const root = project(t);
79
+ const specification = fs.readFileSync(path.join(
80
+ root, 'docs', 'features', 'demo-gate', '01_specification.md',
81
+ ));
82
+ const validation = fs.readFileSync(path.join(
83
+ root, 'docs', 'features', 'demo-gate', 'validation-report.md',
84
+ ), 'utf8');
85
+ const expectedDigest = crypto.createHash('sha256').update(specification).digest('hex');
86
+ assert.match(validation, new RegExp(`^Spec revision: sha256:${expectedDigest}$`, 'm'),
87
+ 'the checked-in fixture revision must match the specification bytes');
88
+
89
+ const gate = checker(root);
90
+ assert.equal(gate.status, 0, gate.output);
91
+ assert.match(gate.output, /VERDICT traceability=PASS/);
92
+ assert.match(gate.output, /VERDICT completion=PASS/);
93
+ assert.match(gate.output, /VERDICT report-revision=PASS/);
94
+ assert.match(gate.output, /VERDICT criterion-scenarios=PASS/);
95
+
96
+ const review = hook(root);
97
+ assert.equal(review.status, 0, review.output);
98
+ const fixtureTests = execute(process.execPath, ['--test', 'tests/demo.test.js'], root);
99
+ assert.equal(fixtureTests.status, 0, fixtureTests.output);
100
+ });
101
+
102
+ test('deleting AC-demo-gate-2 coverage is a named completion gap', (t) => {
103
+ const root = project(t);
104
+ replace(root, 'docs/features/demo-gate/05_completion.md', (body) =>
105
+ body.replace(/^\| AC-demo-gate-2 .*\n/m, ''));
106
+ const result = checker(root, ['--completion']);
107
+ assert.equal(result.status, 1, result.output);
108
+ assert.match(result.output,
109
+ /GAP contour=demo-gate completion AC-demo-gate-2 has no row in Criterion coverage/);
110
+ });
111
+
112
+ test('changing specification bytes invalidates both report revision contracts', (t) => {
113
+ const root = project(t);
114
+ fs.appendFileSync(path.join(
115
+ root, 'docs', 'features', 'demo-gate', '01_specification.md',
116
+ ), '\n');
117
+ const revision = checker(root, ['--report-revision']);
118
+ assert.equal(revision.status, 1, revision.output);
119
+ assert.match(revision.output,
120
+ /GAP contour=demo-gate report-revision validation-report\.md sha256:.* != specification sha256:/);
121
+
122
+ const review = hook(root);
123
+ assert.equal(review.status, 1, review.output);
124
+ assert.match(review.output, /GAP Spec revision mismatch report=.* specification=/);
125
+ });
126
+
127
+ test('removing Reviewer family is a named review-contract gap', (t) => {
128
+ const root = project(t);
129
+ replace(root, 'docs/features/demo-gate/review-report.md', (body) =>
130
+ body.replace(/^Reviewer family:.*\n/m, ''));
131
+ const review = hook(root);
132
+ assert.equal(review.status, 1, review.output);
133
+ assert.match(review.output, /GAP Reviewer family line missing/);
134
+ });
135
+
136
+ test('changing a test title breaks completion evidence by name', (t) => {
137
+ const root = project(t);
138
+ replace(root, 'tests/demo.test.js', (body) =>
139
+ body.replace('rejects an invalid value', 'declines an invalid value'));
140
+ const result = checker(root, ['--completion']);
141
+ assert.equal(result.status, 1, result.output);
142
+ assert.match(result.output,
143
+ /AC-demo-gate-2 test file tests\/demo\.test\.js does not contain title "rejects an invalid value"/);
144
+ });
145
+
146
+ test('deleting AC-demo-gate-1 scenario is a named traceability-floor gap', (t) => {
147
+ const root = project(t);
148
+ replace(root, 'docs/features/demo-gate/validation-report.md', (body) =>
149
+ body.replace(/^\| AC-demo-gate-1 .*\n/m, ''));
150
+ const result = checker(root, ['--criterion-scenarios']);
151
+ assert.equal(result.status, 1, result.output);
152
+ assert.match(result.output,
153
+ /GAP contour=demo-gate criterion-scenarios AC-demo-gate-1 has no scenario row/);
154
+ });
155
+
156
+ test('feature template carries each blocking gate in its owning phase and AUTO mode', () => {
157
+ const template = fs.readFileSync(FEATURE_TEMPLATE, 'utf8');
158
+ const phase2 = phase(template, 2, 3);
159
+ const phase3 = phase(template, 3, 4);
160
+ const phase4 = phase(template, 4);
161
+ assert.match(phase2, /--report-revision/);
162
+ assert.match(phase2, /--criterion-scenarios/);
163
+ assert.match(phase3, /--completion/);
164
+ assert.match(phase3, /## Criterion coverage/);
165
+ assert.match(phase4, /check-review-contract\.cjs/);
166
+
167
+ const autoStart = template.indexOf('## AUTO mode');
168
+ const autoEnd = template.indexOf('\n## Related', autoStart);
169
+ assert.notEqual(autoStart, -1, 'AUTO mode heading missing');
170
+ assert.notEqual(autoEnd, -1, 'AUTO mode boundary missing');
171
+ const auto = template.slice(autoStart, autoEnd);
172
+ assert.match(auto, /--report-revision/);
173
+ assert.match(auto, /--criterion-scenarios/);
174
+ assert.match(auto, /--completion/);
175
+ });
176
+ });