@haposoft/cafekit 0.16.0-rc.3 → 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 (98) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +531 -29
  3. package/bin/__tests__/codex-native.test.js +228 -7
  4. package/bin/__tests__/develop-contract.test.js +653 -185
  5. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  6. package/bin/__tests__/installer-safety.test.js +39 -0
  7. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  8. package/bin/__tests__/package-inventory.test.js +705 -13
  9. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  10. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  11. package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
  12. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
  13. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
  14. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  15. package/bin/__tests__/validator-grounding.test.js +2013 -221
  16. package/bin/lib/managed-writer.js +12 -3
  17. package/bin/phases/codex-runtime.js +17 -1
  18. package/package.json +5 -2
  19. package/src/claude/CLAUDE.md +6 -0
  20. package/src/claude/agents/code-auditor.md +20 -0
  21. package/src/claude/agents/spec-maker.md +143 -99
  22. package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
  23. package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
  24. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  25. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  26. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  27. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  28. package/src/claude/hooks/completion-authority.cjs +13 -7
  29. package/src/claude/hooks/docs-sync.cjs +5 -2
  30. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  31. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  32. package/src/claude/hooks/privacy-block.cjs +99 -28
  33. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  34. package/src/claude/hooks/spec-gate.cjs +47 -117
  35. package/src/claude/hooks/spec-state.cjs +8 -2
  36. package/src/claude/migration-manifest.json +9 -1
  37. package/src/claude/rules/state-sync.md +52 -21
  38. package/src/claude/rules/workflow.md +17 -9
  39. package/src/claude/scripts/change-firewall.cjs +1748 -0
  40. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  41. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  42. package/src/claude/scripts/spec-final-state.cjs +290 -0
  43. package/src/claude/scripts/spec-ground.cjs +571 -173
  44. package/src/claude/scripts/spec-readiness.cjs +425 -0
  45. package/src/claude/scripts/spec-receipt.cjs +162 -0
  46. package/src/claude/scripts/spec-resolver.cjs +67 -16
  47. package/src/claude/scripts/spec-scaffold.cjs +1594 -139
  48. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  49. package/src/claude/scripts/validate-spec-output.cjs +3110 -218
  50. package/src/claude/scripts/workflow-policy.cjs +501 -183
  51. package/src/claude/settings/settings.json +4 -0
  52. package/src/claude/skills/code-review/SKILL.md +12 -3
  53. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  54. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  55. package/src/claude/skills/develop/SKILL.md +47 -60
  56. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  57. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  58. package/src/claude/skills/specs/SKILL.md +183 -177
  59. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  60. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  61. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  62. package/src/claude/skills/specs/references/review.md +214 -262
  63. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  64. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  65. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  66. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  67. package/src/claude/skills/specs/rules/design-review.md +42 -97
  68. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  69. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  70. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  71. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  72. package/src/claude/skills/specs/templates/design.md +49 -263
  73. package/src/claude/skills/specs/templates/requirements.md +26 -36
  74. package/src/claude/skills/specs/templates/research.md +9 -98
  75. package/src/claude/skills/specs/templates/spec-state.json +35 -85
  76. package/src/claude/skills/specs/templates/task.md +30 -102
  77. package/src/claude/skills/sync/SKILL.md +33 -10
  78. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  79. package/src/claude/skills/test/SKILL.md +17 -7
  80. package/src/codex/AGENTS.md +6 -0
  81. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  82. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  83. package/src/codex/hooks/completion-authority.cjs +13 -7
  84. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  85. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  86. package/src/codex/hooks/lib/privacy-state.cjs +61 -38
  87. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  88. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  89. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  90. package/src/codex/hooks/privacy-approval.cjs +6 -2
  91. package/src/codex/hooks/privacy-block.cjs +94 -15
  92. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  93. package/src/codex/hooks/spec-gate.cjs +49 -34
  94. package/src/codex/hooks/spec-state.cjs +10 -3
  95. package/src/codex/hooks/state.cjs +2 -0
  96. package/src/codex/hooks.json +6 -1
  97. package/src/codex/rules/state-sync.md +47 -27
  98. package/src/common/AGENTS.md +1 -1
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const SEMANTIC = require('./spec-semantic-model.cjs');
8
+ const VALIDATOR = require('./validate-spec-output.cjs');
9
+ const { canonicalProjectRoot, groundSpec } = require('./spec-ground.cjs');
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
+
27
+ function plain(value) {
28
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
29
+ }
30
+
31
+ function exactKeys(value, fields) {
32
+ return plain(value)
33
+ && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort());
34
+ }
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
+
121
+ function readReviewResult(input) {
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');
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);
148
+ return JSON.parse(JSON.stringify(input));
149
+ }
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
+
268
+ function atomicReplace(file, bytes, io = fs) {
269
+ const stat = io.lstatSync(file);
270
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('spec.json must be a regular non-symlink file');
271
+ const original = io.readFileSync(file);
272
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.finalize-${process.pid}-${Date.now()}`);
273
+ let descriptor;
274
+ try {
275
+ descriptor = io.openSync(temporary, 'wx', stat.mode);
276
+ io.writeFileSync(descriptor, bytes);
277
+ io.fsyncSync(descriptor);
278
+ io.closeSync(descriptor);
279
+ descriptor = undefined;
280
+ if (process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME === '1') throw new Error('injected finalization failure before rename');
281
+ io.renameSync(temporary, file);
282
+ } catch (error) {
283
+ if (descriptor !== undefined) try { io.closeSync(descriptor); } catch {}
284
+ try { io.unlinkSync(temporary); } catch {}
285
+ if (!io.readFileSync(file).equals(original)) {
286
+ const rollback = `${temporary}-rollback`;
287
+ try {
288
+ io.writeFileSync(rollback, original, { flag: 'wx', mode: stat.mode });
289
+ io.renameSync(rollback, file);
290
+ } finally { try { io.unlinkSync(rollback); } catch {} }
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+
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
+ }) {
306
+ const directory = io.realpathSync(path.resolve(specDir));
307
+ const root = projectRoot ? io.realpathSync(path.resolve(projectRoot)) : canonicalProjectRoot(directory);
308
+ const relative = path.relative(root, directory);
309
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
310
+ throw new Error('spec directory escapes the canonical project root');
311
+ }
312
+ const specFile = path.join(directory, 'spec.json');
313
+ const original = io.readFileSync(specFile);
314
+ let spec;
315
+ try { spec = JSON.parse(original); } catch (error) { throw new Error(`spec.json is invalid JSON (${error.message})`); }
316
+ if (!plain(spec) || spec.schema_version !== '2.1') throw new Error('readiness finalization accepts canonical schema 2.1 only');
317
+ const review = readReviewResult(reviewResult);
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);
324
+ if (projection.errors.length) throw new Error(`semantic projection failed (${projection.errors.join('; ')})`);
325
+
326
+ const candidate = JSON.parse(JSON.stringify(spec));
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;
330
+ candidate.validation = {
331
+ ...candidate.validation,
332
+ status: 'completed',
333
+ semantic_review_history: {
334
+ lineage_id: state.history.lineage_id,
335
+ entries: [...state.history.entries],
336
+ },
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
+
342
+ candidate.ready_for_implementation = false;
343
+ const digestResult = validator.computeSemanticDigest21(directory, candidate);
344
+ if (digestResult.errors.length) throw new Error(`semantic digest failed (${digestResult.errors.join('; ')})`);
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,
380
+ };
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
+
397
+ const bytes = Buffer.from(`${JSON.stringify(candidate, null, 2)}\n`);
398
+ atomicReplace(specFile, bytes, io);
399
+ return { spec: candidate, semantic_digest: digestResult.digest, replayed: false, history_entry: historyEntry };
400
+ }
401
+
402
+ function usage() {
403
+ process.stderr.write('Usage: node .claude/scripts/spec-readiness.cjs specs/<feature> --review-result <review.json>\n');
404
+ }
405
+
406
+ function main() {
407
+ const args = process.argv.slice(2);
408
+ const marker = args.indexOf('--review-result');
409
+ if (args.length !== 3 || marker !== 1) { usage(); process.exitCode = 2; return; }
410
+ try {
411
+ const reviewFile = path.resolve(args[2]);
412
+ const reviewStat = fs.lstatSync(reviewFile);
413
+ if (!reviewStat.isFile() || reviewStat.isSymbolicLink()) throw new Error('review result must be a regular non-symlink JSON file');
414
+ const reviewResult = JSON.parse(fs.readFileSync(reviewFile, 'utf8'));
415
+ const result = finalizeReadiness({ specDir: args[0], reviewResult });
416
+ process.stdout.write(`SPEC_READY ${args[0]} ${result.semantic_digest}\n`);
417
+ } catch (error) {
418
+ process.stderr.write(`SPEC_NOT_READY ${error.message}\n`);
419
+ process.exitCode = 1;
420
+ }
421
+ }
422
+
423
+ module.exports = { atomicReplace, finalizeReadiness, readReviewResult };
424
+
425
+ if (require.main === module) main();
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const EVIDENCE_HEADINGS = new Set([
8
+ 'Evidence',
9
+ 'Task Test Plan & Verification Evidence',
10
+ 'Verification & Evidence',
11
+ ]);
12
+
13
+ function inside(root, target) {
14
+ const relative = path.relative(path.resolve(root), path.resolve(target));
15
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
16
+ }
17
+
18
+ function safeRead(root, relativePath) {
19
+ if (typeof relativePath !== 'string' || path.isAbsolute(relativePath)) return { status: 'unsafe' };
20
+ const segments = relativePath.split(/[\\/]+/);
21
+ if (segments.length === 0 || segments.some((segment) => !segment || segment === '.' || segment === '..')) return { status: 'unsafe' };
22
+ const resolvedRoot = path.resolve(root);
23
+ const target = path.resolve(resolvedRoot, ...segments);
24
+ if (!inside(resolvedRoot, target)) return { status: 'unsafe' };
25
+ try {
26
+ const canonicalRoot = fs.realpathSync(resolvedRoot);
27
+ let current = resolvedRoot;
28
+ for (const segment of segments) {
29
+ current = path.join(current, segment);
30
+ const stat = fs.lstatSync(current);
31
+ if (stat.isSymbolicLink()) return { status: 'unsafe' };
32
+ }
33
+ const canonicalTarget = fs.realpathSync(target);
34
+ if (!inside(canonicalRoot, canonicalTarget) || !fs.statSync(target).isFile()) return { status: 'unsafe' };
35
+ return { status: 'ok', path: canonicalTarget, bytes: fs.readFileSync(target) };
36
+ } catch (error) {
37
+ return { status: error && error.code === 'ENOENT' ? 'missing' : 'unsafe' };
38
+ }
39
+ }
40
+
41
+ function canonicalTaskReceiptPath(taskPath) {
42
+ if (typeof taskPath !== 'string') return null;
43
+ const match = taskPath.match(/^tasks\/(task-[A-Za-z0-9][A-Za-z0-9._-]*\.md)$/);
44
+ return match ? `receipts/${match[1]}` : null;
45
+ }
46
+
47
+ function evidenceBody(text, policy = {}) {
48
+ const lines = String(text || '').split('\n');
49
+ let start = -1;
50
+ let level = 0;
51
+ for (let index = 0; index < lines.length; index += 1) {
52
+ const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
53
+ if (match && EVIDENCE_HEADINGS.has(match[2])) {
54
+ start = index + 1;
55
+ level = match[1].length;
56
+ break;
57
+ }
58
+ }
59
+ if (start < 0) return null;
60
+ let end = lines.length;
61
+ for (let index = start; index < lines.length; index += 1) {
62
+ const heading = lines[index].match(/^(#{1,6})\s+/);
63
+ const tap = typeof policy.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(lines[index]);
64
+ if (heading && heading[1].length <= level && !tap) { end = index; break; }
65
+ }
66
+ return lines.slice(start, end).join('\n');
67
+ }
68
+
69
+ function fieldValues(body, name) {
70
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71
+ const matcher = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*?)\\s*$`, 'i');
72
+ return String(body || '').split('\n').map((line) => line.match(matcher)).filter(Boolean).map((match) => match[1]);
73
+ }
74
+
75
+ function concrete(value) {
76
+ return typeof value === 'string' && value.trim() !== '' && !/^(?:N\/A|NONE|PENDING|TBD|UNKNOWN|\{\{.*\}\})$/i.test(value.trim());
77
+ }
78
+
79
+ function plannedField(taskText, name) {
80
+ const plan = String(taskText || '').split(/^##\s+Verification Plan\s*$/m)[1];
81
+ if (!plan) return null;
82
+ const value = fieldValues(plan.split(/^##\s+/m)[0], `- **${name}**`)[0];
83
+ return value ? value.replace(/^`|`$/g, '').trim() : null;
84
+ }
85
+
86
+ function identity(body) {
87
+ const names = ['Verification', 'Command', 'Commands', 'Exit', 'Result', 'Base', 'Head', 'base_sha', 'head_sha'];
88
+ const fields = names.flatMap((name) => fieldValues(body, name).map((value) => [name.toLowerCase(), value.trim()]));
89
+ const artifacts = String(body || '').split('\n').filter((line) => /^\s*Artifacts?\s*:|^\s*Artifact\s+produced\b/i.test(line)).map((line) => line.trim());
90
+ return crypto.createHash('sha256').update(JSON.stringify({ fields, artifacts })).digest('hex');
91
+ }
92
+
93
+ function canonicalFailures(body, task, runtimeContext, policy) {
94
+ if (!policy || typeof policy.validateCanonicalReceipt !== 'function') return ['validator_unavailable'];
95
+ const options = typeof policy.receiptValidatorOptions === 'function'
96
+ ? policy.receiptValidatorOptions(task || {}, { runtimeContext, requireProvenanceBinding: true })
97
+ : { requireProvenanceBinding: true };
98
+ return policy.validateCanonicalReceipt(body, options);
99
+ }
100
+
101
+ function separateMetadataFailures(body, taskPath, taskText) {
102
+ const failures = [];
103
+ const basename = path.posix.basename(taskPath);
104
+ if (fieldValues(body, 'Task').length !== 1 || fieldValues(body, 'Task')[0] !== basename) failures.push('task_identity');
105
+ if (fieldValues(body, 'Task path').length !== 1 || fieldValues(body, 'Task path')[0] !== taskPath) failures.push('task_identity');
106
+ if (!concrete(fieldValues(body, 'Expected')[0]) || !concrete(fieldValues(body, 'Observed')[0])) failures.push('expected_observed');
107
+ const plannedCommand = plannedField(taskText, 'Command');
108
+ const actualCommand = fieldValues(body, 'Command')[0] || fieldValues(body, 'Commands')[0];
109
+ if (plannedCommand && !/^N\/A\b/i.test(plannedCommand) && actualCommand !== plannedCommand) failures.push('command_identity');
110
+ for (const name of ['Negative path', 'Reachability']) {
111
+ const planned = plannedField(taskText, name);
112
+ if (planned && !/^(?:N\/A|none|not relevant)\b/i.test(planned) && !concrete(fieldValues(body, name)[0])) failures.push(name.toLowerCase().replace(' ', '_'));
113
+ }
114
+ return [...new Set(failures)];
115
+ }
116
+
117
+ function readTaskProof(featureDir, taskPath, policy = {}) {
118
+ const receiptPath = canonicalTaskReceiptPath(taskPath);
119
+ if (typeof taskPath !== 'string' || !/^tasks\/[^/]+\.md$/.test(taskPath)) return { status: 'unsafe', taskPath, receiptPath: null };
120
+ const taskFile = safeRead(featureDir, taskPath);
121
+ if (taskFile.status !== 'ok') return { status: taskFile.status, taskPath, receiptPath };
122
+ const taskText = taskFile.bytes.toString('utf8');
123
+ const separate = receiptPath ? safeRead(featureDir, receiptPath) : { status: 'missing' };
124
+ const legacyBody = evidenceBody(taskText, policy);
125
+ if (separate.status === 'unsafe') return { status: 'unsafe', taskPath, receiptPath, taskFile };
126
+ if (separate.status === 'ok') {
127
+ const body = separate.bytes.toString('utf8');
128
+ return { status: 'ok', source: 'separate', body, receiptBytes: separate.bytes, receiptFile: separate, taskFile, taskText, taskPath, receiptPath, legacyBody };
129
+ }
130
+ if (legacyBody !== null) return { status: 'ok', source: 'legacy', body: legacyBody, receiptBytes: Buffer.from(legacyBody), taskFile, taskText, taskPath, receiptPath, legacyBody };
131
+ return { status: 'missing', taskPath, receiptPath, taskFile, taskText };
132
+ }
133
+
134
+ function checkTaskReceipt(featureDir, taskPath, task, runtimeContext, policy) {
135
+ const proof = readTaskProof(featureDir, taskPath, policy);
136
+ const failures = [];
137
+ if (proof.status !== 'ok') return { ...proof, failures: [proof.status === 'unsafe' ? 'unsafe_path' : 'missing_receipt'] };
138
+ const status = proof.taskText.split('\n').find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
139
+ if (!status || !/\bdone\b/i.test(status)) failures.push('task_status');
140
+ failures.push(...canonicalFailures(proof.body, task, runtimeContext, policy));
141
+ if (proof.source === 'separate') {
142
+ failures.push(...separateMetadataFailures(proof.body, taskPath, proof.taskText));
143
+ if (proof.legacyBody !== null
144
+ && /^\s*Verification:\s*PASS\s*$/m.test(proof.legacyBody)
145
+ && identity(proof.legacyBody) !== identity(proof.body)) failures.push('receipt_conflict');
146
+ }
147
+ const completedAt = task && task.completed_at;
148
+ if (typeof completedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(completedAt) || Number.isNaN(Date.parse(completedAt))) failures.push('completed_at');
149
+ return { ...proof, failures: [...new Set(failures)] };
150
+ }
151
+
152
+ function checkFeatureReceipt(featureDir, runtimeContext, policy) {
153
+ const receipt = safeRead(featureDir, 'feature-receipt.md');
154
+ if (receipt.status !== 'ok') return { status: receipt.status, failures: [receipt.status === 'unsafe' ? 'unsafe_path' : 'missing_receipt'] };
155
+ const body = receipt.bytes.toString('utf8');
156
+ const failures = canonicalFailures(body, {}, runtimeContext, policy);
157
+ if (fieldValues(body, 'Feature').length !== 1 || fieldValues(body, 'Feature')[0] !== path.basename(featureDir)) failures.push('feature_identity');
158
+ if (!concrete(fieldValues(body, 'Expected')[0]) || !concrete(fieldValues(body, 'Observed')[0])) failures.push('expected_observed');
159
+ return { status: 'ok', source: 'feature', body, receiptBytes: receipt.bytes, receiptFile: receipt, failures: [...new Set(failures)] };
160
+ }
161
+
162
+ module.exports = { canonicalTaskReceiptPath, checkFeatureReceipt, checkTaskReceipt, evidenceBody, readTaskProof, safeRead };