@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
@@ -3,10 +3,27 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const crypto = require('crypto');
6
7
  const SEMANTIC = require('./spec-semantic-model.cjs');
7
8
  const VALIDATOR = require('./validate-spec-output.cjs');
8
9
  const { canonicalProjectRoot, groundSpec } = require('./spec-ground.cjs');
9
10
 
11
+ const REVIEW_RESULT_FIELDS = Object.freeze([
12
+ 'verdict', 'findings', 'unresolved_decisions', 'graph_coverage',
13
+ 'reviewed_criteria', 'counterexamples', 'reviewer_evidence',
14
+ ]);
15
+ const C2_RECEIPT_FIELDS = Object.freeze([
16
+ 'status', 'semantic_digest', 'verdict', 'lifecycle_disposition',
17
+ 'findings', 'unresolved_decisions', 'graph_coverage', 'repair_round',
18
+ 'reviewed_criteria', 'counterexamples', 'reviewer_evidence',
19
+ ]);
20
+ const HISTORY_FIELDS = Object.freeze(['lineage_id', 'entries']);
21
+ const HISTORY_ENTRY_FIELDS = Object.freeze([
22
+ 'sequence', 'semantic_digest', 'review_receipt_digest', 'verdict',
23
+ 'lifecycle_disposition', 'blocking_count', 'attempt_index', 'review_epoch',
24
+ ]);
25
+ const SHA256_RE = /^sha256:[0-9a-f]{64}$/;
26
+
10
27
  function plain(value) {
11
28
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
12
29
  }
@@ -16,13 +33,238 @@ function exactKeys(value, fields) {
16
33
  && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort());
17
34
  }
18
35
 
