@haposoft/cafekit 0.16.0-rc.4 → 0.16.0-rc.5

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 (28) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +136 -64
  3. package/bin/__tests__/develop-contract.test.js +55 -17
  4. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  5. package/bin/__tests__/package-inventory.test.js +29 -11
  6. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  7. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  8. package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
  9. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
  10. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
  11. package/bin/__tests__/validator-grounding.test.js +103 -4
  12. package/package.json +5 -2
  13. package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
  14. package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
  15. package/src/claude/hooks/privacy-block.cjs +38 -14
  16. package/src/claude/scripts/change-firewall.cjs +1748 -0
  17. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  18. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  19. package/src/claude/scripts/spec-final-state.cjs +46 -0
  20. package/src/claude/scripts/spec-ground.cjs +46 -10
  21. package/src/claude/scripts/spec-readiness.cjs +324 -17
  22. package/src/claude/scripts/spec-scaffold.cjs +268 -28
  23. package/src/claude/scripts/validate-spec-output.cjs +262 -10
  24. package/src/claude/skills/specs/references/review.md +14 -2
  25. package/src/claude/skills/specs/templates/spec-state.json +14 -2
  26. package/src/codex/hooks/lib/privacy-state.cjs +23 -22
  27. package/src/codex/hooks/privacy-approval.cjs +7 -3
  28. package/src/codex/hooks/privacy-block.cjs +45 -22
