@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
@@ -11,6 +11,7 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const POLICY = require('./workflow-policy.cjs');
13
13
  const SEMANTIC = require('./spec-semantic-model.cjs');
14
+ const AUTHORING_DIGEST = require('./spec-authoring-digest.cjs');
14
15
  const {
15
16
  canonicalProjectRoot,
16
17
  groundSpec,
@@ -74,6 +75,41 @@ const SPEC_21_REQUIRED_FIELDS = Object.freeze([
74
75
  ]);
75
76
  const SPEC_21_OPTIONAL_FIELDS = Object.freeze(['research', 'task_files', 'task_registry', 'decisions']);
76
77
 
78
+ // C2 SemanticReviewReceipt (R1-01, frozen) — separate reviewer verdict from
79
+ // lifecycle_disposition; repair_round is the finalizer-derived C13 attempt_index.
80
+ // This is the sole canonical field-list authority for C2's exact key set;
81
+ // every other consumer (e.g. run-skill-self-tests.mjs's template contract
82
+ // check) imports this constant rather than maintaining its own literal copy.
83
+ const C2_FIELDS = Object.freeze([
84
+ 'status', 'semantic_digest', 'verdict', 'lifecycle_disposition', 'findings',
85
+ 'unresolved_decisions', 'graph_coverage', 'repair_round', 'reviewed_criteria',
86
+ 'counterexamples', 'reviewer_evidence',
87
+ ]);
88
+ const C2_VERDICTS = new Set(['PASS', 'FAIL']);
89
+ const C2_LIFECYCLE_DISPOSITIONS = new Set(['CONTINUE', 'BLOCKED']);
90
+ const C2_FINDING_FIELDS = ['id', 'severity', 'location', 'summary', 'blocking', 'disposition'];
91
+ const C2_FINDING_SEVERITIES = new Set(['Critical', 'High', 'Medium', 'Low']);
92
+ const C2_FINDING_DISPOSITIONS = new Set(['open', 'accepted', 'mitigated']);
93
+ const C2_UNRESOLVED_DECISION_FIELDS = ['id', 'summary', 'blocking'];
94
+ const C2_GRAPH_COVERAGE_FIELDS = ['surface', 'covered', 'notes'];
95
+ const C2_GRAPH_COVERAGE_SURFACES = [
96
+ 'criterion_local', 'cross_criterion', 'runtime_path', 'assumption_provenance', 'compatibility_migration',
97
+ ];
98
+ const C2_REVIEWER_EVIDENCE_FIELDS = ['assurance', 'summary'];
99
+ const C2_REVIEWER_EVIDENCE_ASSURANCES = new Set(['Routine', 'Elevated', 'Strict']);
100
+ // C13 SemanticReviewHistory (R1-01, frozen) — durable, append-only, whole-lineage-deduped.
101
+ const C13_HISTORY_FIELDS = ['lineage_id', 'entries'];
102
+ const C13_ENTRY_FIELDS = [
103
+ 'sequence', 'semantic_digest', 'review_receipt_digest', 'verdict',
104
+ 'lifecycle_disposition', 'blocking_count', 'attempt_index', 'review_epoch',
105
+ ];
106
+ // C16 AuthoringValidationReceipt (R1-01, frozen) — sole freshness evidence for
107
+ // an authoring stage's `validated` reading (I20); computed via the shared,
108
+ // R0-01-owned spec-authoring-digest.cjs primitive (never reimplemented here).
109
+ const C16_FIELDS = ['schema_version', 'requirements', 'design', 'research', 'tasks'];
110
+ const C16_STAGE_FIELDS = ['digest', 'validated_at'];
111
+ const VALIDATION_21_FIELDS = ['status', 'semantic_review', 'semantic_review_history', 'authoring_validation'];
112
+
77
113
  function usage() {
78
114
  console.error('Usage: node .claude/scripts/validate-spec-output.cjs specs/<feature> [--semantic-digest]');
79
115
  }
@@ -2551,7 +2587,7 @@ function hasConcreteTaskProse(section) {
2551
2587
  ));
2552
2588
  }
2553
2589
 