36
+ function stableDigest(value, semantic) {
37
+ if (!semantic || typeof semantic.stableJson !== 'function') {
38
+ throw new Error('semantic stableJson is required for readiness receipts');
39
+ }
40
+ const stable = semantic.stableJson(value);
41
+ return `sha256:${crypto.createHash('sha256').update(Buffer.from(stable, 'utf8')).digest('hex')}`;
42
+ }
43
+
44
+ function requireString(value, label, minimum = 1) {
45
+ if (typeof value !== 'string' || value.length < minimum) {
46
+ throw new Error(`${label} must be a string with at least ${minimum} characters`);
47
+ }
48
+ }
49
+
50
+ function validateReviewFinding(finding, index) {
51
+ if (!exactKeys(finding, ['id', 'severity', 'location', 'summary', 'blocking', 'disposition'])) {
52
+ throw new Error(`semantic review finding ${index} fields must be exact`);
53
+ }
54
+ requireString(finding.id, `semantic review finding ${index}.id`);
55
+ requireString(finding.location, `semantic review finding ${index}.location`);
56
+ requireString(finding.summary, `semantic review finding ${index}.summary`, 8);
57
+ if (!['Critical', 'High', 'Medium', 'Low'].includes(finding.severity)) {
58
+ throw new Error(`semantic review finding ${index}.severity is invalid`);
59
+ }
60
+ if (typeof finding.blocking !== 'boolean') {
61
+ throw new Error(`semantic review finding ${index}.blocking must be boolean`);
62
+ }
63
+ if (!['open', 'accepted', 'mitigated'].includes(finding.disposition)) {
64
+ throw new Error(`semantic review finding ${index}.disposition is invalid`);
65
+ }
66
+ }
67
+
68
+ function validateReviewDecision(decision, index) {
69
+ if (!exactKeys(decision, ['id', 'summary', 'blocking'])) {
70
+ throw new Error(`semantic review unresolved decision ${index} fields must be exact`);
71
+ }
72
+ requireString(decision.id, `semantic review unresolved decision ${index}.id`);
73
+ requireString(decision.summary, `semantic review unresolved decision ${index}.summary`, 8);
74
+ if (typeof decision.blocking !== 'boolean') {
75
+ throw new Error(`semantic review unresolved decision ${index}.blocking must be boolean`);
76
+ }
77
+ }
78
+
79
+ function validateGraphCoverage(surface, index) {
80
+ if (!exactKeys(surface, ['surface', 'covered', 'notes'])) {
81
+ throw new Error(`semantic review graph coverage ${index} fields must be exact`);
82
+ }
83
+ if (![
84
+ 'criterion_local', 'cross_criterion', 'runtime_path',
85
+ 'assumption_provenance', 'compatibility_migration',
86
+ ].includes(surface.surface)) {
87
+ throw new Error(`semantic review graph coverage ${index}.surface is invalid`);
88
+ }
89
+ if (typeof surface.covered !== 'boolean') {
90
+ throw new Error(`semantic review graph coverage ${index}.covered must be boolean`);
91
+ }
92
+ requireString(surface.notes, `semantic review graph coverage ${index}.notes`);
93
+ }
94
+
95
+ function validateCounterexample(counterexample, index) {
96
+ if (!exactKeys(counterexample, [
97
+ 'criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref',
98
+ ])) {
99
+ throw new Error(`semantic review counterexample ${index} fields must be exact`);
100
+ }
101
+ for (const field of ['criterion', 'case_kind', 'scenario', 'expected', 'verification_ref']) {
102
+ requireString(counterexample[field], `semantic review counterexample ${index}.${field}`);
103
+ }
104
+ if (!Array.isArray(counterexample.decision_refs)
105
+ || counterexample.decision_refs.some((value) => typeof value !== 'string')) {
106
+ throw new Error(`semantic review counterexample ${index}.decision_refs must be string array`);
107
+ }
108
+ }
109
+
110
+ function validateReviewerEvidence(evidence) {
111
+ if (evidence === null) return;
112
+ if (!exactKeys(evidence, ['assurance', 'summary'])) {
113
+ throw new Error('semantic review reviewer_evidence fields must be exact');
114
+ }
115
+ if (!['Routine', 'Elevated', 'Strict'].includes(evidence.assurance)) {
116
+ throw new Error('semantic review reviewer_evidence.assurance is invalid');
117
+ }
118
+ requireString(evidence.summary, 'semantic review reviewer_evidence.summary', 8);
119
+ }
120
+
19
121
  function readReviewResult(input) {
20
- if (!exactKeys(input, ['reviewed_criteria', 'counterexamples'])) {
21
- throw new Error('semantic review result fields must be exactly reviewed_criteria and counterexamples');
122
+ if (!exactKeys(input, REVIEW_RESULT_FIELDS)) {
123
+ throw new Error(`semantic review result fields must be exactly ${REVIEW_RESULT_FIELDS.join(', ')}`);
124
+ }
125
+ if (input.verdict !== 'PASS' && input.verdict !== 'FAIL') {
126
+ throw new Error('semantic review result verdict must be PASS or FAIL');
127
+ }
128
+ if (!Array.isArray(input.findings)) throw new Error('semantic review result findings must be an array');
129
+ input.findings.forEach(validateReviewFinding);
130
+ if (!Array.isArray(input.unresolved_decisions)) {
131
+ throw new Error('semantic review result unresolved_decisions must be an array');
22
132
  }
133
+ input.unresolved_decisions.forEach(validateReviewDecision);
134
+ if (!Array.isArray(input.graph_coverage)) {
135
+ throw new Error('semantic review result graph_coverage must be an array');
136
+ }
137
+ input.graph_coverage.forEach(validateGraphCoverage);
138
+ if (!Array.isArray(input.reviewed_criteria)
139
+ || input.reviewed_criteria.some((value) => typeof value !== 'string' || !/^R\d+\.\d+$/.test(value))
140
+ || new Set(input.reviewed_criteria).size !== input.reviewed_criteria.length) {
141
+ throw new Error('semantic review result reviewed_criteria must be unique RN.M strings');
142
+ }
143
+ if (!Array.isArray(input.counterexamples)) {
144
+ throw new Error('semantic review result counterexamples must be an array');
145
+ }
146
+ input.counterexamples.forEach(validateCounterexample);
147
+ validateReviewerEvidence(input.reviewer_evidence);
23
148
  return JSON.parse(JSON.stringify(input));
24
149
  }
25
150
 
151
+ function blockingCount(review) {
152
+ return review.findings.filter((finding) => finding.blocking).length
153
+ + review.unresolved_decisions.filter((decision) => decision.blocking).length;
154
+ }
155
+
156
+ function buildCompletedReceipt(review, semanticDigest, recordedEntry) {
157
+ return {
158
+ status: 'completed',
159
+ semantic_digest: semanticDigest,
160
+ verdict: review.verdict,
161
+ lifecycle_disposition: recordedEntry.lifecycle_disposition,
162
+ findings: JSON.parse(JSON.stringify(review.findings)),
163
+ unresolved_decisions: JSON.parse(JSON.stringify(review.unresolved_decisions)),
164
+ graph_coverage: JSON.parse(JSON.stringify(review.graph_coverage)),
165
+ repair_round: recordedEntry.attempt_index,
166
+ reviewed_criteria: JSON.parse(JSON.stringify(review.reviewed_criteria)),
167
+ counterexamples: JSON.parse(JSON.stringify(review.counterexamples)),
168
+ reviewer_evidence: JSON.parse(JSON.stringify(review.reviewer_evidence)),
169
+ };
170
+ }
171
+
172
+ function validateHistory(history) {
173
+ if (!plain(history) || !exactKeys(history, HISTORY_FIELDS)) {
174
+ throw new Error('spec.validation.semantic_review_history must contain exactly lineage_id and entries');
175
+ }
176
+ if (typeof history.lineage_id !== 'string' || !SHA256_RE.test(history.lineage_id)) {
177
+ throw new Error('spec.validation.semantic_review_history.lineage_id must be a sha256 digest');
178
+ }
179
+ if (!Array.isArray(history.entries)) {
180
+ throw new Error('spec.validation.semantic_review_history.entries must be an array');
181
+ }
182
+
183
+ const receipts = new Set();
184
+ let priorPasses = 0;
185
+ let failuresSincePass = 0;
186
+ let blocked = false;
187
+ for (const [index, entry] of history.entries.entries()) {
188
+ if (!plain(entry) || !exactKeys(entry, HISTORY_ENTRY_FIELDS)) {
189
+ throw new Error(`semantic review history entry ${index} fields diverge from the canonical entry`);
190
+ }
191
+ if (entry.sequence !== index) throw new Error(`semantic review history entry ${index} has a divergent sequence`);
192
+ if (typeof entry.semantic_digest !== 'string' || !SHA256_RE.test(entry.semantic_digest)) {
193
+ throw new Error(`semantic review history entry ${index} has an invalid semantic digest`);
194
+ }
195
+ if (typeof entry.review_receipt_digest !== 'string' || !SHA256_RE.test(entry.review_receipt_digest)) {
196
+ throw new Error(`semantic review history entry ${index} has an invalid review receipt digest`);
197
+ }
198
+ if (receipts.has(entry.review_receipt_digest)) {
199
+ throw new Error(`semantic review history entry ${index} duplicates a review receipt digest`);
200
+ }
201
+ receipts.add(entry.review_receipt_digest);
202
+ if (entry.verdict !== 'PASS' && entry.verdict !== 'FAIL') {
203
+ throw new Error(`semantic review history entry ${index} has an invalid verdict`);
204
+ }
205
+ if (entry.review_epoch !== priorPasses) {
206
+ throw new Error(`semantic review history entry ${index} has a divergent review_epoch`);
207
+ }
208
+ if (entry.attempt_index !== failuresSincePass) {
209
+ throw new Error(`semantic review history entry ${index} has a divergent attempt_index`);
210
+ }
211
+ if (entry.attempt_index > 2) {
212
+ throw new Error(`semantic review history entry ${index} exceeds the terminal attempt limit`);
213
+ }
214
+ if (!Number.isInteger(entry.blocking_count) || entry.blocking_count < 0) {
215
+ throw new Error(`semantic review history entry ${index} has an invalid blocking_count`);
216
+ }
217
+ const expectedDisposition = entry.verdict === 'PASS' || entry.attempt_index < 2
218
+ ? 'CONTINUE' : 'BLOCKED';
219
+ if (entry.lifecycle_disposition !== expectedDisposition) {
220
+ throw new Error(`semantic review history entry ${index} has a divergent lifecycle_disposition`);
221
+ }
222
+ if (blocked) throw new Error('semantic review history contains an entry after BLOCKED');
223
+ if (entry.verdict === 'PASS') {
224
+ if (entry.blocking_count !== 0) throw new Error(`semantic review history entry ${index} PASS has blockers`);
225
+ priorPasses += 1;
226
+ failuresSincePass = 0;
227
+ } else {
228
+ failuresSincePass += 1;
229
+ blocked = entry.lifecycle_disposition === 'BLOCKED';
230
+ }
231
+ }
232
+ return { history: JSON.parse(JSON.stringify(history)), receipts, priorPasses, failuresSincePass, blocked };
233
+ }
234
+
235
+ function deriveLineageId(spec, semantic) {
236
+ if (!plain(spec) || typeof spec.feature_name !== 'string' || spec.feature_name.length === 0) {
237
+ throw new Error('spec.feature_name is required for semantic review lineage');
238
+ }
239
+ if (typeof spec.created_at !== 'string' || spec.created_at.length === 0) {
240
+ throw new Error('spec.created_at is required for semantic review lineage');
241
+ }
242
+ return stableDigest({ feature_name: spec.feature_name, created_at: spec.created_at }, semantic);
243
+ }
244
+
245
+ function currentHistory(spec, semantic) {
246
+ const lineageId = deriveLineageId(spec, semantic);
247
+ const supplied = spec.validation?.semantic_review_history;
248
+ if (supplied === undefined || supplied === null) {
249
+ return {
250
+ history: { lineage_id: lineageId, entries: [] },
251
+ receipts: new Set(), priorPasses: 0, failuresSincePass: 0, blocked: false,
252
+ };
253
+ }
254
+ const state = validateHistory(supplied);
255
+ if (state.history.lineage_id !== lineageId) {
256
+ throw new Error('spec.validation.semantic_review_history lineage_id diverges from spec identity');
257
+ }
258
+ return state;
259
+ }
260
+
261
+ function canonicalCheck({ directory, root, candidate, validator, grounder }) {
262
+ const validation = validator.validateSpec(directory, candidate);
263
+ if (validation.errors.length) throw new Error(`readiness validation failed (${validation.errors.join('; ')})`);
264
+ const grounding = grounder({ specDir: directory, root, spec: candidate });
265
+ if (grounding.errors.length) throw new Error(`readiness grounding failed (${grounding.errors.join('; ')})`);
266
+ }
267
+
26
268
  function atomicReplace(file, bytes, io = fs) {
27
269
  const stat = io.lstatSync(file);
28
270
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('spec.json must be a regular non-symlink file');
@@ -51,7 +293,16 @@ function atomicReplace(file, bytes, io = fs) {
51
293
  }
52
294
  }
53
295
 
54
- function finalizeReadiness({ specDir, reviewResult, projectRoot, io = fs }) {
296
+ function finalizeReadiness({
297
+ specDir,
298
+ reviewResult,
299
+ projectRoot,
300
+ io = fs,
301
+ validator = VALIDATOR,
302
+ grounder = groundSpec,
303
+ semantic = SEMANTIC,
304
+ now = new Date().toISOString(),
305
+ }) {
55
306
  const directory = io.realpathSync(path.resolve(specDir));
56
307
  const root = projectRoot ? io.realpathSync(path.resolve(projectRoot)) : canonicalProjectRoot(directory);
57
308
  const relative = path.relative(root, directory);
@@ -64,32 +315,88 @@ function finalizeReadiness({ specDir, reviewResult, projectRoot, io = fs }) {
64
315
  try { spec = JSON.parse(original); } catch (error) { throw new Error(`spec.json is invalid JSON (${error.message})`); }
65
316
  if (!plain(spec) || spec.schema_version !== '2.1') throw new Error('readiness finalization accepts canonical schema 2.1 only');
66
317
  const review = readReviewResult(reviewResult);
67
- const projection = SEMANTIC.modelFromMarkdown(directory, spec);
318
+ const blockerCount = blockingCount(review);
319
+ if (review.verdict === 'PASS' && blockerCount > 0) {
320
+ throw new Error('semantic review PASS cannot contain blocking findings or unresolved decisions');
321
+ }
322
+ const state = currentHistory(spec, semantic);
323
+ const projection = semantic.modelFromMarkdown(directory, spec);
68
324
  if (projection.errors.length) throw new Error(`semantic projection failed (${projection.errors.join('; ')})`);
325
+
69
326
  const candidate = JSON.parse(JSON.stringify(spec));
70
327
  candidate.semantic_model = projection.model;
328
+ if (!plain(candidate.validation)) throw new Error('spec.validation must be an object');
329
+ const authoringValidation = candidate.validation.authoring_validation;
71
330
  candidate.validation = {
331
+ ...candidate.validation,
72
332
  status: 'completed',
73
- semantic_review: {
74
- status: 'not-run', semantic_digest: null, reviewed_criteria: [], counterexamples: [],
333
+ semantic_review_history: {
334
+ lineage_id: state.history.lineage_id,
335
+ entries: [...state.history.entries],
75
336
  },
76
337
  };
338
+ if (JSON.stringify(candidate.validation.authoring_validation) !== JSON.stringify(authoringValidation)) {
339
+ throw new Error('spec.validation.authoring_validation must be preserved');
340
+ }
341
+
77
342
  candidate.ready_for_implementation = false;
78
- const digestResult = VALIDATOR.computeSemanticDigest21(directory, candidate);
343
+ const digestResult = validator.computeSemanticDigest21(directory, candidate);
79
344
  if (digestResult.errors.length) throw new Error(`semantic digest failed (${digestResult.errors.join('; ')})`);
80
- candidate.validation.semantic_review = {
81
- status: 'completed', semantic_digest: digestResult.digest,
82
- reviewed_criteria: review.reviewed_criteria, counterexamples: review.counterexamples,
345
+ for (const entry of state.history.entries) {
346
+ const hypotheticalReceipt = buildCompletedReceipt(review, digestResult.digest, entry);
347
+ const hypotheticalDigest = stableDigest(hypotheticalReceipt, semantic);
348
+ if (entry.review_receipt_digest === hypotheticalDigest) {
349
+ return {
350
+ spec,
351
+ semantic_digest: entry.semantic_digest,
352
+ replayed: true,
353
+ history_entry: entry,
354
+ };
355
+ }
356
+ }
357
+ if (state.blocked) throw new Error('readiness finalization refused: current review epoch already ends BLOCKED');
358
+ const reviewEpoch = state.priorPasses;
359
+ const attemptIndex = state.failuresSincePass;
360
+ if (attemptIndex > 2) throw new Error('readiness finalization refused: no next attempt exists');
361
+ const lifecycleDisposition = review.verdict === 'PASS' || attemptIndex < 2 ? 'CONTINUE' : 'BLOCKED';
362
+ const receipt = buildCompletedReceipt(review, digestResult.digest, {
363
+ attempt_index: attemptIndex,
364
+ lifecycle_disposition: lifecycleDisposition,
365
+ });
366
+ if (!exactKeys(receipt, C2_RECEIPT_FIELDS)) {
367
+ throw new Error('completed semantic review receipt fields diverge from C2');
368
+ }
369
+ candidate.validation.semantic_review = receipt;
370
+ const reviewReceiptDigest = stableDigest(receipt, semantic);
371
+ const historyEntry = {
372
+ sequence: state.history.entries.length,
373
+ semantic_digest: digestResult.digest,
374
+ review_receipt_digest: reviewReceiptDigest,
375
+ verdict: review.verdict,
376
+ lifecycle_disposition: lifecycleDisposition,
377
+ blocking_count: blockerCount,
378
+ attempt_index: attemptIndex,
379
+ review_epoch: reviewEpoch,
83
380
  };
84
- candidate.ready_for_implementation = true;
85
- candidate.updated_at = new Date().toISOString();
86
- const validation = VALIDATOR.validateSpec(directory, candidate);
87
- if (validation.errors.length) throw new Error(`readiness validation failed (${validation.errors.join('; ')})`);
88
- const grounding = groundSpec({ specDir: directory, root, spec: candidate });
89
- if (grounding.errors.length) throw new Error(`readiness grounding failed (${grounding.errors.join('; ')})`);
381
+ candidate.validation.semantic_review_history.entries.push(historyEntry);
382
+ candidate.updated_at = now;
383
+ canonicalCheck({ directory, root, candidate, validator, grounder });
384
+
385
+ const ready = review.verdict === 'PASS'
386
+ && lifecycleDisposition === 'CONTINUE'
387
+ && blockerCount === 0
388
+ && candidate.validation.semantic_review.semantic_digest === digestResult.digest;
389
+ candidate.ready_for_implementation = ready;
390
+ if (ready) {
391
+ if (plain(candidate.timestamps)) {
392
+ candidate.timestamps = { ...candidate.timestamps, validation_done: now };
393
+ }
394
+ canonicalCheck({ directory, root, candidate, validator, grounder });
395
+ }
396
+
90
397
  const bytes = Buffer.from(`${JSON.stringify(candidate, null, 2)}\n`);
91
398
  atomicReplace(specFile, bytes, io);
92
- return { spec: candidate, semantic_digest: digestResult.digest };
399
+ return { spec: candidate, semantic_digest: digestResult.digest, replayed: false, history_entry: historyEntry };
93
400
  }
94
401
 
95
402
  function usage() {