@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
@@ -28,6 +28,7 @@ const path = require('path');
28
28
  const crypto = require('crypto');
29
29
  const POLICY = require('./workflow-policy.cjs');
30
30
  const SEMANTIC = require('./spec-semantic-model.cjs');
31
+ const AUTHORING_DIGEST = require('./spec-authoring-digest.cjs');
31
32
  const PATH_SAFETY = require('../hooks/lib/runtime-path-safety.cjs');
32
33
 
33
34
  const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
@@ -81,9 +82,29 @@ const CANONICAL_SPEC_FIELDS = new Set([
81
82
  ]);
82
83
  const SCOPE_LOCK_FIELDS = ['source', 'in_scope', 'out_of_scope', 'expansion_policy'];
83
84
  const COORDINATION_FIELDS = new Set(['boundaries', 'phases']);
84
- const VALIDATION_FIELDS = ['status', 'semantic_review'];
85
- const SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples'];
86
- const LEGACY_SEMANTIC_REVIEW_FIELDS = [...SEMANTIC_REVIEW_FIELDS, 'reviewed_artifact_digest'];
85
+ // Schema 2.0 legacy shapes (pre-Specs-v2.1 migration input) are frozen as-is;
86
+ // they never gain the C2/C13/C16 fields below, which are canonical-2.1-only.
87
+ const LEGACY_VALIDATION_FIELDS = ['status', 'semantic_review'];
88
+ const LEGACY_SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples', 'reviewed_artifact_digest'];
89
+ // Canonical schema 2.1 (R1-01, frozen C2/C13/C16).
90
+ const VALIDATION_FIELDS = ['status', 'semantic_review', 'semantic_review_history', 'authoring_validation'];
91
+ const PRE_C2_SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples'];
92
+ const SEMANTIC_REVIEW_FIELDS = [
93
+ 'status', 'semantic_digest', 'verdict', 'lifecycle_disposition', 'findings',
94
+ 'unresolved_decisions', 'graph_coverage', 'repair_round', 'reviewed_criteria',
95
+ 'counterexamples', 'reviewer_evidence',
96
+ ];
97
+ const SEMANTIC_REVIEW_FINDING_FIELDS = ['id', 'severity', 'location', 'summary', 'blocking', 'disposition'];
98
+ const SEMANTIC_REVIEW_DECISION_FIELDS = ['id', 'summary', 'blocking'];
99
+ const SEMANTIC_REVIEW_COVERAGE_FIELDS = ['surface', 'covered', 'notes'];
100
+ const SEMANTIC_REVIEW_EVIDENCE_FIELDS = ['assurance', 'summary'];
101
+ const SEMANTIC_REVIEW_HISTORY_FIELDS = ['lineage_id', 'entries'];
102
+ const SEMANTIC_REVIEW_HISTORY_ENTRY_FIELDS = [
103
+ 'sequence', 'semantic_digest', 'review_receipt_digest', 'verdict',
104
+ 'lifecycle_disposition', 'blocking_count', 'attempt_index', 'review_epoch',
105
+ ];
106
+ const AUTHORING_VALIDATION_FIELDS = ['schema_version', 'requirements', 'design', 'research', 'tasks'];
107
+ const AUTHORING_VALIDATION_STAGE_FIELDS = ['digest', 'validated_at'];
87
108
  const LEGACY_SPEC_FIELDS = [
88
109
  ...CANONICAL_SPEC_FIELDS,
89
110
  'approvals', 'current_phase', 'override_receipt', 'timestamps',
@@ -108,8 +129,13 @@ const TRANSACTION_FORMAT = 'cafekit-spec-scaffold-transaction/1';
108
129
 
109
130
  class ScaffoldPreconditionError extends Error {}
110
131
 
132
+ const USAGE_TEXT = 'Usage: node spec-scaffold.cjs <feature> [--sync-semantic-model] [--tasks "R0-01-slug,R1-01-slug" --boundaries \'[{"id":"B-OWN","type":"ownership",...}]\'] [--lane Standard|Critical] [--planning-depth Compact|Full] [--assurance-level Routine|Elevated|Strict] [--research --uncertainty "..."] [--phases \'[{"id":"phase-1","task_ids":["R1-01"],"entry_condition":"...","exit_condition":"...","owner_boundary":"B-OWN"}]\'] [--risks auth,privacy] [--lang en] [--title "..."] [--specs-root specs]';
133
+
134
+ // Usage reaches stderr when it accompanies a precondition failure, and stdout
135
+ // when the caller asked for it with --help; a requested help text is output,
136
+ // not a diagnostic.
111
137
  function usage() {
112
- console.error('Usage: node spec-scaffold.cjs <feature> [--sync-semantic-model] [--tasks "R0-01-slug,R1-01-slug" --boundaries \'[{"id":"B-OWN","type":"ownership",...}]\'] [--lane Standard|Critical] [--planning-depth Compact|Full] [--assurance-level Routine|Elevated|Strict] [--research --uncertainty "..."] [--phases \'[{"id":"phase-1","task_ids":["R1-01"],"entry_condition":"...","exit_condition":"...","owner_boundary":"B-OWN"}]\'] [--risks auth,privacy] [--lang en] [--title "..."] [--specs-root specs]');
138
+ console.error(USAGE_TEXT);
113
139
  }
114
140
 
115
141
  function failPrecondition(message) {
@@ -278,15 +304,35 @@ function pendingApproval() {
278
304
  return 'draft';
279
305
  }
280
306
 
307
+ // C2 SemanticReviewReceipt (R1-01, frozen): not-run keeps every receipt field
308
+ // null/empty, including the separate verdict and lifecycle_disposition and
309
+ // the finalizer-derived repair_round attempt index.
281
310
  function semanticReviewNotRun() {
282
311
  return {
283
312
  status: 'not-run',
284
313
  semantic_digest: null,
314
+ verdict: null,
315
+ lifecycle_disposition: null,
316
+ findings: [],
317
+ unresolved_decisions: [],
318
+ graph_coverage: [],
319
+ repair_round: 0,
285
320
  reviewed_criteria: [],
286
321
  counterexamples: [],
322
+ reviewer_evidence: null,
287
323
  };
288
324
  }
289
325
 
326
+ // C13 SemanticReviewHistory (R1-01, frozen): lineage_id is derived once, from
327
+ // the immutable feature_name/created_at pair, and never set by a caller.
328
+ function computeLineageId(featureName, createdAt) {
329
+ return `sha256:${crypto.createHash('sha256').update(SEMANTIC.stableJson({ feature_name: featureName, created_at: createdAt })).digest('hex')}`;
330
+ }
331
+
332
+ function semanticReviewHistoryInitial(featureName, createdAt) {
333
+ return { lineage_id: computeLineageId(featureName, createdAt), entries: [] };
334
+ }
335
+
290
336
  function parseArtifactDeclarations(raw, parsedTasks) {
291
337
  if (raw === null || raw === undefined) return new Map();
292
338
  let declarations;
@@ -716,7 +762,7 @@ function canonicalRegistryFields(entry) {
716
762
  return hasOwn(entry, 'artifacts') ? [...REGISTRY_KEYS, 'artifacts'] : REGISTRY_KEYS;
717
763
  }
718
764
 
719
- function assertCanonicalSpecInputClosedWorld(spec) {
765
+ function assertCanonicalSpecInputClosedWorld(spec, { allowLegacyReview = false } = {}) {
720
766
  if (!isPlainObject(spec) || spec.schema_version !== '2.1') return;
721
767
  const topLevel = [...CANONICAL_SPEC_FIELDS].filter((field) => {
722
768
  if (field === 'research') return hasOwn(spec, 'research');
@@ -730,10 +776,29 @@ function assertCanonicalSpecInputClosedWorld(spec) {
730
776
  const coordinationFields = hasOwn(spec.coordination, 'phases')
731
777
  ? [...COORDINATION_FIELDS] : ['boundaries'];
732
778
  assertExactKeys(spec.coordination, coordinationFields, 'canonical schema 2.1 coordination');
733
- assertExactKeys(spec.validation, VALIDATION_FIELDS, 'canonical schema 2.1 validation');
779
+ const reviewKeys = isPlainObject(spec.validation?.semantic_review)
780
+ ? Object.keys(spec.validation.semantic_review).sort().join(',') : '';
781
+ const legacyReviewFields = reviewKeys === [...LEGACY_SEMANTIC_REVIEW_FIELDS].sort().join(',')
782
+ ? LEGACY_SEMANTIC_REVIEW_FIELDS
783
+ : reviewKeys === [...PRE_C2_SEMANTIC_REVIEW_FIELDS].sort().join(',')
784
+ ? PRE_C2_SEMANTIC_REVIEW_FIELDS : null;
785
+ const legacyReview = allowLegacyReview
786
+ && isPlainObject(spec.validation)
787
+ && Object.keys(spec.validation).sort().join(',') === ['semantic_review', 'status'].sort().join(',')
788
+ && legacyReviewFields !== null;
789
+ // semantic_review_history and authoring_validation (C13/C16) are required
790
+ // going forward, but a pre-existing 2.1 spec authored before this feature's
791
+ // cutover may not carry them yet; accept their absence here so scaffold can
792
+ // migrate such a spec forward (normalizeSpec21Candidate forces a fresh reset
793
+ // whenever either is missing) instead of hard-failing on an old file.
794
+ const validationFields = legacyReview ? ['status', 'semantic_review'] : [...VALIDATION_FIELDS].filter((field) => {
795
+ if (field === 'semantic_review_history' || field === 'authoring_validation') return hasOwn(spec.validation, field);
796
+ return true;
797
+ });
798
+ assertExactKeys(spec.validation, validationFields, 'canonical schema 2.1 validation');
734
799
  assertExactKeys(
735
800
  spec.validation.semantic_review,
736
- SEMANTIC_REVIEW_FIELDS,
801
+ legacyReview ? legacyReviewFields : SEMANTIC_REVIEW_FIELDS,
737
802
  'canonical schema 2.1 validation.semantic_review',
738
803
  );
739
804
  if (Array.isArray(spec.validation.semantic_review?.counterexamples)) {
@@ -746,6 +811,38 @@ function assertCanonicalSpecInputClosedWorld(spec) {
746
811
  );
747
812
  }
748
813
  }
814
+ for (const [index, finding] of (spec.validation.semantic_review?.findings || []).entries()) {
815
+ assertExactKeys(finding, SEMANTIC_REVIEW_FINDING_FIELDS, `canonical schema 2.1 validation.semantic_review.findings[${index}]`);
816
+ }
817
+ for (const [index, decision] of (spec.validation.semantic_review?.unresolved_decisions || []).entries()) {
818
+ assertExactKeys(decision, SEMANTIC_REVIEW_DECISION_FIELDS, `canonical schema 2.1 validation.semantic_review.unresolved_decisions[${index}]`);
819
+ }
820
+ for (const [index, coverage] of (spec.validation.semantic_review?.graph_coverage || []).entries()) {
821
+ assertExactKeys(coverage, SEMANTIC_REVIEW_COVERAGE_FIELDS, `canonical schema 2.1 validation.semantic_review.graph_coverage[${index}]`);
822
+ }
823
+ if (!legacyReview && spec.validation.semantic_review?.reviewer_evidence !== null) {
824
+ assertExactKeys(
825
+ spec.validation.semantic_review?.reviewer_evidence,
826
+ SEMANTIC_REVIEW_EVIDENCE_FIELDS,
827
+ 'canonical schema 2.1 validation.semantic_review.reviewer_evidence',
828
+ );
829
+ }
830
+ if (hasOwn(spec.validation, 'semantic_review_history')) {
831
+ assertExactKeys(spec.validation.semantic_review_history, SEMANTIC_REVIEW_HISTORY_FIELDS, 'canonical schema 2.1 validation.semantic_review_history');
832
+ for (const [index, entry] of (spec.validation.semantic_review_history?.entries || []).entries()) {
833
+ assertExactKeys(entry, SEMANTIC_REVIEW_HISTORY_ENTRY_FIELDS, `canonical schema 2.1 validation.semantic_review_history.entries[${index}]`);
834
+ }
835
+ }
836
+ if (hasOwn(spec.validation, 'authoring_validation') && spec.validation.authoring_validation !== null) {
837
+ assertExactKeys(spec.validation.authoring_validation, AUTHORING_VALIDATION_FIELDS, 'canonical schema 2.1 validation.authoring_validation');
838
+ for (const stage of ['requirements', 'design', 'research', 'tasks']) {
839
+ assertExactKeys(
840
+ spec.validation.authoring_validation?.[stage],
841
+ AUTHORING_VALIDATION_STAGE_FIELDS,
842
+ `canonical schema 2.1 validation.authoring_validation.${stage}`,
843
+ );
844
+ }
845
+ }
749
846
  if (hasOwn(spec, 'decisions')) {
750
847
  if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) failPrecondition('canonical schema 2.1 decisions must be omitted or a non-empty array');
751
848
  for (const [index, decision] of spec.decisions.entries()) {
@@ -790,7 +887,7 @@ function assertLegacyMigrationClosedWorld(spec) {
790
887
  rejectUnknownKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'schema 2.0 scope_lock');
791
888
  rejectUnknownKeys(spec.authoring, AUTHORING_FIELDS, 'schema 2.0 authoring');
792
889
  rejectUnknownKeys(spec.coordination, LEGACY_COORDINATION_FIELDS, 'schema 2.0 coordination');
793
- rejectUnknownKeys(spec.validation, VALIDATION_FIELDS, 'schema 2.0 validation');
890
+ rejectUnknownKeys(spec.validation, LEGACY_VALIDATION_FIELDS, 'schema 2.0 validation');
794
891
  rejectUnknownKeys(spec.validation?.semantic_review, LEGACY_SEMANTIC_REVIEW_FIELDS, 'schema 2.0 validation.semantic_review');
795
892
  for (const [index, counterexample] of (spec.validation?.semantic_review?.counterexamples || []).entries()) {
796
893
  rejectUnknownKeys(counterexample, ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'], `schema 2.0 semantic_review.counterexamples[${index}]`);
@@ -993,11 +1090,36 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
993
1090
  }
994
1091
 
995
1092
  function semanticReviewShapeValid(review) {
996
- return isPlainObject(review)
997
- && ['not-run', 'completed'].includes(review.status)
998
- && (review.semantic_digest === null || typeof review.semantic_digest === 'string')
999
- && Array.isArray(review.reviewed_criteria)
1000
- && Array.isArray(review.counterexamples);
1093
+ if (!isPlainObject(review) || Object.keys(review).sort().join(',') !== [...SEMANTIC_REVIEW_FIELDS].sort().join(',')) return false;
1094
+ if (!['not-run', 'completed'].includes(review.status)) return false;
1095
+ if (review.semantic_digest !== null && typeof review.semantic_digest !== 'string') return false;
1096
+ if (review.verdict !== null && !['PASS', 'FAIL'].includes(review.verdict)) return false;
1097
+ if (review.lifecycle_disposition !== null && !['CONTINUE', 'BLOCKED'].includes(review.lifecycle_disposition)) return false;
1098
+ if (!Array.isArray(review.findings) || !review.findings.every((finding) => isPlainObject(finding)
1099
+ && Object.keys(finding).sort().join(',') === [...SEMANTIC_REVIEW_FINDING_FIELDS].sort().join(','))) return false;
1100
+ if (!Array.isArray(review.unresolved_decisions) || !review.unresolved_decisions.every((decision) => isPlainObject(decision)
1101
+ && Object.keys(decision).sort().join(',') === [...SEMANTIC_REVIEW_DECISION_FIELDS].sort().join(','))) return false;
1102
+ if (!Array.isArray(review.graph_coverage) || !review.graph_coverage.every((coverage) => isPlainObject(coverage)
1103
+ && Object.keys(coverage).sort().join(',') === [...SEMANTIC_REVIEW_COVERAGE_FIELDS].sort().join(','))) return false;
1104
+ if (!Number.isInteger(review.repair_round) || review.repair_round < 0 || review.repair_round > 2) return false;
1105
+ if (!Array.isArray(review.reviewed_criteria) || !Array.isArray(review.counterexamples)) return false;
1106
+ if (review.reviewer_evidence !== null && (!isPlainObject(review.reviewer_evidence)
1107
+ || Object.keys(review.reviewer_evidence).sort().join(',') !== [...SEMANTIC_REVIEW_EVIDENCE_FIELDS].sort().join(','))) return false;
1108
+ return true;
1109
+ }
1110
+
1111
+ function semanticReviewHistoryShapeValid(history) {
1112
+ if (!isPlainObject(history) || Object.keys(history).sort().join(',') !== [...SEMANTIC_REVIEW_HISTORY_FIELDS].sort().join(',')) return false;
1113
+ if (typeof history.lineage_id !== 'string') return false;
1114
+ return Array.isArray(history.entries) && history.entries.every((entry) => isPlainObject(entry)
1115
+ && Object.keys(entry).sort().join(',') === [...SEMANTIC_REVIEW_HISTORY_ENTRY_FIELDS].sort().join(','));
1116
+ }
1117
+
1118
+ function authoringValidationShapeValid(receipt) {
1119
+ if (receipt === null) return true;
1120
+ if (!isPlainObject(receipt) || Object.keys(receipt).sort().join(',') !== [...AUTHORING_VALIDATION_FIELDS].sort().join(',')) return false;
1121
+ return ['requirements', 'design', 'research', 'tasks'].every((stage) => isPlainObject(receipt[stage])
1122
+ && Object.keys(receipt[stage]).sort().join(',') === [...AUTHORING_VALIDATION_STAGE_FIELDS].sort().join(','));
1001
1123
  }
1002
1124
 
1003
1125
  function preflightSpec21Candidate(spec, evidence) {
@@ -1047,7 +1169,9 @@ function preflightSpec21Candidate(spec, evidence) {
1047
1169
  failPrecondition('schema 2.1 candidate coordination.boundaries must be an array');
1048
1170
  }
1049
1171
  if (!isPlainObject(spec.validation) || !VALIDATION_STATUS_VALUES.has(spec.validation.status)
1050
- || !semanticReviewShapeValid(spec.validation.semantic_review)) {
1172
+ || !semanticReviewShapeValid(spec.validation.semantic_review)
1173
+ || !semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
1174
+ || !authoringValidationShapeValid(spec.validation.authoring_validation)) {
1051
1175
  failPrecondition('schema 2.1 candidate validation state is invalid');
1052
1176
  }
1053
1177
  if (typeof spec.ready_for_implementation !== 'boolean') failPrecondition('schema 2.1 candidate readiness must be boolean');
@@ -1122,19 +1246,48 @@ function normalizeSpec21Candidate(candidate, evidence) {
1122
1246
  failPrecondition('existing scope_lock canonical fields are invalid');
1123
1247
  }
1124
1248
 
1125
- const previousAuthoring = isPlainObject(candidate.authoring) ? candidate.authoring : {};
1249
+ // D13/I21/R3.9: spec-scaffold.cjs never *writes* `validated` for any
1250
+ // authoring field — only spec-authoring-validation.cjs's atomic coordinator
1251
+ // may flip a stage to validated, after its own clean validator+grounder
1252
+ // pass. It does, however, carry an existing `validated` reading forward
1253
+ // untouched while that stage's C16 receipt still matches the stage's
1254
+ // current bytes, exactly as it already preserves the C13 history and the
1255
+ // receipt itself below: a still-fresh durable record is not scaffold's to
1256
+ // destroy, and drafting it would force a pointless re-validation. Any
1257
+ // stage whose bytes actually changed no longer matches its receipt and
1258
+ // therefore lands on draft here (and reads as draft everywhere via I20).
1259
+ const receiptForFreshness = authoringValidationShapeValid(candidate.validation?.authoring_validation)
1260
+ ? candidate.validation.authoring_validation
1261
+ : null;
1262
+ // One pass over the current bytes for all four stages; assertAuthoringFresh
1263
+ // already treats a non-`validated` enum as not fresh. Any unreadable
1264
+ // artifact fails closed to draft here, and the per-stage preconditions
1265
+ // below still report the missing file as the real error.
1266
+ let authoringFreshness = null;
1267
+ if (receiptForFreshness !== null && typeof evidence.specDir === 'string') {
1268
+ try {
1269
+ authoringFreshness = AUTHORING_DIGEST.assertAuthoringFresh({
1270
+ specDir: evidence.specDir,
1271
+ authoring: candidate.authoring,
1272
+ receipt: receiptForFreshness,
1273
+ fields: ['requirements', 'design', 'research', 'tasks'],
1274
+ });
1275
+ } catch {
1276
+ authoringFreshness = null;
1277
+ }
1278
+ }
1279
+ const carryForward = (field) => (authoringFreshness?.[field]?.fresh ? 'validated' : 'draft');
1126
1280
  const authoring = {};
1127
1281
  for (const field of ['requirements', 'design']) {
1128
1282
  if (!evidence[`${field}Exists`]) failPrecondition(`physical ${field}.md is required for schema 2.1 migration`);
1129
- authoring[field] = previousAuthoring[field] === 'validated' ? 'validated' : 'draft';
1283
+ authoring[field] = carryForward(field);
1130
1284
  }
1131
1285
  if (evidence.researchExists) {
1132
1286
  const pointer = isPlainObject(candidate.research) ? candidate.research.path : candidate.research;
1133
1287
  if (pointer !== undefined && pointer !== null && pointer !== 'research.md') {
1134
1288
  failPrecondition('legacy research pointer is ambiguous; expected canonical research.md');
1135
1289
  }
1136
- authoring.research = previousAuthoring.research === 'validated' && !evidence.researchChanged
1137
- ? 'validated' : 'draft';
1290
+ authoring.research = carryForward('research');
1138
1291
  } else {
1139
1292
  if (hasOwn(candidate, 'research') && candidate.research !== null) {
1140
1293
  failPrecondition('research pointer exists without a physical research.md artifact');
@@ -1142,8 +1295,7 @@ function normalizeSpec21Candidate(candidate, evidence) {
1142
1295
  authoring.research = 'absent';
1143
1296
  }
1144
1297
  if (evidence.taskFiles.length > 0) {
1145
- authoring.tasks = previousAuthoring.tasks === 'validated' && !evidence.tasksChanged
1146
- ? 'validated' : 'draft';
1298
+ authoring.tasks = carryForward('tasks');
1147
1299
  } else {
1148
1300
  authoring.tasks = 'absent';
1149
1301
  }
@@ -1156,10 +1308,35 @@ function normalizeSpec21Candidate(candidate, evidence) {
1156
1308
  if (hasOwn(entry, key)) projectedRegistry[taskPath][key] = cloneJson(entry[key]);
1157
1309
  }
1158
1310
  }
1311
+ // semantic_review_history (C13) and authoring_validation (C16) are durable
1312
+ // records this migration step never wipes on its own: history is
1313
+ // append-only for the life of the spec record (I6 — lineage_id never
1314
+ // resets on an ordinary content edit) and the C16 receipt may only ever be
1315
+ // written by spec-authoring-validation.cjs (I21) — scaffold is not a
1316
+ // writer of either and only seeds a fresh skeleton when a pre-cutover
1317
+ // legacy spec never carried them at all.
1318
+ const historyValid = hasOwn(candidate.validation || {}, 'semantic_review_history')
1319
+ && semanticReviewHistoryShapeValid(candidate.validation.semantic_review_history);
1320
+ const authoringValidationValid = hasOwn(candidate.validation || {}, 'authoring_validation')
1321
+ && authoringValidationShapeValid(candidate.validation.authoring_validation);
1159
1322
  const validationValid = isPlainObject(candidate.validation)
1160
1323
  && VALIDATION_STATUS_VALUES.has(candidate.validation.status)
1161
- && semanticReviewShapeValid(candidate.validation.semantic_review);
1324
+ && semanticReviewShapeValid(candidate.validation.semantic_review)
1325
+ && historyValid
1326
+ && authoringValidationValid;
1162
1327
  const readinessValid = typeof candidate.ready_for_implementation === 'boolean';
1328
+ const preservedHistory = historyValid
1329
+ ? cloneJson(candidate.validation.semantic_review_history)
1330
+ : semanticReviewHistoryInitial(featureName, candidate.created_at);
1331
+ const preservedAuthoringValidation = authoringValidationValid
1332
+ ? cloneJson(candidate.validation.authoring_validation)
1333
+ : null;
1334
+ const freshValidation = {
1335
+ status: 'not-run',
1336
+ semantic_review: semanticReviewNotRun(),
1337
+ semantic_review_history: preservedHistory,
1338
+ authoring_validation: preservedAuthoringValidation,
1339
+ };
1163
1340
  const next = {
1164
1341
  schema_version: '2.1',
1165
1342
  feature_name: featureName,
@@ -1170,9 +1347,7 @@ function normalizeSpec21Candidate(candidate, evidence) {
1170
1347
  scope_lock: scopeLock,
1171
1348
  authoring,
1172
1349
  coordination: cloneJson(evidence.coordination),
1173
- validation: validationValid ? cloneJson(candidate.validation) : {
1174
- status: 'not-run', semantic_review: semanticReviewNotRun(),
1175
- },
1350
+ validation: validationValid ? cloneJson(candidate.validation) : freshValidation,
1176
1351
  semantic_model: isLegacy ? null : cloneJson(candidate.semantic_model),
1177
1352
  ready_for_implementation: readinessValid ? candidate.ready_for_implementation : false,
1178
1353
  workflow_policy: cloneJson(evidence.workflowPolicy),
@@ -1185,7 +1360,7 @@ function normalizeSpec21Candidate(candidate, evidence) {
1185
1360
  || isLegacy || JSON.stringify(next) !== JSON.stringify(candidate);
1186
1361
  if (semanticChanged || !validationValid || !readinessValid) {
1187
1362
  next.ready_for_implementation = false;
1188
- next.validation = { status: 'not-run', semantic_review: semanticReviewNotRun() };
1363
+ next.validation = freshValidation;
1189
1364
  next.semantic_model = null;
1190
1365
  }
1191
1366
  const changed = JSON.stringify(next) !== JSON.stringify(candidate);
@@ -1719,6 +1894,7 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
1719
1894
  const normalized = normalizeSpec21Candidate(nextSpec, {
1720
1895
  featureName: args.feature,
1721
1896
  timestamp: ts,
1897
+ specDir,
1722
1898
  requirementsExists: fs.existsSync(requirementsPath),
1723
1899
  designExists: fs.existsSync(designPath),
1724
1900
  researchExists: researchExists || researchTemplate !== null,
@@ -1775,11 +1951,16 @@ function syncSemanticModel({ workRoot, args, specDir, ts }) {
1775
1951
  try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
1776
1952
  catch (error) { failPrecondition(`--sync-semantic-model spec.json is invalid JSON (${error.message})`); }
1777
1953
  if (!isPlainObject(spec) || spec.schema_version !== '2.1') failPrecondition('--sync-semantic-model accepts canonical schema 2.1 only');
1778
- assertCanonicalSpecInputClosedWorld(spec);
1954
+ assertCanonicalSpecInputClosedWorld(spec, { allowLegacyReview: true });
1779
1955
  const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
1780
1956
  if (projection.errors.length) failPrecondition(`semantic projection is invalid (${projection.errors.join('; ')})`);
1781
1957
  const modelUnchanged = SEMANTIC.stableJson(spec.semantic_model) === SEMANTIC.stableJson(projection.model);
1782
- if (modelUnchanged) {
1958
+ const lifecycleCanonical = isPlainObject(spec.validation)
1959
+ && Object.keys(spec.validation).sort().join(',') === [...VALIDATION_FIELDS].sort().join(',')
1960
+ && semanticReviewShapeValid(spec.validation.semantic_review)
1961
+ && semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
1962
+ && authoringValidationShapeValid(spec.validation.authoring_validation);
1963
+ if (modelUnchanged && lifecycleCanonical) {
1783
1964
  console.log(`SEMANTIC_MODEL_UNCHANGED ${path.relative(process.cwd(), specDir) || specDir}`);
1784
1965
  return;
1785
1966
  }
@@ -1787,7 +1968,26 @@ function syncSemanticModel({ workRoot, args, specDir, ts }) {
1787
1968
  next.semantic_model = projection.model;
1788
1969
  next.updated_at = ts;
1789
1970
  next.ready_for_implementation = false;
1790
- next.validation = { status: 'not-run', semantic_review: semanticReviewNotRun() };
1971
+ if (!lifecycleCanonical) {
1972
+ for (const stage of ['requirements', 'design', 'research', 'tasks']) {
1973
+ if (next.authoring?.[stage] === 'validated') next.authoring[stage] = 'draft';
1974
+ }
1975
+ }
1976
+ // Only the ephemeral semantic_review pointer is invalidated by a projection
1977
+ // resync; semantic_review_history (C13) and authoring_validation (C16) are
1978
+ // durable records this command never writes and must carry forward as-is.
1979
+ next.validation = {
1980
+ status: 'not-run',
1981
+ semantic_review: semanticReviewNotRun(),
1982
+ semantic_review_history: hasOwn(spec.validation, 'semantic_review_history')
1983
+ && semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
1984
+ ? cloneJson(spec.validation.semantic_review_history)
1985
+ : semanticReviewHistoryInitial(args.feature, spec.created_at),
1986
+ authoring_validation: hasOwn(spec.validation, 'authoring_validation')
1987
+ && authoringValidationShapeValid(spec.validation.authoring_validation)
1988
+ ? cloneJson(spec.validation.authoring_validation)
1989
+ : null,
1990
+ };
1791
1991
  preflightSpec21Candidate(next, {
1792
1992
  featureName: args.feature,
1793
1993
  requirementsExists: fs.existsSync(path.join(specDir, 'requirements.md')),
@@ -1807,7 +2007,45 @@ function syncSemanticModel({ workRoot, args, specDir, ts }) {
1807
2007
  console.log(`SEMANTIC_MODEL_SYNCED ${path.relative(process.cwd(), specDir) || specDir}`);
1808
2008
  }
1809
2009
 
2010
+ // Version lives in a different file depending on where this script runs: an
2011
+ // installed adapter carries `<adapter>/cafekit.json`, the source tree carries
2012
+ // `packages/spec/package.json`. Read them with fs rather than require so the
2013
+ // runtime dependency closure — which may only copy files under src/claude —
2014
+ // stays satisfiable in an installed runtime.
2015
+ function readVersion() {
2016
+ const candidates = [
2017
+ path.resolve(__dirname, '..', 'cafekit.json'),
2018
+ path.resolve(__dirname, '..', '..', '..', 'package.json'),
2019
+ ];
2020
+ for (const candidate of candidates) {
2021
+ try {
2022
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
2023
+ if (typeof parsed.version === 'string' && parsed.version) return parsed.version;
2024
+ } catch {
2025
+ // try the next location
2026
+ }
2027
+ }
2028
+ return 'unknown';
2029
+ }
2030
+
2031
+ // --help/--version answer before any precondition runs: a caller asking what
2032
+ // this command is must not be told their help flag is an invalid feature name,
2033
+ // and must not trigger transaction recovery as a side effect.
2034
+ function respondToInfoFlags(argv) {
2035
+ const flags = argv.slice(2);
2036
+ if (flags.includes('--help') || flags.includes('-h')) {
2037
+ console.log(USAGE_TEXT);
2038
+ return true;
2039
+ }
2040
+ if (flags.includes('--version') || flags.includes('-v')) {
2041
+ console.log(readVersion());
2042
+ return true;
2043
+ }
2044
+ return false;
2045
+ }
2046
+
1810
2047
  function main() {
2048
+ if (respondToInfoFlags(process.argv)) return;
1811
2049
  const workRoot = path.resolve(process.cwd());
1812
2050
  const recoveredTransactions = recoverTransactions(workRoot);
1813
2051
  if (recoveredTransactions > 0) {
@@ -1986,6 +2224,8 @@ function main() {
1986
2224
  Object.assign(spec, POLICY.persistWorkflowPolicySnapshot(spec, policyInput));
1987
2225
  spec.validation = isPlainObject(spec.validation) ? spec.validation : {};
1988
2226
  spec.validation.semantic_review = semanticReviewNotRun();
2227
+ spec.validation.semantic_review_history = semanticReviewHistoryInitial(args.feature, ts);
2228
+ spec.validation.authoring_validation = null;
1989
2229
  if (args.research) {
1990
2230
  spec.research = 'research.md';
1991
2231
  spec.authoring.research = 'draft';