2554
- function validateTask21(taskPath, content, entry, errors) {
2590
+ function validateTask21(taskPath, content, entry, specReady, errors) {
2555
2591
  for (const section of TASK_21_SECTIONS) {
2556
2592
  if (headingCount(content, section) !== 1) errors.push(`${taskPath}: requires exactly one ## ${section} section`);
2557
2593
  }
@@ -2561,7 +2597,7 @@ function validateTask21(taskPath, content, entry, errors) {
2561
2597
  }
2562
2598
  validateTaskIdentity(taskPath, content, entry, errors);
2563
2599
  validateTaskDependencyHeader(taskPath, content, entry, true, errors);
2564
- validateTaskStatusHeader(taskPath, content, entry, true, errors);
2600
+ validateTaskStatusHeader(taskPath, content, entry, specReady, errors);
2565
2601
  validateVerificationPlan(taskPath, content, errors);
2566
2602
  validateTaskPlaceholders(taskPath, content, errors, []);
2567
2603
  const outcome = extractSection(content, 'Outcome') || '';
@@ -2702,17 +2738,94 @@ function computeSemanticDigest21(specDir, spec) {
2702
2738
  return { errors, digest: semanticDigest21(specDir, digestSpec, taskFiles) };
2703
2739
  }
2704
2740
 
2741
+ function validateSemanticReviewFindings(review, errors) {
2742
+ if (!Array.isArray(review.findings)) {
2743
+ errors.push('spec.json.validation.semantic_review.findings: must be an array');
2744
+ return;
2745
+ }
2746
+ const findingIds = new Set();
2747
+ for (const [index, finding] of review.findings.entries()) {
2748
+ const label = `semantic_review.findings[${index}]`;
2749
+ if (!isPlainObject(finding) || Object.keys(finding).sort().join(',') !== [...C2_FINDING_FIELDS].sort().join(',')) {
2750
+ errors.push(`${label}: fields must be exactly ${C2_FINDING_FIELDS.join(', ')}`); continue;
2751
+ }
2752
+ if (typeof finding.id !== 'string' || finding.id === '' || findingIds.has(finding.id)) errors.push(`${label}.id: must be a unique non-empty string`);
2753
+ findingIds.add(finding.id);
2754
+ if (!C2_FINDING_SEVERITIES.has(finding.severity)) errors.push(`${label}.severity: must be Critical, High, Medium, or Low`);
2755
+ if (typeof finding.location !== 'string' || finding.location.trim() === '') errors.push(`${label}.location: must be a non-empty string`);
2756
+ if (!concreteSemanticReviewText(finding.summary)) errors.push(`${label}.summary: must be concrete with minimum length 8`);
2757
+ if (typeof finding.blocking !== 'boolean') errors.push(`${label}.blocking: must be boolean`);
2758
+ if (!C2_FINDING_DISPOSITIONS.has(finding.disposition)) errors.push(`${label}.disposition: must be open, accepted, or mitigated`);
2759
+ }
2760
+ }
2761
+
2762
+ function validateSemanticReviewUnresolvedDecisions(review, errors) {
2763
+ if (!Array.isArray(review.unresolved_decisions)) {
2764
+ errors.push('spec.json.validation.semantic_review.unresolved_decisions: must be an array');
2765
+ return;
2766
+ }
2767
+ const decisionIds = new Set();
2768
+ for (const [index, decision] of review.unresolved_decisions.entries()) {
2769
+ const label = `semantic_review.unresolved_decisions[${index}]`;
2770
+ if (!isPlainObject(decision) || Object.keys(decision).sort().join(',') !== [...C2_UNRESOLVED_DECISION_FIELDS].sort().join(',')) {
2771
+ errors.push(`${label}: fields must be exactly ${C2_UNRESOLVED_DECISION_FIELDS.join(', ')}`); continue;
2772
+ }
2773
+ if (typeof decision.id !== 'string' || decision.id === '' || decisionIds.has(decision.id)) errors.push(`${label}.id: must be a unique non-empty string`);
2774
+ decisionIds.add(decision.id);
2775
+ if (!concreteSemanticReviewText(decision.summary)) errors.push(`${label}.summary: must be concrete with minimum length 8`);
2776
+ if (typeof decision.blocking !== 'boolean') errors.push(`${label}.blocking: must be boolean`);
2777
+ }
2778
+ }
2779
+
2780
+ function validateSemanticReviewGraphCoverage(review, errors) {
2781
+ if (!Array.isArray(review.graph_coverage)) {
2782
+ errors.push('spec.json.validation.semantic_review.graph_coverage: must be an array');
2783
+ return;
2784
+ }
2785
+ const coveredSurfaces = new Set();
2786
+ for (const [index, coverage] of review.graph_coverage.entries()) {
2787
+ const label = `semantic_review.graph_coverage[${index}]`;
2788
+ if (!isPlainObject(coverage) || Object.keys(coverage).sort().join(',') !== [...C2_GRAPH_COVERAGE_FIELDS].sort().join(',')) {
2789
+ errors.push(`${label}: fields must be exactly ${C2_GRAPH_COVERAGE_FIELDS.join(', ')}`); continue;
2790
+ }
2791
+ if (!C2_GRAPH_COVERAGE_SURFACES.includes(coverage.surface)) errors.push(`${label}.surface: must be a canonical graph coverage surface`);
2792
+ else coveredSurfaces.add(coverage.surface);
2793
+ if (typeof coverage.covered !== 'boolean') errors.push(`${label}.covered: must be boolean`);
2794
+ if (typeof coverage.notes !== 'string') errors.push(`${label}.notes: must be a string`);
2795
+ }
2796
+ for (const surface of C2_GRAPH_COVERAGE_SURFACES) if (!coveredSurfaces.has(surface)) {
2797
+ errors.push(`spec.json.validation.semantic_review.graph_coverage: missing required surface ${surface}`);
2798
+ }
2799
+ }
2800
+
2801
+ function validateSemanticReviewerEvidence(review, errors) {
2802
+ if (review.reviewer_evidence === null) return;
2803
+ if (!isPlainObject(review.reviewer_evidence)
2804
+ || Object.keys(review.reviewer_evidence).sort().join(',') !== [...C2_REVIEWER_EVIDENCE_FIELDS].sort().join(',')) {
2805
+ errors.push(`spec.json.validation.semantic_review.reviewer_evidence: fields must be exactly ${C2_REVIEWER_EVIDENCE_FIELDS.join(', ')} or null`);
2806
+ return;
2807
+ }
2808
+ if (!C2_REVIEWER_EVIDENCE_ASSURANCES.has(review.reviewer_evidence.assurance)) {
2809
+ errors.push('spec.json.validation.semantic_review.reviewer_evidence.assurance: must be Routine, Elevated, or Strict');
2810
+ }
2811
+ if (!concreteSemanticReviewText(review.reviewer_evidence.summary)) {
2812
+ errors.push('spec.json.validation.semantic_review.reviewer_evidence.summary: must be concrete with minimum length 8');
2813
+ }
2814
+ }
2815
+
2705
2816
  function validateSemanticReview21(specDir, spec, criteria, designIds, verificationDefs, errors) {
2706
2817
  const review = spec.validation?.semantic_review;
2707
- const fields = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples'];
2708
- if (!isPlainObject(review) || Object.keys(review).sort().join(',') !== fields.sort().join(',')) {
2709
- errors.push(`spec.json.validation.semantic_review: schema 2.1 fields must be exactly ${fields.join(', ')}`);
2818
+ if (!isPlainObject(review) || Object.keys(review).sort().join(',') !== [...C2_FIELDS].sort().join(',')) {
2819
+ errors.push(`spec.json.validation.semantic_review: schema 2.1 fields must be exactly ${C2_FIELDS.join(', ')}`);
2710
2820
  return;
2711
2821
  }
2712
2822
  if (!['not-run', 'completed'].includes(review.status)) errors.push('spec.json.validation.semantic_review.status: must be not-run or completed');
2713
2823
  if (review.status === 'not-run') {
2714
- if (review.semantic_digest !== null || review.reviewed_criteria?.length || review.counterexamples?.length) {
2715
- errors.push('spec.json.validation.semantic_review: not-run receipt must be empty');
2824
+ if (review.semantic_digest !== null || review.verdict !== null || review.lifecycle_disposition !== null
2825
+ || review.reviewer_evidence !== null || review.repair_round !== 0
2826
+ || review.findings?.length || review.unresolved_decisions?.length
2827
+ || review.graph_coverage?.length || review.reviewed_criteria?.length || review.counterexamples?.length) {
2828
+ errors.push('spec.json.validation.semantic_review: not-run receipt must keep verdict, lifecycle_disposition, and reviewer_evidence null, repair_round 0, and every array empty');
2716
2829
  }
2717
2830
  if (spec.ready_for_implementation === true) errors.push('spec.json.ready_for_implementation: requires completed semantic review');
2718
2831
  return;
@@ -2723,6 +2836,18 @@ function validateSemanticReview21(specDir, spec, criteria, designIds, verificati
2723
2836
  } else if (review.semantic_digest !== computed.digest) {
2724
2837
  errors.push('spec.json.validation.semantic_review.semantic_digest: stale for current authoring, policy, topology, or research');
2725
2838
  }
2839
+ if (typeof review.semantic_digest !== 'string' || !SHA256_RE.test(review.semantic_digest)) {
2840
+ errors.push('spec.json.validation.semantic_review.semantic_digest: must be sha256:<64 lowercase hex> when completed');
2841
+ }
2842
+ if (!C2_VERDICTS.has(review.verdict)) errors.push('spec.json.validation.semantic_review.verdict: must be PASS or FAIL when completed');
2843
+ if (!C2_LIFECYCLE_DISPOSITIONS.has(review.lifecycle_disposition)) errors.push('spec.json.validation.semantic_review.lifecycle_disposition: must be CONTINUE or BLOCKED when completed');
2844
+ if (!Number.isInteger(review.repair_round) || review.repair_round < 0 || review.repair_round > 2) {
2845
+ errors.push('spec.json.validation.semantic_review.repair_round: must be an integer 0..2 when completed');
2846
+ }
2847
+ validateSemanticReviewFindings(review, errors);
2848
+ validateSemanticReviewUnresolvedDecisions(review, errors);
2849
+ validateSemanticReviewGraphCoverage(review, errors);
2850
+ validateSemanticReviewerEvidence(review, errors);
2726
2851
  if (!Array.isArray(review.reviewed_criteria) || new Set(review.reviewed_criteria).size !== review.reviewed_criteria.length) {
2727
2852
  errors.push('spec.json.validation.semantic_review.reviewed_criteria: must be a unique array');
2728
2853
  } else {
@@ -2788,6 +2913,91 @@ function validateSemanticReview21(specDir, spec, criteria, designIds, verificati
2788
2913
  }
2789
2914
  }
2790
2915
 
2916
+ function computeLineageId(featureName, createdAt) {
2917
+ return `sha256:${crypto.createHash('sha256').update(SEMANTIC.stableJson({ feature_name: featureName, created_at: createdAt })).digest('hex')}`;
2918
+ }
2919
+
2920
+ // C13 SemanticReviewHistory: structural completeness, types, nullability,
2921
+ // enums, cardinality, and whole-lineage digest dedupe only — never semantic
2922
+ // judgment of the epoch/attempt-index derivation itself (that is the R1-02
2923
+ // finalizer's own write-time responsibility, see C3/D4).
2924
+ function validateSemanticReviewHistory21(spec, errors) {
2925
+ const history = spec.validation?.semantic_review_history;
2926
+ if (!isPlainObject(history) || Object.keys(history).sort().join(',') !== [...C13_HISTORY_FIELDS].sort().join(',')) {
2927
+ errors.push(`spec.json.validation.semantic_review_history: fields must be exactly ${C13_HISTORY_FIELDS.join(', ')}`);
2928
+ return;
2929
+ }
2930
+ const expectedLineageId = computeLineageId(spec.feature_name, spec.created_at);
2931
+ if (history.lineage_id !== expectedLineageId) {
2932
+ errors.push('spec.json.validation.semantic_review_history.lineage_id: must be sha256 of feature_name and created_at; callers never set it directly');
2933
+ }
2934
+ if (!Array.isArray(history.entries)) {
2935
+ errors.push('spec.json.validation.semantic_review_history.entries: must be an array');
2936
+ return;
2937
+ }
2938
+ const digestsSeen = new Set();
2939
+ for (const [index, entry] of history.entries.entries()) {
2940
+ const label = `semantic_review_history.entries[${index}]`;
2941
+ if (!isPlainObject(entry) || Object.keys(entry).sort().join(',') !== [...C13_ENTRY_FIELDS].sort().join(',')) {
2942
+ errors.push(`${label}: fields must be exactly ${C13_ENTRY_FIELDS.join(', ')}`); continue;
2943
+ }
2944
+ if (entry.sequence !== index) errors.push(`${label}.sequence: must start at 0 and increase by exactly 1 per real append`);
2945
+ if (typeof entry.semantic_digest !== 'string' || !SHA256_RE.test(entry.semantic_digest)) errors.push(`${label}.semantic_digest: must be sha256:<64 lowercase hex>`);
2946
+ if (typeof entry.review_receipt_digest !== 'string' || !SHA256_RE.test(entry.review_receipt_digest)) {
2947
+ errors.push(`${label}.review_receipt_digest: must be sha256:<64 lowercase hex>`);
2948
+ } else if (digestsSeen.has(entry.review_receipt_digest)) {
2949
+ errors.push(`${label}.review_receipt_digest: replays an existing entry's digest anywhere in the lineage; a replay must not be appended twice`);
2950
+ } else digestsSeen.add(entry.review_receipt_digest);
2951
+ if (!C2_VERDICTS.has(entry.verdict)) errors.push(`${label}.verdict: must be PASS or FAIL`);
2952
+ if (!C2_LIFECYCLE_DISPOSITIONS.has(entry.lifecycle_disposition)) errors.push(`${label}.lifecycle_disposition: must be CONTINUE or BLOCKED`);
2953
+ if (!Number.isInteger(entry.blocking_count) || entry.blocking_count < 0) errors.push(`${label}.blocking_count: must be a non-negative integer`);
2954
+ if (!Number.isInteger(entry.attempt_index) || entry.attempt_index < 0 || entry.attempt_index > 2) errors.push(`${label}.attempt_index: must be an integer 0..2`);
2955
+ if (!Number.isInteger(entry.review_epoch) || entry.review_epoch < 0) errors.push(`${label}.review_epoch: must be a non-negative integer`);
2956
+ }
2957
+ }
2958
+
2959
+ // C16 AuthoringValidationReceipt: structural completeness plus the fail-closed
2960
+ // freshness check (I20/R3.8) — an authoring.<field> reading `validated`
2961
+ // without a matching fresh digest is a hard structural failure here, not a
2962
+ // silent downgrade to draft.
2963
+ function validateAuthoringValidation21(specDir, spec, errors) {
2964
+ const receipt = spec.validation?.authoring_validation;
2965
+ if (receipt !== null) {
2966
+ if (!isPlainObject(receipt) || Object.keys(receipt).sort().join(',') !== [...C16_FIELDS].sort().join(',')) {
2967
+ errors.push(`spec.json.validation.authoring_validation: fields must be exactly ${C16_FIELDS.join(', ')} or null`);
2968
+ return;
2969
+ }
2970
+ if (receipt.schema_version !== '1') errors.push('spec.json.validation.authoring_validation.schema_version: must be exactly "1"');
2971
+ for (const stage of ['requirements', 'design', 'research', 'tasks']) {
2972
+ const label = `spec.json.validation.authoring_validation.${stage}`;
2973
+ const entry = receipt[stage];
2974
+ if (!isPlainObject(entry) || Object.keys(entry).sort().join(',') !== [...C16_STAGE_FIELDS].sort().join(',')) {
2975
+ errors.push(`${label}: fields must be exactly ${C16_STAGE_FIELDS.join(', ')}`); continue;
2976
+ }
2977
+ const sentinel = stage === 'research' ? AUTHORING_DIGEST.RESEARCH_ABSENT_DIGEST
2978
+ : stage === 'tasks' ? AUTHORING_DIGEST.TASKS_ABSENT_DIGEST : null;
2979
+ const digestValid = typeof entry.digest === 'string'
2980
+ && (SHA256_RE.test(entry.digest) || (sentinel !== null && entry.digest === sentinel));
2981
+ if (!digestValid) errors.push(`${label}.digest: must be sha256:<64 lowercase hex>${sentinel ? ` or ${sentinel}` : ''}`);
2982
+ if (!isIsoTimestamp(entry.validated_at)) errors.push(`${label}.validated_at: must be an ISO timestamp`);
2983
+ }
2984
+ }
2985
+ let freshness;
2986
+ try {
2987
+ freshness = AUTHORING_DIGEST.assertAuthoringFresh({
2988
+ specDir, authoring: spec.authoring, receipt, fields: ['requirements', 'design', 'research', 'tasks'],
2989
+ });
2990
+ } catch (error) {
2991
+ errors.push(`spec.json.validation.authoring_validation: cannot recompute current authoring digests (${error.message})`);
2992
+ return;
2993
+ }
2994
+ for (const field of ['requirements', 'design', 'research', 'tasks']) {
2995
+ if (spec.authoring?.[field] === 'validated' && !freshness[field]?.fresh) {
2996
+ errors.push(`spec.json.authoring.${field}: reads validated but validation.authoring_validation is absent, missing this field's entry, or digest-mismatched against current bytes (I20/C16)`);
2997
+ }
2998
+ }
2999
+ }
3000
+
2791
3001
  function validateDecisionState21(spec, errors) {
2792
3002
  if (!Object.prototype.hasOwnProperty.call(spec, 'decisions')) return;
2793
3003
  if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) {
@@ -3026,6 +3236,36 @@ function validateBoundaries21(spec, taskFiles, registry, anchorsByTask, criteria
3026
3236
  }
3027
3237
  }
3028
3238
  }
3239
+ // D10/I7/R8.3: global ownership-contention scan. Candidate pairs are derived
3240
+ // directly from the complete anchorsByTask write-anchor projection — every
3241
+ // task's every write anchor — never only from declared write_sets or from
3242
+ // iterating already-declared boundaries. Any single existing dependency
3243
+ // boundary between a pair covers every target that pair shares, regardless
3244
+ // of which target it names as its own deliverable; a same-target write pair
3245
+ // with no dependency boundary at all (even one never named in any boundary)
3246
+ // is exactly the defect this scan exists to catch.
3247
+ const connectedPairs = new Set(dependencyEdges.map(([producer, consumer]) => [producer, consumer].sort().join('\0')));
3248
+ const writersByTarget = new Map();
3249
+ for (const taskPath of taskFiles) {
3250
+ const taskId = registry[taskPath]?.id;
3251
+ if (!taskId) continue;
3252
+ for (const anchor of anchorsByTask.get(taskPath) || []) {
3253
+ if (anchor.access !== 'write') continue;
3254
+ if (!writersByTarget.has(anchor.target)) writersByTarget.set(anchor.target, new Set());
3255
+ writersByTarget.get(anchor.target).add(taskId);
3256
+ }
3257
+ }
3258
+ const flaggedContentionPairs = new Set();
3259
+ for (const [target, writers] of writersByTarget) {
3260
+ const writerIds = [...writers].sort();
3261
+ for (let left = 0; left < writerIds.length; left += 1) for (let right = left + 1; right < writerIds.length; right += 1) {
3262
+ const a = writerIds[left]; const b = writerIds[right];
3263
+ const pairKey = [a, b].sort().join('\0');
3264
+ if (connectedPairs.has(pairKey) || flaggedContentionPairs.has(pairKey)) continue;
3265
+ flaggedContentionPairs.add(pairKey);
3266
+ errors.push(`spec.json.coordination.boundaries: ${a} and ${b} both hold a write anchor for ${target} with no dependency boundary connecting them`);
3267
+ }
3268
+ }
3029
3269
  const participation = new Map([...taskIds].map((taskId) => [taskId, 0]));
3030
3270
  for (const boundary of spec.coordination.boundaries) {
3031
3271
  for (const taskId of new Set(boundaryParticipants(boundary))) if (participation.has(taskId)) {
@@ -3161,8 +3401,8 @@ function validateSpec21(specDir, canonicalSpecDir, spec, errors, warnings) {
3161
3401
  if (spec.scope_lock.expansion_policy !== 'requires-user-approval') errors.push('spec.json.scope_lock.expansion_policy: must be requires-user-approval');
3162
3402
  }
3163
3403
  if (isPlainObject(spec.validation)
3164
- && Object.keys(spec.validation).sort().join(',') !== 'semantic_review,status') {
3165
- errors.push('spec.json.validation: fields must be exactly status, semantic_review');
3404
+ && Object.keys(spec.validation).sort().join(',') !== [...VALIDATION_21_FIELDS].sort().join(',')) {
3405
+ errors.push(`spec.json.validation: fields must be exactly ${VALIDATION_21_FIELDS.join(', ')}`);
3166
3406
  }
3167
3407
  const taskFiles = listTaskFiles(specDir, canonicalSpecDir, errors);
3168
3408
  validateCoreMachineState(spec, taskFiles, errors, { schema21: true });
@@ -3206,7 +3446,13 @@ function validateSpec21(specDir, canonicalSpecDir, spec, errors, warnings) {
3206
3446
  const taskContents = new Map();
3207
3447
  for (const taskPath of taskFiles) {
3208
3448
  const content = fs.readFileSync(path.join(specDir, taskPath), 'utf8');
3209
- const task = validateTask21(taskPath, content, spec.task_registry?.[taskPath], errors);
3449
+ const task = validateTask21(
3450
+ taskPath,
3451
+ content,
3452
+ spec.task_registry?.[taskPath],
3453
+ spec.ready_for_implementation === true,
3454
+ errors,
3455
+ );
3210
3456
  taskContents.set(taskPath, content);
3211
3457
  anchorsByTask.set(taskPath, task.anchors);
3212
3458
  criteriaByTask.set(taskPath, task.criteria);
@@ -3241,6 +3487,8 @@ function validateSpec21(specDir, canonicalSpecDir, spec, errors, warnings) {
3241
3487
  designText, verificationDefinitions, errors,
3242
3488
  );
3243
3489
  validateSemanticReview21(specDir, spec, criteria, designIds, verificationDefinitions, errors);
3490
+ validateSemanticReviewHistory21(spec, errors);
3491
+ validateAuthoringValidation21(specDir, spec, errors);
3244
3492
  if (spec.ready_for_implementation === true) {
3245
3493
  for (const artifact of ['requirements', 'design']) if (spec.authoring?.[artifact] !== 'validated') errors.push(`spec.json.authoring.${artifact}: readiness requires validated`);
3246
3494
  if (taskFiles.length > 0 && spec.authoring?.tasks !== 'validated') errors.push('spec.json.authoring.tasks: readiness requires validated task projection');
@@ -3685,5 +3933,9 @@ function main() {
3685
3933
  }
3686
3934
 
3687
3935
  module.exports = { validateSpec, computeSemanticDigest21 };
3936
+ Object.defineProperty(module.exports, 'C2_FIELDS', {
3937
+ value: C2_FIELDS,
3938
+ enumerable: false,
3939
+ });
3688
3940
 
3689
3941
  if (require.main === module) main();
@@ -137,8 +137,20 @@ fix. Scope or product decisions require explicit user approval before editing.
137
137
  ## Deterministic and grounding gates
138
138
 
139
139
  Finalize requirements, design, optional research, task files, and canonical
140
- `spec.json` topology first. Compute the candidate digest read-only, then provide
141
- a review JSON object with exactly `reviewed_criteria` and `counterexamples`. The
140
+ `spec.json` topology first. Every required authoring stage (`requirements`,
141
+ `design`, and, when present, `research`/`tasks`) must already read `validated`
142
+ via a fresh run of `spec-authoring-validation.cjs` — the sole atomic writer of
143
+ that transition and its C16 `AuthoringValidationReceipt` — before the digest
144
+ below is computed; a `validated` reading with no matching fresh receipt digest
145
+ is treated as `draft` everywhere it is read. Compute the candidate digest
146
+ read-only, then provide a review JSON object with exactly `verdict`,
147
+ `findings`, `unresolved_decisions`, `graph_coverage`, `reviewed_criteria`,
148
+ `counterexamples`, and `reviewer_evidence` — the frozen C2 fields the reviewer
149
+ itself supplies. `lifecycle_disposition` and `repair_round` are never authored
150
+ in this JSON: the finalizer derives both (repair_round is the C13 `attempt_index`
151
+ for the current review_epoch; lifecycle_disposition is CONTINUE unless a
152
+ non-PASS at the epoch's third attempt forces BLOCKED) and appends the completed
153
+ receipt to the durable, append-only `semantic_review_history` lineage. The
142
154
  author must not fabricate or directly write authority state.
143
155
 
144
156
  The digest projects task registry semantics as `id`, `dependencies`, and optional
@@ -25,9 +25,21 @@
25
25
  "semantic_review": {
26
26
  "status": "not-run",
27
27
  "semantic_digest": null,
28
+ "verdict": null,
29
+ "lifecycle_disposition": null,
30
+ "findings": [],
31
+ "unresolved_decisions": [],
32
+ "graph_coverage": [],
33
+ "repair_round": 0,
28
34
  "reviewed_criteria": [],
29
- "counterexamples": []
30
- }
35
+ "counterexamples": [],
36
+ "reviewer_evidence": null
37
+ },
38
+ "semantic_review_history": {
39
+ "lineage_id": "{{LINEAGE_ID}}",
40
+ "entries": []
41
+ },
42
+ "authoring_validation": null
31
43
  },
32
44
  "semantic_model": null,
33
45
  "ready_for_implementation": false,
@@ -5,7 +5,12 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const PATH_SAFETY = require('./runtime-path-safety.cjs');
7
7
 
8
- const TTL_MS = 5 * 60 * 1000;
8
+ // Codex cannot raise an interactive prompt from a hook -- PreToolUse honours no
9
+ // "ask" decision -- so approval costs a full round trip: the agent has to relay
10
+ // a request id and the user has to type it back. A five-minute window expired
11
+ // mid-conversation, leaving the agent repeating a request id that could no
12
+ // longer be approved. An hour outlives a working session's train of thought.
13
+ const TTL_MS = 60 * 60 * 1000;
9
14
  const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
10
15
  function stateDir(projectRoot) {
11
16
  return path.join(projectRoot, '.codex', 'hooks', '.privacy');
@@ -115,8 +120,7 @@ function createPending({
115
120
  sessionCwd,
116
121
  sessionId,
117
122
  filePaths,
118
- filePath,
119
- toolName
123
+ filePath
120
124
  }) {
121
125
  cleanup(projectRoot);
122
126
  if (!sessionId) throw new Error('A Codex session ID is required');
@@ -129,7 +133,6 @@ function createPending({
129
133
  sessionId: String(sessionId || ''),
130
134
  pathKeys: requestPathKeys(paths, sessionCwd),
131
135
  displayName: displayNames(paths),
132
- toolName: String(toolName || ''),
133
136
  expiresAt: Date.now() + TTL_MS
134
137
  };
135
138
  const dir = secureStateDir(projectRoot, true);
@@ -156,24 +159,32 @@ function approvePending({ projectRoot, sessionId, requestId }) {
156
159
  return { ok: false, reason: 'consumed' };
157
160
  }
158
161
 
162
+ // The grant window starts when the user approves, not when the request was
163
+ // raised. Carrying the pending record's deadline over meant a late approval
164
+ // bought only the minutes left on it.
159
165
  atomicWrite(dir, path.join(dir, `token-${requestId}.json`), {
160
166
  version: 2,
161
167
  requestId,
162
168
  sessionId: record.sessionId,
163
169
  pathKeys: record.pathKeys,
164
- toolName: record.toolName,
165
- expiresAt: Math.min(record.expiresAt, Date.now() + TTL_MS)
170
+ expiresAt: Date.now() + TTL_MS
166
171
  });
167
172
  return { ok: true, displayName: record.displayName };
168
173
  }
169
174
 
170
- function consumeToken({
175
+ /**
176
+ * A grant is session-bound, path-bound and time-limited, but not single-use:
177
+ * one approval covers repeated access to the same paths until it expires.
178
+ * Consuming it on first use made every re-read a fresh round trip, and the tool
179
+ * name is not part of the identity either -- reading a file with `cat` and with
180
+ * a native read is the same access to the same secret.
181
+ */
182
+ function hasGrant({
171
183
  projectRoot,
172
184
  sessionCwd,
173
185
  sessionId,
174
186
  filePaths,
175
- filePath,
176
- toolName
187
+ filePath
177
188
  }) {
178
189
  cleanup(projectRoot);
179
190
  if (!sessionId) return false;
@@ -185,26 +196,16 @@ function consumeToken({
185
196
 
186
197
  for (const name of fs.readdirSync(dir)) {
187
198
  if (!/^token-[a-f0-9]{24}\.json$/.test(name)) continue;
188
- const tokenFile = path.join(dir, name);
189
- const token = readJson(dir, tokenFile);
199
+ const token = readJson(dir, path.join(dir, name));
190
200
  if (
191
201
  !token ||
192
202
  token.sessionId !== String(sessionId || '') ||
193
203
  !samePathKeys(token.pathKeys, expectedPaths) ||
194
- token.toolName !== String(toolName || '') ||
195
204
  Number(token.expiresAt) <= Date.now()
196
205
  ) {
197
206
  continue;
198
207
  }
199
-
200
- const claimed = `${tokenFile}.claimed-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
201
- try {
202
- fs.renameSync(tokenFile, claimed);
203
- fs.unlinkSync(claimed);
204
- return true;
205
- } catch {
206
- // Only one concurrent tool invocation can rename and consume this token.
207
- }
208
+ return true;
208
209
  }
209
210
  return false;
210
211
  }
@@ -215,7 +216,7 @@ module.exports = {
215
216
  pathKey,
216
217
  createPending,
217
218
  approvePending,
218
- consumeToken,
219
+ hasGrant,
219
220
  cleanup,
220
221
  clearState
221
222
  };
@@ -24,7 +24,11 @@ try {
24
24
  const data = readPayload();
25
25
  if (!data) process.exit(0);
26
26
  const { projectRoot } = getHookContext(data);
27
- const prompt = String(data.prompt || '');
27
+ // A pasted prompt arrives with whatever whitespace the terminal added around
28
+ // it. Rejecting on a trailing newline made the approval look broken while the
29
+ // id it named was still perfectly valid; the exactness that matters is the
30
+ // request id, checked below.
31
+ const prompt = String(data.prompt || '').trim();
28
32
  cleanup(projectRoot);
29
33
 
30
34
  if (!prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
@@ -49,8 +53,8 @@ try {
49
53
  }
50
54
 
51
55
  context(
52
- `User approved one retry for the pending sensitive access (${result.displayName}). ` +
53
- `The token is session-bound, path-bound, tool-bound, time-limited, and consumed atomically.`
56
+ `User approved the pending sensitive access (${result.displayName}). ` +
57
+ `The grant is session-bound, bound to exactly those paths, and expires in one hour.`
54
58
  );
55
59
  process.exit(0);
56
60
  } catch {
@@ -6,16 +6,13 @@ const path = require('path');
6
6
  const {
7
7
  APPROVAL_PREFIX,
8
8
  createPending,
9
- consumeToken
9
+ hasGrant
10
10
  } = require('./lib/privacy-state.cjs');
11
11
  const {
12
12
  getHookContext,
13
13
  readPayload
14
14
  } = require('./lib/hook-context.cjs');
15
- const {
16
- DYNAMIC_READ_PATH,
17
- commandAccess,
18
- } = require('./lib/privacy-command-analysis.cjs');
15
+ const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
19
16
 
20
17
  const RESTRICTED = [
21
18
  /^\.env(?:[.\[*?{]|$)/i,
@@ -32,13 +29,7 @@ const RESTRICTED = [
32
29
  /\.keystore$/i,
33
30
  /\.jks$/i,
34
31
  /auth\.json$/i,
35
- /token(s)?\.json$/i,
36
- /\.cafekit/i,
37
- /hmac\.key/i,
38
- /completion-authority/i,
39
- /session-state/i,
40
- /hook-log/i,
41
- /sessions/i
32
+ /token(s)?\.json$/i
42
33
  ];
43
34
 
44
35
  const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
@@ -58,7 +49,6 @@ function isSafe(filePath) {
58
49
  }
59
50
 
60
51
  function isSensitive(filePath) {
61
- if (filePath === DYNAMIC_READ_PATH) return true;
62
52
  return matchesAny(filePath, RESTRICTED);
63
53
  }
64
54
 
@@ -120,11 +110,44 @@ function isSymlinkToSensitive(filePath, cwd) {
120
110
  }
121
111
  }
122
112
 
123
- function extractCommandPaths(command) {
124
- const access = commandAccess(command);
125
- return access.dynamic ? [...access.paths, DYNAMIC_READ_PATH] : access.paths;
113
+ function extractCommandPaths(command) {
114
+ // The analyzer parses the command and returns the operands of every reading
115
+ // command it recognises, nested shells and `find -exec` included. Treating
116
+ // every unresolved expansion as sensitive, as this did before, denied
117
+ // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike -- and every denial here
118
+ // costs a round trip through a one-shot approval prompt.
119
+ // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell expands
120
+ // nothing inside it, so no word in the body is ever read. The analyzer has no
121
+ // notion of heredocs and parses the body as shell, so prose that quotes a
122
+ // command -- a PR body carrying `cat .env` in backticks -- came back as a real
123
+ // command substitution. Blank those bodies out first. An unquoted delimiter
124
+ // (<<BODY) does expand, so it is left to the analyzer.
125
+ const scrubbed = String(command).replace(
126
+ /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
127
+ '$1$2$3$2'
128
+ );
129
+
130
+ const paths = new Set(commandAccess(scrubbed).paths);
131
+
132
+ // Two shapes the analyzer cannot return. It drops any word carrying an
133
+ // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
134
+ // name. And it only walks commands it recognises, so an unknown tool handed a
135
+ // secret through a file-shaped option, such as
136
+ // `docker compose --env-file .env`, is invisible to it.
137
+ const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
138
+ const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
139
+ for (let index = 0; index < tokens.length; index += 1) {
140
+ const token = tokens[index];
141
+ const shaped = /^~|[*?[{]/.test(token);
142
+ const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
143
+ if (!shaped && !optionFed) continue;
144
+ const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
145
+ if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
126
146
  }
127
147
 
148
+ return [...paths];
149
+ }
150
+
128
151
  function extractPatchPaths(patch) {
129
152
  return [...String(patch).matchAll(
130
153
  /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
@@ -216,19 +239,19 @@ try {
216
239
  );
217
240
  process.exit(0);
218
241
  }
219
- const tokenInput = {
242
+ const grantInput = {
220
243
  projectRoot,
221
244
  sessionCwd,
222
245
  sessionId: data.session_id,
223
- filePaths: effectivePaths,
224
- toolName: data.tool_name
246
+ filePaths: effectivePaths
225
247
  };
226
- if (consumeToken(tokenInput)) process.exit(0);
248
+ if (hasGrant(grantInput)) process.exit(0);
227
249
 
228
- const pending = createPending(tokenInput);
250
+ const pending = createPending(grantInput);
229
251
  deny(
230
252
  `Sensitive file access denied: ${pending.displayName}. ` +
231
- `If you approve exactly one retry in this Codex session, send this exact user prompt: ` +
253
+ `To approve access to exactly these paths for the next hour of this Codex ` +
254
+ `session, send this exact user prompt: ` +
232
255
  `${APPROVAL_PREFIX}${pending.requestId}`
233
256
  );
234
257
  process.exit(0);