@@ -0,0 +1,378 @@
1
+ 'use strict';
2
+
3
+ // R1-01 owned proof: frozen C2 SemanticReviewReceipt (verdict vs
4
+ // lifecycle_disposition, structural-only checks per R3.4), C13
5
+ // SemanticReviewHistory (exact keys, whole-lineage digest dedupe), and C16
6
+ // AuthoringValidationReceipt (the sole spec-authoring-validation.cjs atomic
7
+ // writer, fail-closed freshness in validate-spec-output.cjs).
8
+
9
+ const assert = require('node:assert/strict');
10
+ const crypto = require('node:crypto');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { spawnSync } = require('node:child_process');
15
+ const test = require('node:test');
16
+
17
+ const PACKAGE_ROOT = path.resolve(__dirname, '../..');
18
+ const SCAFFOLD = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-scaffold.cjs');
19
+ const VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-spec-output.cjs');
20
+ const AUTHORING_VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-authoring-validation.cjs');
21
+
22
+ function exec(root, script, args) {
23
+ return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8' });
24
+ }
25
+
26
+ function output(result) { return `${result.stdout}\n${result.stderr}`; }
27
+
28
+ function writeSpec(specPath, spec) {
29
+ fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
30
+ }
31
+
32
+ function readSpec(specPath) {
33
+ return JSON.parse(fs.readFileSync(specPath, 'utf8'));
34
+ }
35
+
36
+ function task(id, title, ownedPath, action, criterion, taskRole) {
37
+ return `# Task ${id}: ${title}
38
+ **Status:** pending
39
+
40
+ ## Outcome
41
+ Deliver observable ${title.toLowerCase()} behavior through the declared entrypoint.
42
+
43
+ ## Scope
44
+ - **In scope:** Exact behavior for ${ownedPath}.
45
+ - **Out of scope:** Unrelated runtime behavior.
46
+
47
+ ## Anchors and Ownership
48
+ | ID | Type | Target | Role | Access | Action |
49
+ |---|---|---|---|---|---|
50
+ | A-${id}-01 | file | \`${ownedPath}\` | owner | write | ${action} |
51
+ | A-${id}-02 | artifact | \`artifacts/${id}.json\` | verifier | write | create |
52
+
53
+ ## Changes
54
+ ${criterion ? `- [ ] Implement the owned behavior. _Requirements: ${criterion.slice(1)}_` : '- [ ] Verify the subject implementation through the canonical proof boundary.'}
55
+
56
+ ## Acceptance
57
+ ${criterion ? `- **${criterion}:** The command returns the concrete expected state.` : '- The verifier produces an observable proof artifact without claiming the subject acceptance criterion.'}
58
+
59
+ ## Dependencies
60
+ - none
61
+
62
+ ## Verification Plan
63
+ - **Verification ref:** V1
64
+ - **Task role:** ${taskRole}
65
+ - **Command:** \`node --test test/service.test.js\`
66
+ - **Expected:** Exit code 0 and exact state enabled.
67
+ - **Negative path:** Invalid input returns the specified failure state.
68
+ - **Reachability:** \`src/entry.js\`
69
+ `;
70
+ }
71
+
72
+ // A ready, coordinator-validated fixture. C16's own receipt is produced by
73
+ // the real spec-authoring-validation.cjs coordinator, never hand-written, so
74
+ // every test here starts from a byte-accurate receipt.
75
+ function makeReadyFixture() {
76
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-c2-'));
77
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
78
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
79
+ fs.mkdirSync(path.join(root, 'artifacts'), { recursive: true });
80
+ fs.writeFileSync(path.join(root, 'src/entry.js'), 'module.exports = { enabled: true };\n');
81
+ fs.writeFileSync(path.join(root, 'src/service.js'), 'module.exports = { enabled: true };\n');
82
+ fs.writeFileSync(path.join(root, 'test/service.test.js'), 'require("node:test")("service",()=>{});\n');
83
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ scripts: { test: 'node --test' } }));
84
+ const boundaries = [{
85
+ id: 'B-OWN', type: 'ownership', tasks: ['R1-01', 'R1-02'],
86
+ write_sets: {
87
+ 'R1-01': ['src/entry.js', 'artifacts/R1-01.json'],
88
+ 'R1-02': ['src/service.js', 'artifacts/R1-02.json'],
89
+ },
90
+ }, {
91
+ id: 'B-V', type: 'proof', subject: 'R1-01', verifier: 'R1-02',
92
+ verification_ref: 'V1', artifact_anchor: 'A-R1-02-02',
93
+ }];
94
+ const scaffold = exec(root, SCAFFOLD, ['feature', '--tasks', 'R1-01-entry,R1-02-verify', '--boundaries', JSON.stringify(boundaries)]);
95
+ assert.equal(scaffold.status, 0, scaffold.stderr);
96
+ const specDir = path.join(root, 'specs/feature');
97
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
98
+
99
+ ## Requirements
100
+ ### Requirement 1: Entry behavior
101
+ - **R1.1**: When valid input arrives, the entry shall return enabled state.
102
+ - **R1.2**: The verifier shall produce a separate observable proof artifact for the enabled state.
103
+ `);
104
+ fs.writeFileSync(path.join(root, 'src/design-boundary.js'), 'module.exports = {};\n');
105
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
106
+
107
+ ## Architecture
108
+ The entry delegates to the service and emits a verifier-owned artifact.
109
+
110
+ ## Typed Anchors
111
+ | ID | Type | Target | Role | Access | Action |
112
+ |---|---|---|---|---|---|
113
+ | A-D-01 | file | \`src/design-boundary.js\` | design boundary | read | read |
114
+
115
+ ## Canonical Contracts & Invariants
116
+ ### D1 — Entry decision
117
+ The entry always returns enabled true on valid input.
118
+ ### I1 — Invalid-state invariant
119
+ Invalid input never returns enabled state.
120
+ ### C1 — Result contract
121
+ The result contains an enabled boolean.
122
+
123
+ ## Verification Definitions
124
+ - **V1**: Criteria R1.1; Owner R1-01; Proof criteria R1.2; Proof owner R1-02; Evidence anchor A-R1-02-02; Decision refs D1, I1, C1; Method command \`node --test test/service.test.js\`; Expected exit 0 with enabled state and verifier artifact; Negative/failure invalid input returns rejected state without an enabled result; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01, A-R1-02-02.
125
+ `);
126
+ fs.writeFileSync(path.join(specDir, 'tasks/task-R1-01-entry.md'), task('R1-01', 'Entry', 'src/entry.js', 'modify', 'R1.1', 'subject'));
127
+ fs.writeFileSync(path.join(specDir, 'tasks/task-R1-02-verify.md'), task('R1-02', 'Verify', 'src/service.js', 'modify', 'R1.2', 'verifier'));
128
+ const promoted = exec(root, SCAFFOLD, ['feature', '--sync-semantic-model']);
129
+ assert.equal(promoted.status, 0, output(promoted));
130
+ const specPath = path.join(specDir, 'spec.json');
131
+ const coordinated = exec(root, AUTHORING_VALIDATOR, [specDir]);
132
+ assert.equal(coordinated.status, 0, output(coordinated));
133
+ const spec = readSpec(specPath);
134
+ spec.validation.status = 'completed';
135
+ spec.validation.semantic_review = {
136
+ status: 'completed',
137
+ semantic_digest: null,
138
+ verdict: 'PASS',
139
+ lifecycle_disposition: 'CONTINUE',
140
+ findings: [],
141
+ unresolved_decisions: [],
142
+ graph_coverage: [
143
+ 'criterion_local', 'cross_criterion', 'runtime_path', 'assumption_provenance', 'compatibility_migration',
144
+ ].map((surface) => ({ surface, covered: true, notes: 'Fixture coverage.' })),
145
+ repair_round: 0,
146
+ reviewed_criteria: ['R1.1', 'R1.2'],
147
+ counterexamples: [{
148
+ criterion: 'R1.1', case_kind: 'failure',
149
+ scenario: 'The entry receives an invalid input object without the required discriminator.',
150
+ expected: 'The entry returns rejected state and never emits enabled true.',
151
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
152
+ }, {
153
+ criterion: 'R1.2', case_kind: 'adversarial',
154
+ scenario: 'The implementation reports enabled state without producing the verifier-owned artifact.',
155
+ expected: 'Verification fails because the separate proof artifact is absent.',
156
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
157
+ }],
158
+ reviewer_evidence: { assurance: 'Routine', summary: 'Fixture reviewer evidence for structural contract test.' },
159
+ };
160
+ writeSpec(specPath, spec);
161
+ const digest = exec(root, VALIDATOR, [specDir, '--semantic-digest']);
162
+ assert.equal(digest.status, 0, output(digest));
163
+ const refreshed = readSpec(specPath);
164
+ refreshed.validation.semantic_review.semantic_digest = digest.stdout.trim();
165
+ refreshed.ready_for_implementation = true;
166
+ writeSpec(specPath, refreshed);
167
+ return { root, specDir, specPath };
168
+ }
169
+
170
+ function cleanup(fixture) {
171
+ fs.rmSync(fixture.root, { recursive: true, force: true });
172
+ }
173
+
174
+ function sha256Tag(bytes) {
175
+ return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
176
+ }
177
+
178
+ test('C2 receipt freezes the exact key set with separate verdict and lifecycle_disposition', () => {
179
+ const fixture = makeReadyFixture();
180
+ try {
181
+ const clean = exec(fixture.root, VALIDATOR, [fixture.specDir]);
182
+ assert.equal(clean.status, 0, output(clean));
183
+ // Snapshot the known-clean baseline once; every mutation below clones it
184
+ // fresh so one case's leftover mutation never contaminates the next.
185
+ const baseline = readSpec(fixture.specPath);
186
+
187
+ const missingLifecycle = JSON.parse(JSON.stringify(baseline));
188
+ missingLifecycle.validation.semantic_review.lifecycle_disposition = null;
189
+ writeSpec(fixture.specPath, missingLifecycle);
190
+ const result1 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
191
+ assert.equal(result1.status, 1, output(result1));
192
+ assert.match(output(result1), /lifecycle_disposition: must be CONTINUE or BLOCKED when completed/);
193
+
194
+ const extraKey = JSON.parse(JSON.stringify(baseline));
195
+ extraKey.validation.semantic_review.reviewer_note = 'not a canonical C2 field';
196
+ writeSpec(fixture.specPath, extraKey);
197
+ const result2 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
198
+ assert.equal(result2.status, 1, output(result2));
199
+ assert.match(output(result2), /semantic_review: schema 2\.1 fields must be exactly/);
200
+
201
+ const conflated = JSON.parse(JSON.stringify(baseline));
202
+ conflated.validation.semantic_review.verdict = 'FAIL';
203
+ conflated.validation.semantic_review.lifecycle_disposition = 'CONTINUE';
204
+ writeSpec(fixture.specPath, conflated);
205
+ const result3 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
206
+ // R3.4: structural validator enforces shape/type/enum/cardinality only —
207
+ // it does not judge whether a FAIL+CONTINUE pairing is a meaningful
208
+ // review outcome (that policy belongs to the R1-02 finalizer).
209
+ assert.equal(result3.status, 0, output(result3));
210
+ } finally { cleanup(fixture); }
211
+ });
212
+
213
+ test('sync migrates an exact legacy review into a fresh C2/C13/C16 lifecycle even when the semantic model is unchanged', () => {
214
+ const fixture = makeReadyFixture();
215
+ try {
216
+ const legacy = readSpec(fixture.specPath);
217
+ legacy.validation = {
218
+ status: 'completed',
219
+ semantic_review: {
220
+ status: 'completed',
221
+ semantic_digest: legacy.validation.semantic_review.semantic_digest,
222
+ reviewed_criteria: legacy.validation.semantic_review.reviewed_criteria,
223
+ counterexamples: legacy.validation.semantic_review.counterexamples,
224
+ },
225
+ };
226
+ legacy.ready_for_implementation = true;
227
+ writeSpec(fixture.specPath, legacy);
228
+
229
+ const migrated = exec(fixture.root, SCAFFOLD, ['feature', '--sync-semantic-model']);
230
+ assert.equal(migrated.status, 0, output(migrated));
231
+ const next = readSpec(fixture.specPath);
232
+ assert.equal(next.ready_for_implementation, false);
233
+ assert.equal(next.validation.status, 'not-run');
234
+ assert.deepEqual(Object.keys(next.validation).sort(), [
235
+ 'authoring_validation', 'semantic_review', 'semantic_review_history', 'status',
236
+ ]);
237
+ assert.equal(next.validation.semantic_review.status, 'not-run');
238
+ assert.deepEqual(next.validation.semantic_review_history.entries, []);
239
+ assert.equal(next.validation.authoring_validation, null);
240
+ assert.equal(next.authoring.requirements, 'draft');
241
+ assert.equal(next.authoring.design, 'draft');
242
+ assert.equal(next.authoring.tasks, 'draft');
243
+ } finally { cleanup(fixture); }
244
+ });
245
+
246
+ test('C2 findings/unresolved_decisions/graph_coverage/reviewer_evidence enforce exact shape', () => {
247
+ const fixture = makeReadyFixture();
248
+ try {
249
+ const badFinding = readSpec(fixture.specPath);
250
+ badFinding.validation.semantic_review.findings = [{
251
+ id: 'F1', severity: 'Severe', location: 'design.md', summary: 'A concrete finding summary.', blocking: false, disposition: 'open',
252
+ }];
253
+ writeSpec(fixture.specPath, badFinding);
254
+ const result1 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
255
+ assert.equal(result1.status, 1, output(result1));
256
+ assert.match(output(result1), /findings\[0\]\.severity: must be Critical, High, Medium, or Low/);
257
+
258
+ const badCoverage = readSpec(fixture.specPath);
259
+ badCoverage.validation.semantic_review.graph_coverage = badCoverage.validation.semantic_review.graph_coverage.slice(0, 4);
260
+ writeSpec(fixture.specPath, badCoverage);
261
+ const result2 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
262
+ assert.equal(result2.status, 1, output(result2));
263
+ assert.match(output(result2), /graph_coverage: missing required surface/);
264
+
265
+ const badEvidence = readSpec(fixture.specPath);
266
+ badEvidence.validation.semantic_review.reviewer_evidence = { assurance: 'Overkill', summary: 'A concrete reviewer summary.' };
267
+ writeSpec(fixture.specPath, badEvidence);
268
+ const result3 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
269
+ assert.equal(result3.status, 1, output(result3));
270
+ assert.match(output(result3), /reviewer_evidence\.assurance: must be Routine, Elevated, or Strict/);
271
+ } finally { cleanup(fixture); }
272
+ });
273
+
274
+ test('C13 semantic_review_history freezes exact keys and dedupes across the whole lineage', () => {
275
+ const fixture = makeReadyFixture();
276
+ try {
277
+ const spec = readSpec(fixture.specPath);
278
+ const receiptDigestA = sha256Tag(Buffer.from('receipt-a'));
279
+ const receiptDigestB = sha256Tag(Buffer.from('receipt-b'));
280
+ const semanticDigest = spec.validation.semantic_review.semantic_digest;
281
+ spec.validation.semantic_review_history.entries = [
282
+ {
283
+ sequence: 0, semantic_digest: semanticDigest, review_receipt_digest: receiptDigestA,
284
+ verdict: 'FAIL', lifecycle_disposition: 'CONTINUE', blocking_count: 1, attempt_index: 0, review_epoch: 0,
285
+ },
286
+ {
287
+ sequence: 1, semantic_digest: semanticDigest, review_receipt_digest: receiptDigestB,
288
+ verdict: 'PASS', lifecycle_disposition: 'CONTINUE', blocking_count: 0, attempt_index: 1, review_epoch: 0,
289
+ },
290
+ ];
291
+ writeSpec(fixture.specPath, spec);
292
+ const clean = exec(fixture.root, VALIDATOR, [fixture.specDir]);
293
+ assert.equal(clean.status, 0, output(clean));
294
+ // Snapshot the known-clean 2-entry baseline once; every mutation below
295
+ // clones it fresh so one case's leftover mutation never contaminates the
296
+ // next (each case must independently isolate the single defect it proves).
297
+ const baseline = readSpec(fixture.specPath);
298
+
299
+ const missingEpoch = JSON.parse(JSON.stringify(baseline));
300
+ delete missingEpoch.validation.semantic_review_history.entries[0].review_epoch;
301
+ writeSpec(fixture.specPath, missingEpoch);
302
+ const result1 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
303
+ assert.equal(result1.status, 1, output(result1));
304
+ assert.match(output(result1), /entries\[0\]: fields must be exactly/);
305
+
306
+ // Whole-lineage dedupe: a third entry replaying entry 0's digest is a
307
+ // replay even though entry 0 is not the latest entry.
308
+ const replay = JSON.parse(JSON.stringify(baseline));
309
+ replay.validation.semantic_review_history.entries.push({
310
+ sequence: 2, semantic_digest: semanticDigest, review_receipt_digest: receiptDigestA,
311
+ verdict: 'PASS', lifecycle_disposition: 'CONTINUE', blocking_count: 0, attempt_index: 2, review_epoch: 0,
312
+ });
313
+ writeSpec(fixture.specPath, replay);
314
+ const result2 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
315
+ assert.equal(result2.status, 1, output(result2));
316
+ assert.match(output(result2), /review_receipt_digest: replays an existing entry's digest anywhere in the lineage/);
317
+
318
+ const badLineage = JSON.parse(JSON.stringify(baseline));
319
+ badLineage.validation.semantic_review_history.lineage_id = sha256Tag(Buffer.from('wrong'));
320
+ writeSpec(fixture.specPath, badLineage);
321
+ const result3 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
322
+ assert.equal(result3.status, 1, output(result3));
323
+ assert.match(output(result3), /lineage_id: must be sha256 of feature_name and created_at/);
324
+ } finally { cleanup(fixture); }
325
+ });
326
+
327
+ test('C16 coordinator is the sole atomic writer: clean pass writes, a failing pass leaves spec.json untouched', () => {
328
+ const fixture = makeReadyFixture();
329
+ try {
330
+ const before = readSpec(fixture.specPath);
331
+ assert.equal(before.authoring.requirements, 'validated');
332
+ assert.equal(before.authoring.design, 'validated');
333
+ assert.equal(before.authoring.tasks, 'validated');
334
+ assert.ok(before.validation.authoring_validation);
335
+ assert.match(before.validation.authoring_validation.requirements.digest, /^sha256:[a-f0-9]{64}$/);
336
+
337
+ // Re-running the coordinator on already-fresh bytes is a clean no-op pass.
338
+ const rerun = exec(fixture.root, AUTHORING_VALIDATOR, [fixture.specDir]);
339
+ assert.equal(rerun.status, 0, output(rerun));
340
+
341
+ // Break grounding (dangling design anchor target) so the coordinator's
342
+ // own validate+ground pass fails; spec.json bytes must stay identical.
343
+ const designPath = path.join(fixture.specDir, 'design.md');
344
+ fs.writeFileSync(designPath, fs.readFileSync(designPath, 'utf8').replace(
345
+ '| A-D-01 | file | `src/design-boundary.js` | design boundary | read | read |',
346
+ '| A-D-01 | file | `src/does-not-exist.js` | design boundary | read | read |',
347
+ ));
348
+ const beforeBytes = fs.readFileSync(fixture.specPath, 'utf8');
349
+ const failing = exec(fixture.root, AUTHORING_VALIDATOR, [fixture.specDir]);
350
+ assert.notEqual(failing.status, 0, output(failing));
351
+ const afterBytes = fs.readFileSync(fixture.specPath, 'utf8');
352
+ assert.equal(afterBytes, beforeBytes, 'a failed coordinator run must leave spec.json completely unchanged');
353
+ } finally { cleanup(fixture); }
354
+ });
355
+
356
+ test('C16/I20 fail-closed freshness: an edit after validation reverts the stage to draft-equivalent structurally', () => {
357
+ const fixture = makeReadyFixture();
358
+ try {
359
+ const clean = exec(fixture.root, VALIDATOR, [fixture.specDir]);
360
+ assert.equal(clean.status, 0, output(clean));
361
+
362
+ // Edit requirements.md after the coordinator already ran; do not rerun the
363
+ // coordinator. authoring.requirements still reads validated, but its
364
+ // receipt digest no longer matches the current bytes.
365
+ fs.appendFileSync(path.join(fixture.specDir, 'requirements.md'), '\nA later edit changes the authored requirement text.\n');
366
+ const stale = exec(fixture.root, VALIDATOR, [fixture.specDir]);
367
+ assert.equal(stale.status, 1, output(stale));
368
+ assert.match(output(stale), /spec\.json\.authoring\.requirements: reads validated but validation\.authoring_validation.*digest-mismatched/);
369
+
370
+ // An absent receipt with a validated reading is refused identically.
371
+ const missingReceipt = readSpec(fixture.specPath);
372
+ missingReceipt.validation.authoring_validation = null;
373
+ writeSpec(fixture.specPath, missingReceipt);
374
+ const absent = exec(fixture.root, VALIDATOR, [fixture.specDir]);
375
+ assert.equal(absent.status, 1, output(absent));
376
+ assert.match(output(absent), /spec\.json\.authoring\.requirements: reads validated but validation\.authoring_validation is absent/);
377
+ } finally { cleanup(fixture); }
378
+ });
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const assert = require('node:assert/strict');
4
+ const crypto = require('node:crypto');
4
5
  const fs = require('node:fs');
5
6
  const os = require('node:os');
6
7
  const path = require('node:path');
@@ -14,6 +15,8 @@ const PROVENANCE = require(path.join(SCRIPTS, 'provenance.cjs'));
14
15
  const RECEIPT = require(path.join(SCRIPTS, 'spec-receipt.cjs'));
15
16
  const SCAFFOLD = path.join(SCRIPTS, 'spec-scaffold.cjs');
16
17
  const VALIDATOR = path.join(SCRIPTS, 'validate-spec-output.cjs');
18
+ const AUTHORING_VALIDATION = path.join(SCRIPTS, 'spec-authoring-validation.cjs');
19
+ const RUNTIME_CLOSURE = require('./test-runtime-dependency-closure.cjs');
17
20
  const SEMANTIC_MODEL = require(path.join(SCRIPTS, 'spec-semantic-model.cjs'));
18
21
  const CLAUDE_CHECK = require(path.join(ROOT, 'src/claude/hooks/completion-authority-check.cjs'));
19
22
  const CODEX_CHECK = require(path.join(ROOT, 'src/codex/hooks/completion-authority-check.cjs'));
@@ -143,12 +146,41 @@ Only a validated and grounded current digest reaches closeout.
143
146
  R1.1 and R1.2 are verified by V1 against D1, I1, and C1.
144
147
  `);
145
148
  const specPath = path.join(featureDir, 'spec.json');
149
+ const seed = JSON.parse(fs.readFileSync(specPath, 'utf8'));
150
+ const seedProjection = SEMANTIC_MODEL.modelFromMarkdown(featureDir, seed);
151
+ assert.deepEqual(seedProjection.errors, []);
152
+ seed.semantic_model = seedProjection.model;
153
+ fs.writeFileSync(specPath, `${JSON.stringify(seed, null, 2)}\n`);
154
+ // I21: only the C16 coordinator may set an authoring stage to `validated`,
155
+ // and only together with the digest receipt that proves it fresh. The fixture
156
+ // runs the real command instead of hand-authoring the enum.
157
+ const authored = spawnSync(
158
+ process.execPath,
159
+ [AUTHORING_VALIDATION, featureDir, '--root', root],
160
+ { cwd: root, encoding: 'utf8' },
161
+ );
162
+ assert.equal(authored.status, 0, `${authored.stdout}\n${authored.stderr}`);
146
163
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
147
- spec.authoring.requirements = 'validated';
148
- spec.authoring.design = 'validated';
164
+ assert.equal(spec.authoring.requirements, 'validated');
165
+ assert.equal(spec.authoring.design, 'validated');
149
166
  spec.validation.status = 'completed';
150
167
  spec.validation.semantic_review = {
151
- status: 'completed', semantic_digest: null, reviewed_criteria: ['R1.1', 'R1.2'],
168
+ status: 'completed',
169
+ semantic_digest: null,
170
+ verdict: 'PASS',
171
+ lifecycle_disposition: 'CONTINUE',
172
+ findings: [],
173
+ unresolved_decisions: [],
174
+ graph_coverage: [
175
+ 'criterion_local', 'cross_criterion', 'runtime_path',
176
+ 'assumption_provenance', 'compatibility_migration',
177
+ ].map((surface) => ({ surface, covered: true, notes: `${surface} reviewed for the closeout fixture.` })),
178
+ repair_round: 0,
179
+ reviewer_evidence: {
180
+ assurance: 'Routine',
181
+ summary: 'Closeout fixture reviewer confirmed both criteria against the grounded final-state boundary.',
182
+ },
183
+ reviewed_criteria: ['R1.1', 'R1.2'],
152
184
  counterexamples: [{
153
185
  criterion: 'R1.1', case_kind: 'failure',
154
186
  scenario: 'A requirements artifact changes after the reviewed digest was recorded.',
@@ -161,13 +193,24 @@ R1.1 and R1.2 are verified by V1 against D1, I1, and C1.
161
193
  decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
162
194
  }],
163
195
  };
164
- const projection = SEMANTIC_MODEL.modelFromMarkdown(featureDir, spec);
165
- assert.deepEqual(projection.errors, []);
166
- spec.semantic_model = projection.model;
167
196
  fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
168
197
  const digest = spawnSync(process.execPath, [VALIDATOR, featureDir, '--semantic-digest'], { cwd: root, encoding: 'utf8' });
169
198
  assert.equal(digest.status, 0, `${digest.stdout}\n${digest.stderr}`);
170
199
  spec.validation.semantic_review.semantic_digest = digest.stdout.trim();
200
+ // C13: a completed receipt is only terminal when the durable history records
201
+ // it. The entry binds the same semantic digest, the receipt's own canonical
202
+ // digest, and the attempt index the receipt reports as repair_round.
203
+ spec.validation.semantic_review_history.entries = [{
204
+ sequence: 0,
205
+ semantic_digest: spec.validation.semantic_review.semantic_digest,
206
+ review_receipt_digest: `sha256:${crypto.createHash('sha256')
207
+ .update(SEMANTIC_MODEL.stableJson(spec.validation.semantic_review), 'utf8').digest('hex')}`,
208
+ verdict: 'PASS',
209
+ lifecycle_disposition: 'CONTINUE',
210
+ blocking_count: 0,
211
+ attempt_index: spec.validation.semantic_review.repair_round,
212
+ review_epoch: 0,
213
+ }];
171
214
  spec.ready_for_implementation = true;
172
215
  spec.status = specStatus === 'complete' ? 'done' : specStatus;
173
216
  delete spec.current_phase;
@@ -212,12 +255,16 @@ function installGate(root, kind) {
212
255
  fs.mkdirSync(path.join(runtimeDir, 'hooks'), { recursive: true });
213
256
  fs.mkdirSync(path.join(runtimeDir, 'hooks', 'lib'), { recursive: true });
214
257
  fs.mkdirSync(path.join(runtimeDir, 'scripts'), { recursive: true });
215
- for (const name of [
216
- 'workflow-policy.cjs', 'provenance.cjs', 'spec-resolver.cjs', 'spec-receipt.cjs',
217
- 'validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs',
218
- ]) {
219
- fs.copyFileSync(path.join(SCRIPTS, name), path.join(runtimeDir, 'scripts', name));
220
- }
258
+ // Copy the real transitive require closure rather than a hand-listed subset.
259
+ // A hand-listed subset silently rots the moment one of these entrypoints
260
+ // gains a new local dependency: the fixture keeps building, and the gate only
261
+ // fails later with a bare MODULE_NOT_FOUND from inside the installed runtime.
262
+ RUNTIME_CLOSURE.copyClaudeTestRuntime(ROOT, runtimeDir, [
263
+ 'scripts/workflow-policy.cjs', 'scripts/provenance.cjs', 'scripts/spec-resolver.cjs',
264
+ 'scripts/spec-receipt.cjs', 'scripts/validate-spec-output.cjs', 'scripts/spec-ground.cjs',
265
+ 'scripts/spec-semantic-model.cjs', 'scripts/spec-final-state.cjs',
266
+ 'hooks/lib/runtime-path-safety.cjs',
267
+ ]);
221
268
  if (kind === 'claude') {
222
269
  for (const name of [
223
270
  'spec-gate.cjs', 'completion-authority-check.cjs', 'completion-authority-state.cjs',
@@ -32,6 +32,25 @@ function readSpec(root, feature) {
32
32
  return JSON.parse(fs.readFileSync(path.join(root, 'specs', feature, 'spec.json'), 'utf8'));
33
33
  }
34
34
 
35
+ // A canonical 2.1 scaffold always emits the C2 semantic_review shape plus the
36
+ // C13 history and C16 receipt slots. A genuine schema 2.0 record predates all
37
+ // three, so a fixture that simulates legacy migration input must strip them —
38
+ // relabelling schema_version alone produces a record that never existed and
39
+ // trips the 2.0 allowlist before the behavior under test is ever reached.
40
+ function downgradeToLegacy20(spec) {
41
+ spec.schema_version = '2.0';
42
+ delete spec.validation.semantic_review_history;
43
+ delete spec.validation.authoring_validation;
44
+ const review = spec.validation.semantic_review;
45
+ spec.validation.semantic_review = {
46
+ status: review.status,
47
+ semantic_digest: review.semantic_digest,
48
+ reviewed_criteria: review.reviewed_criteria,
49
+ counterexamples: review.counterexamples,
50
+ };
51
+ return spec;
52
+ }
53
+
35
54
  function ownershipBoundary() {
36
55
  return JSON.stringify([{
37
56
  id: 'B-OWN', type: 'ownership', tasks: ['R1-01', 'R1-02'],
@@ -250,8 +269,7 @@ test('durable transaction recovery covers every fresh and replacement rename bou
250
269
  ]);
251
270
  assert.equal(initial.status, 0, output(initial));
252
271
  const specPath = path.join(root, 'specs', feature, 'spec.json');
253
- const spec = readSpec(root, feature);
254
- spec.schema_version = '2.0';
272
+ const spec = downgradeToLegacy20(readSpec(root, feature));
255
273
  fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
256
274
  },
257
275
  invoke(root, feature, env) { return runMatchingTasksOnly(root, feature, env); },
@@ -362,23 +380,37 @@ test('tasks-only normalization is table-driven, conservative, and idempotent', (
362
380
  ]);
363
381
  assert.equal(Object.hasOwn(spec.task_registry['tasks/task-R1-01-one.md'], 'legacy_owner'), false);
364
382
  assert.deepEqual(Object.keys(spec.workflow_policy), CANONICAL_POLICY_FIELDS);
383
+ // The legacy input asserts requirements: 'validated' with no C16 receipt
384
+ // behind it. Migration downgrades it to draft rather than carrying the
385
+ // unprovable claim forward: only spec-authoring-validation.cjs may write
386
+ // `validated`, and only together with a matching digest receipt (I21).
365
387
  assert.deepEqual(spec.authoring, {
366
- requirements: 'validated', design: 'draft', research: 'absent', tasks: 'draft',
388
+ requirements: 'draft', design: 'draft', research: 'absent', tasks: 'draft',
367
389
  });
368
390
  assert.equal(spec.ready_for_implementation, false);
369
- assert.deepEqual(spec.validation, {
370
- status: 'not-run',
371
- semantic_review: {
372
- status: 'not-run', semantic_digest: null, reviewed_criteria: [], counterexamples: [],
373
- },
391
+ // A legacy validation block never satisfies the canonical 2.1 shape, so
392
+ // migration replaces it with the fresh C2 review, a seeded C13 lineage,
393
+ // and an empty C16 slot — the legacy digest/criteria are not carried
394
+ // forward as if they had been reviewed under the current contract.
395
+ assert.deepEqual(Object.keys(spec.validation).sort(), [
396
+ 'authoring_validation', 'semantic_review', 'semantic_review_history', 'status',
397
+ ]);
398
+ assert.equal(spec.validation.status, 'not-run');
399
+ assert.deepEqual(spec.validation.semantic_review, {
400
+ status: 'not-run', semantic_digest: null, verdict: null, lifecycle_disposition: null,
401
+ findings: [], unresolved_decisions: [], graph_coverage: [], repair_round: 0,
402
+ reviewed_criteria: [], counterexamples: [], reviewer_evidence: null,
374
403
  });
404
+ assert.deepEqual(spec.validation.semantic_review_history.entries, []);
405
+ assert.match(spec.validation.semantic_review_history.lineage_id, /^sha256:[0-9a-f]{64}$/);
406
+ assert.equal(spec.validation.authoring_validation, null);
375
407
  },
376
408
  },
377
409
  {
378
410
  name: 'legacy research pointer and valid done lifecycle',
379
411
  research: true,
380
412
  mutate(spec) {
381
- spec.schema_version = '2.0';
413
+ downgradeToLegacy20(spec);
382
414
  spec.research = { path: 'research.md' };
383
415
  delete spec.authoring.research;
384
416
  const task = spec.task_registry['tasks/task-R1-01-one.md'];
@@ -402,7 +434,7 @@ test('tasks-only normalization is table-driven, conservative, and idempotent', (
402
434
  {
403
435
  name: 'legacy nested inert receipt read migration',
404
436
  mutate(spec) {
405
- spec.schema_version = '2.0';
437
+ downgradeToLegacy20(spec);
406
438
  spec.workflow_policy.override_receipt = null;
407
439
  },
408
440
  verify(spec) {
@@ -522,6 +554,7 @@ test('tasks-only migration rejects invalid evidence without any write', () => {
522
554
  {
523
555
  name: 'ambiguous top level inert receipt',
524
556
  mutate(root, feature, spec) {
557
+ downgradeToLegacy20(spec);
525
558
  delete spec.schema_version;
526
559
  spec.override_receipt = null;
527
560
  fs.writeFileSync(path.join(root, 'specs', feature, 'spec.json'), `${JSON.stringify(spec, null, 2)}\n`);
@@ -531,7 +564,7 @@ test('tasks-only migration rejects invalid evidence without any write', () => {
531
564
  {
532
565
  name: 'legacy top level non inert receipt',
533
566
  mutate(root, feature, spec) {
534
- spec.schema_version = '2.0';
567
+ downgradeToLegacy20(spec);
535
568
  spec.override_receipt = { issuer: 'unsupported' };
536
569
  fs.writeFileSync(path.join(root, 'specs', feature, 'spec.json'), `${JSON.stringify(spec, null, 2)}\n`);
537
570
  },
@@ -587,15 +620,46 @@ test('schema 2.0 migration rejects unknown authority fields instead of projectio
587
620
  const feature = `legacy-unknown-${name}`;
588
621
  assert.equal(run(root, [feature, '--tasks', 'R1-01-one,R1-02-two', '--boundaries', ownershipBoundary()]).status, 0);
589
622
  const specPath = path.join(root, 'specs', feature, 'spec.json');
590
- const spec = readSpec(root, feature);
591
- spec.schema_version = '2.0';
623
+ const spec = downgradeToLegacy20(readSpec(root, feature));
592
624
  mutate(spec);
593
625
  fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
594
626
  const before = readTree(path.dirname(specPath));
595
627
  const result = runMatchingTasksOnly(root, feature);
596
628
  assert.equal(result.status, 2, `${name}: ${output(result)}`);
597
- assert.match(output(result), /unsupported legacy authority field/);
629
+ // Must name the injected dead_authority field specifically: before the
630
+ // legacy downgrade above, this matched on the 2.1-only validation records
631
+ // instead and passed without exercising the mutation under test.
632
+ assert.match(output(result), /unsupported legacy authority field.*dead_authority/);
598
633
  assert.deepEqual(readTree(path.dirname(specPath)), before);
599
634
  } finally { fs.rmSync(root, { recursive: true, force: true }); }
600
635
  }
601
636
  });
637
+
638
+ test('--help and --version answer on stdout before any precondition runs', () => {
639
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-info-flags-'));
640
+ try {
641
+ for (const flag of ['--help', '-h']) {
642
+ const result = run(root, [flag]);
643
+ assert.equal(result.status, 0, `${flag}: ${output(result)}`);
644
+ assert.match(result.stdout, /^Usage: node spec-scaffold\.cjs/, `${flag} must print usage on stdout`);
645
+ assert.equal(result.stderr, '', `${flag} must not write to stderr`);
646
+ }
647
+
648
+ for (const flag of ['--version', '-v']) {
649
+ const result = run(root, [flag]);
650
+ assert.equal(result.status, 0, `${flag}: ${output(result)}`);
651
+ // Prerelease suffixes are normal on this package (e.g. 0.16.0-rc.4).
652
+ assert.match(result.stdout.trim(), /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/, `${flag} must print a SemVer version`);
653
+ assert.equal(result.stderr, '', `${flag} must not write to stderr`);
654
+ }
655
+
656
+ // A help flag must never be read as a feature name, and must not create a spec.
657
+ assert.deepEqual(fs.readdirSync(root), [], 'info flags must not touch the work tree');
658
+
659
+ // The no-argument path keeps usage-as-diagnostic: stderr, exit 2.
660
+ const bare = run(root, []);
661
+ assert.equal(bare.status, 2, output(bare));
662
+ assert.equal(bare.stdout, '', 'bare invocation must not print usage on stdout');
663
+ assert.match(bare.stderr, /^Usage: node spec-scaffold\.cjs/);
664
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
665
+ });