@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
@@ -13,21 +13,53 @@
13
13
  *
14
14
  * Usage:
15
15
  * node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug,..."] \
16
- * [--lane Standard|Critical] [--risks auth,privacy] \
16
+ * [--boundaries '[{"id":"B-OWN","type":"ownership",...}]'] \
17
+ * [--lane Standard|Critical] [--planning-depth None|Compact|Full] \
18
+ * [--assurance-level Routine|Elevated|Strict] [--research] [--risks auth,privacy] \
17
19
  * [--lang en] [--title "..."] [--specs-root specs] \
18
- * [--artifacts '{"R1-01-slug":["dist/output.js"]}']
20
+ * [--artifacts '{"R1-01-slug":["dist/output.js"]}'] \
21
+ * [--dependencies '{"R1-02-consumer":["R1-01-owner"]}']
19
22
  *
20
23
  * Exit: 0 = scaffolded, 2 = usage/precondition error.
21
24
  */
22
25
 
23
26
  const fs = require('fs');
24
27
  const path = require('path');
28
+ const crypto = require('crypto');
25
29
  const POLICY = require('./workflow-policy.cjs');
30
+ const SEMANTIC = require('./spec-semantic-model.cjs');
31
+ const AUTHORING_DIGEST = require('./spec-authoring-digest.cjs');
32
+ const PATH_SAFETY = require('../hooks/lib/runtime-path-safety.cjs');
26
33
 
27
34
  const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
28
- const TASK_ID_RE = /^R(\d+)-(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
35
+
36
+ function resolveTemplatePath(name) {
37
+ const adapterRoot = path.resolve(__dirname, '..');
38
+ const projectRoot = path.resolve(adapterRoot, '..');
39
+ const candidates = [
40
+ { file: path.join(TEMPLATES, name), root: adapterRoot },
41
+ // Packed Codex: <project>/.codex/scripts/spec-scaffold.cjs -> <project>/.agents/skills/specs/templates
42
+ {
43
+ file: path.resolve(__dirname, '..', '..', '.agents', 'skills', 'specs', 'templates', name),
44
+ root: projectRoot,
45
+ },
46
+ ];
47
+ for (const candidate of candidates) {
48
+ try {
49
+ return PATH_SAFETY.canonicalRegularFile(candidate.root, candidate.file, 'spec template');
50
+ } catch (e) {
51
+ if (e.code !== 'ENOENT' && !/cannot be inspected \(ENOENT\)/.test(e.message)) {
52
+ throw new Error(`template check failed: ${e.message} (${candidate.file})`);
53
+ }
54
+ continue;
55
+ }
56
+ }
57
+ throw new Error(`template not found: ${name} (checked ${candidates.map((item) => item.file).join(', ')})`);
58
+ }
59
+ const TASK_ID_RE = /^R(\d+)-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
29
60
  const TASK_PATH_RE = /^tasks\/task-(R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
30
61
  const FEATURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
62
+ const EXECUTION_CLOSURE_BLOCK_RE = /<!-- EXECUTION_CLOSURE_START -->[\s\S]*?<!-- EXECUTION_CLOSURE_END -->\n?/g;
31
63
  const REGISTRY_KEYS = [
32
64
  'id',
33
65
  'title',
@@ -38,11 +70,72 @@ const REGISTRY_KEYS = [
38
70
  'completed_at',
39
71
  'last_updated_at',
40
72
  ];
73
+ const AUTHORING_FIELDS = ['requirements', 'design', 'research', 'tasks'];
74
+ const AUTHORING_STATES = new Set(['draft', 'validated', 'absent']);
75
+ const SPEC_STATUS_VALUES = new Set(['in_progress', 'paused', 'blocked', 'done']);
76
+ const TASK_STATUS_VALUES = new Set(['pending', 'in_progress', 'blocked', 'done']);
77
+ const VALIDATION_STATUS_VALUES = new Set(['not-run', 'in_progress', 'completed']);
78
+ const CANONICAL_SPEC_FIELDS = new Set([
79
+ 'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
80
+ 'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
81
+ 'workflow_policy', 'research', 'task_files', 'task_registry', 'decisions',
82
+ ]);
83
+ const SCOPE_LOCK_FIELDS = ['source', 'in_scope', 'out_of_scope', 'expansion_policy'];
84
+ const COORDINATION_FIELDS = new Set(['boundaries', 'phases']);
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'];
108
+ const LEGACY_SPEC_FIELDS = [
109
+ ...CANONICAL_SPEC_FIELDS,
110
+ 'approvals', 'current_phase', 'override_receipt', 'timestamps',
111
+ ];
112
+ const LEGACY_COORDINATION_FIELDS = [
113
+ ...COORDINATION_FIELDS,
114
+ 'tasks_required', 'phases_required', 'reason', 'task_triggers',
115
+ ];
116
+ const LEGACY_TIMESTAMP_FIELDS = [
117
+ 'init', 'requirements_done', 'research_done', 'design_done', 'tasks_done',
118
+ 'code_done', 'test_done', 'review_done', 'validation_done',
119
+ ];
120
+ const LEGACY_APPROVAL_FIELDS = ['requirements', 'design', 'research', 'tasks'];
121
+ const LEGACY_APPROVAL_STATE_FIELDS = ['generated', 'agent_validated', 'user_approved'];
122
+ const LEGACY_POLICY_FIELDS = [
123
+ 'version', 'planning_depth', 'automatic_planning_depth', 'assurance_level',
124
+ 'automatic_assurance_level', 'lane', 'automatic_lane', 'risks', 'artifact_profile',
125
+ 'planning_obligations', 'proof_obligations', 'actor_needs', 'classified_minimum', 'override_receipt',
126
+ ];
127
+ const TRANSACTION_PREFIX = '.cafekit-scaffold-tx-';
128
+ const TRANSACTION_FORMAT = 'cafekit-spec-scaffold-transaction/1';
41
129
 
42
130
  class ScaffoldPreconditionError extends Error {}
43
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.
44
137
  function usage() {
45
- console.error('Usage: node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug"] [--lane Standard|Critical] [--risks auth,privacy] [--lang en] [--title "..."] [--specs-root specs] [--artifacts \'{"R1-01-slug":["dist/output.js"]}\']');
138
+ console.error(USAGE_TEXT);
46
139
  }
47
140
 
48
141
  function failPrecondition(message) {
@@ -53,17 +146,19 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
53
146
  const adapterDir = path.dirname(__dirname);
54
147
  const adapterName = path.basename(adapterDir);
55
148
  if (path.basename(__dirname) !== 'scripts' || (adapterName !== '.claude' && adapterName !== '.codex')) return;
56
-
57
149
  const statePath = path.join(adapterDir, 'hooks', 'completion-authority-state.cjs');
58
150
  const checkPath = path.join(adapterDir, 'hooks', 'completion-authority-check.cjs');
59
151
  if (![statePath, checkPath].every((filePath) => {
60
152
  try {
61
- return fs.statSync(filePath).isFile();
62
- } catch {
63
- return false;
64
- }
153
+ const stat = fs.lstatSync(filePath);
154
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
155
+ const real = fs.realpathSync(filePath);
156
+ const realDir = fs.realpathSync(path.dirname(filePath));
157
+ const rel = path.relative(realDir, real);
158
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
159
+ return true;
160
+ } catch { return false; }
65
161
  })) return;
66
-
67
162
  let installedRoot;
68
163
  let scaffoldCwd;
69
164
  let specFile;
@@ -78,54 +173,93 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
78
173
  } catch (error) {
79
174
  throw new Error(`installed ${adapterName} scaffold authority baseline could not be verified: ${error.message}`);
80
175
  }
81
-
82
176
  let STATE;
83
177
  let check;
178
+ let runtime = {};
84
179
  try {
85
- STATE = require(statePath);
86
- check = require(checkPath);
180
+ const realStatePath = fs.realpathSync(statePath);
181
+ const realCheckPath = fs.realpathSync(checkPath);
182
+ // Ensure real paths are still inside adapterDir's realpath (no symlink escape)
183
+ const realAdapterDir = fs.realpathSync(adapterDir);
184
+ const relState = path.relative(realAdapterDir, realStatePath);
185
+ const relCheck = path.relative(realAdapterDir, realCheckPath);
186
+ if (relState.startsWith('..') || path.isAbsolute(relState) || relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
187
+ throw new Error('scaffold authority path escapes adapter');
188
+ }
189
+ STATE = require(realStatePath);
190
+ check = require(realCheckPath);
191
+ const runtimePath = path.join(installedRoot, adapterName, 'runtime.json');
192
+ if (fs.existsSync(runtimePath)) runtime = JSON.parse(fs.readFileSync(runtimePath, 'utf8'));
87
193
  } catch (error) {
88
194
  throw new Error(`installed ${adapterName} scaffold authority hooks are unavailable: ${error.message}`);
89
195
  }
90
- if (typeof check.observePolicyBaseline !== 'function') {
91
- throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
92
- }
93
-
196
+ if (typeof check.observePolicyBaseline !== 'function') throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
94
197
  let result;
95
198
  try {
96
- result = check.observePolicyBaseline({
97
- STATE,
98
- policy: POLICY,
99
- projectRoot: installedRoot,
100
- candidate: { spec, specFile, featureName: args.feature },
101
- });
199
+ result = check.observePolicyBaseline({ STATE, policy: POLICY, projectRoot: installedRoot, candidate: { spec, specFile, featureName: args.feature }, runtime });
102
200
  } catch (error) {
103
201
  throw new Error(`installed ${adapterName} scaffold authority baseline failed: ${error.message}`);
104
202
  }
105
- if (!result || result.ok !== true) {
106
- throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
107
- }
203
+ if (!result || result.ok !== true) throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
108
204
  }
109
205
 
110
206
  function parseArgs(argv) {
111
- const a = { feature: null, tasks: null, lane: null, risks: null, lang: 'en', title: null, specsRoot: 'specs', tasksOnly: false, artifacts: null };
207
+ const a = {
208
+ feature: null,
209
+ tasks: null,
210
+ lane: null,
211
+ risks: null,
212
+ lang: 'en',
213
+ title: null,
214
+ specsRoot: 'specs',
215
+ tasksOnly: false,
216
+ artifacts: null,
217
+ dependencies: null,
218
+ planningDepth: null,
219
+ assuranceLevel: null,
220
+ coordination: false,
221
+ taskTriggers: null,
222
+ boundaries: null,
223
+ phases: null,
224
+ research: false,
225
+ uncertainty: null,
226
+ syncSemanticModel: false,
227
+ };
228
+ let dependenciesSeen = false;
112
229
  for (let i = 2; i < argv.length; i++) {
113
230
  const v = argv[i];
114
231
  if (v === '--tasks') a.tasks = argv[++i];
115
232
  else if (v === '--lane') a.lane = argv[++i];
233
+ else if (v === '--planning-depth') a.planningDepth = argv[++i];
234
+ else if (v === '--assurance-level') a.assuranceLevel = argv[++i];
235
+ else if (v === '--coordination') a.coordination = true;
236
+ else if (v === '--task-triggers') a.taskTriggers = argv[++i];
237
+ else if (v === '--boundaries') a.boundaries = argv[++i];
238
+ else if (v === '--phases') a.phases = argv[++i];
239
+ else if (v === '--research') a.research = true;
240
+ else if (v === '--uncertainty') a.uncertainty = argv[++i];
116
241
  else if (v === '--risks') a.risks = argv[++i];
117
242
  else if (v === '--lang') a.lang = argv[++i];
118
243
  else if (v === '--title') a.title = argv[++i];
119
244
  else if (v === '--specs-root') a.specsRoot = argv[++i];
120
245
  else if (v === '--tasks-only') a.tasksOnly = true;
246
+ else if (v === '--sync-semantic-model') a.syncSemanticModel = true;
121
247
  else if (v === '--artifacts') a.artifacts = argv[++i];
248
+ else if (v === '--dependencies') {
249
+ if (dependenciesSeen) failPrecondition('--dependencies may be provided only once');
250
+ dependenciesSeen = true;
251
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) {
252
+ failPrecondition('--dependencies requires one JSON object value');
253
+ }
254
+ a.dependencies = argv[++i];
255
+ }
122
256
  else if (!a.feature) a.feature = v;
123
257
  }
124
258
  return a;
125
259
  }
126
260
 
127
261
  function readTemplate(name) {
128
- const p = path.join(TEMPLATES, name);
262
+ const p = resolveTemplatePath(name);
129
263
  if (!fs.existsSync(p)) {
130
264
  failPrecondition(`template not found: ${p}`);
131
265
  }
@@ -137,12 +271,25 @@ function titleFromSlug(slug) {
137
271
  return s.charAt(0).toUpperCase() + s.slice(1);
138
272
  }
139
273
 
140
- function registryEntry(t, artifactDeclarations) {
274
+ function concreteUncertainty(value) {
275
+ return typeof value === 'string'
276
+ && value === value.trim()
277
+ && value.length >= 12
278
+ && !/^(?:tbd|todo|unknown|n\/a|none|placeholder)$/i.test(value);
279
+ }
280
+
281
+ function renderResearchTemplate(feature, uncertainty) {
282
+ return readTemplate('research.md')
283
+ .replace(/\{\{FEATURE_NAME\}\}/g, feature)
284
+ .replace(/\{\{UNCERTAINTY\}\}/g, uncertainty);
285
+ }
286
+
287
+ function registryEntry(t, artifactDeclarations, dependencies = []) {
141
288
  const entry = {
142
289
  id: `R${t.req}-${t.seq}`,
143
290
  title: titleFromSlug(t.slug),
144
291
  status: 'pending',
145
- dependencies: [],
292
+ dependencies: [...dependencies],
146
293
  blocker: null,
147
294
  started_at: null,
148
295
  completed_at: null,
@@ -153,6 +300,39 @@ function registryEntry(t, artifactDeclarations) {
153
300
  return entry;
154
301
  }
155
302
 
303
+ function pendingApproval() {
304
+ return 'draft';
305
+ }
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.
310
+ function semanticReviewNotRun() {
311
+ return {
312
+ status: 'not-run',
313
+ semantic_digest: null,
314
+ verdict: null,
315
+ lifecycle_disposition: null,
316
+ findings: [],
317
+ unresolved_decisions: [],
318
+ graph_coverage: [],
319
+ repair_round: 0,
320
+ reviewed_criteria: [],
321
+ counterexamples: [],
322
+ reviewer_evidence: null,
323
+ };
324
+ }
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
+
156
336
  function parseArtifactDeclarations(raw, parsedTasks) {
157
337
  if (raw === null || raw === undefined) return new Map();
158
338
  let declarations;
@@ -188,15 +368,344 @@ function parseArtifactDeclarations(raw, parsedTasks) {
188
368
  return result;
189
369
  }
190
370
 
191
- function fillTask(taskTpl, t, feature) {
192
- return taskTpl
371
+ function parseDependencyDeclarations(raw, parsedTasks, knownTaskFiles, existingRegistry = {}) {
372
+ if (raw === null || raw === undefined) return new Map();
373
+ let declarations;
374
+ try {
375
+ declarations = JSON.parse(raw);
376
+ } catch (error) {
377
+ failPrecondition(`--dependencies must be valid JSON (${error.message})`);
378
+ }
379
+ if (!isPlainObject(declarations)) {
380
+ failPrecondition('--dependencies must be an object keyed by requested task id or task path');
381
+ }
382
+
383
+ const requested = new Map();
384
+ for (const task of parsedTasks) {
385
+ requested.set(task.raw, task);
386
+ requested.set(task.file, task);
387
+ }
388
+ const known = new Map();
389
+ for (const taskPath of knownTaskFiles) {
390
+ const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
391
+ known.set(taskPath, taskPath);
392
+ known.set(identity.rawId, taskPath);
393
+ }
394
+
395
+ const result = new Map();
396
+ const declaredTargets = new Map();
397
+ for (const [key, dependencyRefs] of Object.entries(declarations)) {
398
+ const task = requested.get(key);
399
+ if (!task) failPrecondition(`--dependencies references unknown requested task "${key}"`);
400
+ const previousKey = declaredTargets.get(task.file);
401
+ if (previousKey) {
402
+ failPrecondition(
403
+ `--dependencies declares task "${task.file}" more than once after canonical resolution ` +
404
+ `("${previousKey}" and "${key}")`,
405
+ );
406
+ }
407
+ declaredTargets.set(task.file, key);
408
+ if (!Array.isArray(dependencyRefs)) {
409
+ failPrecondition(`--dependencies for "${key}" must be an array`);
410
+ }
411
+ const dependencies = [];
412
+ const seen = new Set();
413
+ for (const dependencyRef of dependencyRefs) {
414
+ if (typeof dependencyRef !== 'string') {
415
+ failPrecondition(`--dependencies for "${key}" must contain only task ids or task paths`);
416
+ }
417
+ const dependency = known.get(dependencyRef);
418
+ if (!dependency) {
419
+ failPrecondition(`--dependencies for "${key}" references unknown task "${dependencyRef}"`);
420
+ }
421
+ if (dependency === task.file) {
422
+ failPrecondition(`--dependencies for "${key}" cannot contain itself`);
423
+ }
424
+ if (seen.has(dependency)) {
425
+ failPrecondition(`--dependencies for "${key}" must not contain duplicate edges`);
426
+ }
427
+ seen.add(dependency);
428
+ dependencies.push(dependency);
429
+ }
430
+ result.set(task.file, dependencies);
431
+ }
432
+
433
+ const candidateRegistry = {};
434
+ for (const taskPath of knownTaskFiles) {
435
+ candidateRegistry[taskPath] = {
436
+ dependencies: [...(existingRegistry[taskPath]?.dependencies || [])],
437
+ };
438
+ }
439
+ for (const task of parsedTasks) {
440
+ if (!candidateRegistry[task.file]) candidateRegistry[task.file] = { dependencies: [] };
441
+ }
442
+ for (const [taskPath, dependencies] of result) {
443
+ candidateRegistry[taskPath].dependencies = dependencies;
444
+ }
445
+ validateDependencyCycles(knownTaskFiles, candidateRegistry);
446
+ return result;
447
+ }
448
+
449
+ function parsePhaseDeclarations(raw, knownTaskFiles) {
450
+ if (raw === null) return [];
451
+ let phases;
452
+ try {
453
+ phases = JSON.parse(raw);
454
+ } catch (error) {
455
+ failPrecondition(`--phases must be valid JSON (${error.message})`);
456
+ }
457
+ if (!Array.isArray(phases) || phases.length === 0) {
458
+ failPrecondition('--phases must be a non-empty JSON array');
459
+ }
460
+ const requiredKeys = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
461
+ const knownTaskIds = new Set(knownTaskFiles.map((taskPath) => parseTaskPath(taskPath, `known task ${taskPath}`).id));
462
+ const phaseIds = new Set();
463
+ return phases.map((phase, index) => {
464
+ const label = `--phases[${index}]`;
465
+ if (!isPlainObject(phase) || JSON.stringify(Object.keys(phase).sort()) !== JSON.stringify(requiredKeys)) {
466
+ failPrecondition(`${label} must contain exactly ${requiredKeys.join(', ')}`);
467
+ }
468
+ for (const key of ['id', 'entry_condition', 'exit_condition', 'owner_boundary']) {
469
+ if (typeof phase[key] !== 'string' || phase[key].trim() === '' || phase[key] !== phase[key].trim()) {
470
+ failPrecondition(`${label}.${key} must be a non-empty trimmed string`);
471
+ }
472
+ }
473
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phase.id)) {
474
+ failPrecondition(`${label}.id must be one safe identifier`);
475
+ }
476
+ if (phaseIds.has(phase.id)) failPrecondition(`--phases contains duplicate id ${phase.id}`);
477
+ phaseIds.add(phase.id);
478
+ if (!Array.isArray(phase.task_ids) || phase.task_ids.length === 0) {
479
+ failPrecondition(`${label}.task_ids must be a non-empty array`);
480
+ }
481
+ const taskIds = new Set();
482
+ for (const taskId of phase.task_ids) {
483
+ if (typeof taskId !== 'string' || !knownTaskIds.has(taskId)) {
484
+ failPrecondition(`${label}.task_ids references unknown task ${String(taskId)}`);
485
+ }
486
+ if (taskIds.has(taskId)) failPrecondition(`${label}.task_ids must not contain duplicate task ids`);
487
+ taskIds.add(taskId);
488
+ }
489
+ return cloneJson(phase);
490
+ });
491
+ }
492
+
493
+ const BOUNDARY_FIELDS = Object.freeze({
494
+ ownership: ['id', 'type', 'tasks', 'write_sets'],
495
+ dependency: ['id', 'type', 'producer', 'consumer', 'deliverable'],
496
+ transition: ['id', 'type', 'design_ref', 'owner', 'consumers', 'precondition', 'postcondition', 'failure', 'recovery'],
497
+ proof: ['id', 'type', 'subject', 'verifier', 'verification_ref', 'artifact_anchor'],
498
+ parallel: ['id', 'type', 'tasks', 'resources'],
499
+ });
500
+
501
+ function exactTarget(value, label) {
502
+ safeRelativePath(value, label);
503
+ if (/[*?{}[\]]/.test(value) || /[\\/]$/.test(value)) {
504
+ failPrecondition(`${label} must be one exact grounded target, not a glob or parent directory`);
505
+ }
506
+ return value;
507
+ }
508
+
509
+ function targetsContend(left, right) {
510
+ const a = left.replace(/\\/g, '/').replace(/\/+$/, '');
511
+ const b = right.replace(/\\/g, '/').replace(/\/+$/, '');
512
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
513
+ }
514
+
515
+ function boundaryTaskList(value, label, knownTaskIds, minimum = 1) {
516
+ if (!Array.isArray(value) || value.length < minimum) {
517
+ failPrecondition(`${label} must contain at least ${minimum} task id(s)`);
518
+ }
519
+ const seen = new Set();
520
+ for (const taskId of value) {
521
+ if (!knownTaskIds.has(taskId)) failPrecondition(`${label} references unknown task ${String(taskId)}`);
522
+ if (seen.has(taskId)) failPrecondition(`${label} must not contain duplicate task ids`);
523
+ seen.add(taskId);
524
+ }
525
+ return [...value];
526
+ }
527
+
528
+ function boundaryTargetMap(value, tasks, label) {
529
+ if (!isPlainObject(value) || JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...tasks].sort())) {
530
+ failPrecondition(`${label} must be an object keyed exactly by its task ids`);
531
+ }
532
+ const result = {};
533
+ for (const taskId of tasks) {
534
+ if (!Array.isArray(value[taskId]) || value[taskId].length === 0) {
535
+ failPrecondition(`${label}.${taskId} must be a non-empty exact target array`);
536
+ }
537
+ result[taskId] = value[taskId].map((target, index) => exactTarget(target, `${label}.${taskId}[${index}]`));
538
+ if (new Set(result[taskId]).size !== result[taskId].length) {
539
+ failPrecondition(`${label}.${taskId} must not contain duplicate targets`);
540
+ }
541
+ }
542
+ for (let left = 0; left < tasks.length; left += 1) {
543
+ for (let right = left + 1; right < tasks.length; right += 1) {
544
+ for (const a of result[tasks[left]]) {
545
+ for (const b of result[tasks[right]]) {
546
+ if (targetsContend(a, b)) {
547
+ failPrecondition(`${label} has resource contention between ${tasks[left]}:${a} and ${tasks[right]}:${b}`);
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ return result;
554
+ }
555
+
556
+ function parseBoundaryDeclarations(raw, knownTaskFiles) {
557
+ if (raw === null || raw === undefined) return [];
558
+ let boundaries;
559
+ try { boundaries = JSON.parse(raw); } catch (error) {
560
+ failPrecondition(`--boundaries must be valid JSON (${error.message})`);
561
+ }
562
+ if (!Array.isArray(boundaries) || boundaries.length === 0) {
563
+ failPrecondition('--boundaries must be a non-empty JSON array');
564
+ }
565
+ const taskPathById = new Map(knownTaskFiles.map((taskPath) => {
566
+ const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
567
+ return [identity.id, taskPath];
568
+ }));
569
+ const knownTaskIds = new Set(taskPathById.keys());
570
+ const ids = new Set();
571
+ const result = boundaries.map((boundary, index) => {
572
+ const label = `--boundaries[${index}]`;
573
+ if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
574
+ failPrecondition(`${label}.type must be ownership, dependency, transition, proof, or parallel`);
575
+ }
576
+ if (JSON.stringify(Object.keys(boundary).sort()) !== JSON.stringify([...BOUNDARY_FIELDS[boundary.type]].sort())) {
577
+ failPrecondition(`${label} ${boundary.type} fields must be exactly ${BOUNDARY_FIELDS[boundary.type].join(', ')}`);
578
+ }
579
+ if (typeof boundary.id !== 'string' || !/^B-[A-Z0-9][A-Z0-9._-]*$/i.test(boundary.id)) {
580
+ failPrecondition(`${label}.id must be a safe B-* identifier`);
581
+ }
582
+ if (ids.has(boundary.id)) failPrecondition(`--boundaries contains duplicate id ${boundary.id}`);
583
+ ids.add(boundary.id);
584
+ if (boundary.type === 'ownership') {
585
+ const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
586
+ boundaryTargetMap(boundary.write_sets, tasks, `${label}.write_sets`);
587
+ } else if (boundary.type === 'dependency') {
588
+ boundaryTaskList([boundary.producer, boundary.consumer], label, knownTaskIds, 2);
589
+ if (boundary.producer === boundary.consumer) failPrecondition(`${label} producer and consumer must differ`);
590
+ exactTarget(boundary.deliverable, `${label}.deliverable`);
591
+ } else if (boundary.type === 'transition') {
592
+ boundaryTaskList([boundary.owner], `${label}.owner`, knownTaskIds);
593
+ boundaryTaskList(boundary.consumers, `${label}.consumers`, knownTaskIds);
594
+ if (boundary.consumers.includes(boundary.owner)) failPrecondition(`${label}.consumers cannot include transition owner`);
595
+ if (!/^T\d+(?:\.\d+)?$/i.test(boundary.design_ref)) failPrecondition(`${label}.design_ref must be a T id`);
596
+ for (const field of ['precondition', 'postcondition', 'failure', 'recovery']) {
597
+ if (typeof boundary[field] !== 'string' || boundary[field].trim().length < 8) {
598
+ failPrecondition(`${label}.${field} must be a concrete definition`);
599
+ }
600
+ }
601
+ } else if (boundary.type === 'proof') {
602
+ boundaryTaskList([boundary.subject, boundary.verifier], label, knownTaskIds, 2);
603
+ if (boundary.subject === boundary.verifier) failPrecondition(`${label} subject and verifier must differ`);
604
+ if (!/^V\d+(?:\.\d+)?$/i.test(boundary.verification_ref)) failPrecondition(`${label}.verification_ref must be a V id`);
605
+ if (!new RegExp(`^A-${boundary.verifier}-\\d{2}$`).test(boundary.artifact_anchor)) {
606
+ failPrecondition(`${label}.artifact_anchor must be owned by verifier ${boundary.verifier}`);
607
+ }
608
+ } else if (boundary.type === 'parallel') {
609
+ const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
610
+ boundaryTargetMap(boundary.resources, tasks, `${label}.resources`);
611
+ }
612
+ return cloneJson(boundary);
613
+ });
614
+
615
+ const dependencies = result.filter((boundary) => boundary.type === 'dependency');
616
+ const graph = new Map([...knownTaskIds].map((taskId) => [taskId, []]));
617
+ for (const boundary of dependencies) graph.get(boundary.producer).push(boundary.consumer);
618
+ const visiting = new Set();
619
+ const visited = new Set();
620
+ const visit = (taskId, stack = []) => {
621
+ if (visiting.has(taskId)) {
622
+ const start = stack.indexOf(taskId);
623
+ failPrecondition(`coordination.boundaries dependency cycle: ${stack.slice(start).concat(taskId).join(' -> ')}`);
624
+ }
625
+ if (visited.has(taskId)) return;
626
+ visiting.add(taskId);
627
+ for (const consumer of graph.get(taskId)) visit(consumer, [...stack, taskId]);
628
+ visiting.delete(taskId);
629
+ visited.add(taskId);
630
+ };
631
+ for (const taskId of knownTaskIds) visit(taskId);
632
+ const pathExists = (from, to, seen = new Set()) => {
633
+ if (from === to) return true;
634
+ if (seen.has(from)) return false;
635
+ seen.add(from);
636
+ return graph.get(from).some((next) => pathExists(next, to, seen));
637
+ };
638
+ for (const boundary of result.filter((candidate) => candidate.type === 'parallel')) {
639
+ for (let left = 0; left < boundary.tasks.length; left += 1) {
640
+ for (let right = left + 1; right < boundary.tasks.length; right += 1) {
641
+ if (pathExists(boundary.tasks[left], boundary.tasks[right])
642
+ || pathExists(boundary.tasks[right], boundary.tasks[left])) {
643
+ failPrecondition(`${boundary.id}: parallel tasks must have no dependency path`);
644
+ }
645
+ }
646
+ }
647
+ const authorities = result.filter((candidate) => ['transition', 'proof'].includes(candidate.type));
648
+ for (const authority of authorities) {
649
+ const owners = authority.type === 'transition'
650
+ ? [authority.owner, ...authority.consumers]
651
+ : [authority.subject, authority.verifier];
652
+ if (boundary.tasks.filter((taskId) => owners.includes(taskId)).length > 1) {
653
+ failPrecondition(`${boundary.id}: parallel tasks cannot share ${authority.type} authority ${authority.id}`);
654
+ }
655
+ }
656
+ }
657
+ return result;
658
+ }
659
+
660
+ function dependenciesFromBoundaries(boundaries, knownTaskFiles) {
661
+ const pathById = new Map(knownTaskFiles.map((taskPath) => [parseTaskPath(taskPath, taskPath).id, taskPath]));
662
+ const result = new Map(knownTaskFiles.map((taskPath) => [taskPath, []]));
663
+ for (const boundary of boundaries) {
664
+ if (boundary.type !== 'dependency') continue;
665
+ result.get(pathById.get(boundary.consumer)).push(pathById.get(boundary.producer));
666
+ }
667
+ for (const [taskPath, dependencies] of result) result.set(taskPath, [...new Set(dependencies)].sort());
668
+ return result;
669
+ }
670
+
671
+ function validatePhaseBoundaryRefs(phases, boundaries) {
672
+ const boundaryIds = new Set(boundaries.map((boundary) => boundary.id));
673
+ for (const phase of phases) {
674
+ if (!boundaryIds.has(phase.owner_boundary)) {
675
+ failPrecondition(`phase ${phase.id}.owner_boundary references unknown boundary ${phase.owner_boundary}`);
676
+ }
677
+ }
678
+ }
679
+
680
+ function applyExecutionClosureProfile(content, includeExecutionClosure) {
681
+ if (!includeExecutionClosure) return content.replace(EXECUTION_CLOSURE_BLOCK_RE, '');
682
+ return content
683
+ .replace('<!-- EXECUTION_CLOSURE_START -->\n', '')
684
+ .replace('<!-- EXECUTION_CLOSURE_END -->\n', '');
685
+ }
686
+
687
+ function declaredImplementationObligations(spec) {
688
+ return Array.isArray(spec?.implementation_obligations)
689
+ ? spec.implementation_obligations.filter(isPlainObject)
690
+ : [];
691
+ }
692
+
693
+ function taskHasDeclaredImplementationObligation(spec, taskPath) {
694
+ return declaredImplementationObligations(spec).some((obligation) => (
695
+ obligation.owner_task === taskPath
696
+ || (Array.isArray(obligation.consumer_tasks) && obligation.consumer_tasks.includes(taskPath))
697
+ ));
698
+ }
699
+
700
+ function fillTask(taskTpl, t, feature, dependencies = [], includeExecutionClosure = false) {
701
+ return applyExecutionClosureProfile(taskTpl, includeExecutionClosure)
193
702
  .replace(/\{\{REQ_NUMBER\}\}/g, t.req)
194
703
  .replace(/\{\{SEQ\}\}/g, t.seq)
195
704
  .replace(/\{\{TITLE\}\}/g, titleFromSlug(t.slug))
196
705
  .replace(/\{\{FEATURE_NAME\}\}/g, feature)
197
706
  .replace(/\{\{PRIORITY\}\}/g, 'P2')
198
707
  .replace(/\{\{EFFORT\}\}/g, 'TBD')
199
- .replace(/\{\{DEPENDENCIES\}\}/g, 'none');
708
+ .replace(/\{\{DEPENDENCIES\}\}/g, dependencies.length > 0 ? dependencies.join(', ') : 'none');
200
709
  }
201
710
 
202
711
  function parseRiskNames(raw) {
@@ -236,6 +745,199 @@ function cloneJson(value) {
236
745
  return JSON.parse(JSON.stringify(value));
237
746
  }
238
747
 
748
+ function assertExactKeys(value, expected, label) {
749
+ if (!isPlainObject(value)) failPrecondition(`${label} must be an object`);
750
+ const actual = Object.keys(value).sort();
751
+ const wanted = [...expected].sort();
752
+ if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
753
+ const unknown = actual.filter((key) => !wanted.includes(key));
754
+ const missing = wanted.filter((key) => !actual.includes(key));
755
+ failPrecondition(
756
+ `${label} must be closed-world; unknown=[${unknown.join(', ')}], missing=[${missing.join(', ')}]`,
757
+ );
758
+ }
759
+ }
760
+
761
+ function canonicalRegistryFields(entry) {
762
+ return hasOwn(entry, 'artifacts') ? [...REGISTRY_KEYS, 'artifacts'] : REGISTRY_KEYS;
763
+ }
764
+
765
+ function assertCanonicalSpecInputClosedWorld(spec, { allowLegacyReview = false } = {}) {
766
+ if (!isPlainObject(spec) || spec.schema_version !== '2.1') return;
767
+ const topLevel = [...CANONICAL_SPEC_FIELDS].filter((field) => {
768
+ if (field === 'research') return hasOwn(spec, 'research');
769
+ if (field === 'task_files' || field === 'task_registry') return hasOwn(spec, field);
770
+ if (field === 'decisions') return hasOwn(spec, 'decisions');
771
+ return true;
772
+ });
773
+ assertExactKeys(spec, topLevel, 'canonical schema 2.1 spec.json');
774
+ assertExactKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'canonical schema 2.1 scope_lock');
775
+ assertExactKeys(spec.authoring, AUTHORING_FIELDS, 'canonical schema 2.1 authoring');
776
+ const coordinationFields = hasOwn(spec.coordination, 'phases')
777
+ ? [...COORDINATION_FIELDS] : ['boundaries'];
778
+ assertExactKeys(spec.coordination, coordinationFields, 'canonical schema 2.1 coordination');
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');
799
+ assertExactKeys(
800
+ spec.validation.semantic_review,
801
+ legacyReview ? legacyReviewFields : SEMANTIC_REVIEW_FIELDS,
802
+ 'canonical schema 2.1 validation.semantic_review',
803
+ );
804
+ if (Array.isArray(spec.validation.semantic_review?.counterexamples)) {
805
+ const fields = ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'];
806
+ for (const [index, counterexample] of spec.validation.semantic_review.counterexamples.entries()) {
807
+ assertExactKeys(
808
+ counterexample,
809
+ fields,
810
+ `canonical schema 2.1 validation.semantic_review.counterexamples[${index}]`,
811
+ );
812
+ }
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
+ }
846
+ if (hasOwn(spec, 'decisions')) {
847
+ if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) failPrecondition('canonical schema 2.1 decisions must be omitted or a non-empty array');
848
+ for (const [index, decision] of spec.decisions.entries()) {
849
+ assertExactKeys(decision, ['id', 'classification', 'statement', 'status', 'evidence'], `canonical schema 2.1 decisions[${index}]`);
850
+ }
851
+ }
852
+ if (Array.isArray(spec.coordination?.boundaries)) {
853
+ for (const [index, boundary] of spec.coordination.boundaries.entries()) {
854
+ if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
855
+ failPrecondition(`canonical schema 2.1 coordination.boundaries[${index}] has an unknown type`);
856
+ }
857
+ assertExactKeys(
858
+ boundary,
859
+ BOUNDARY_FIELDS[boundary.type],
860
+ `canonical schema 2.1 coordination.boundaries[${index}]`,
861
+ );
862
+ }
863
+ }
864
+ if (Array.isArray(spec.coordination?.phases)) {
865
+ const fields = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
866
+ for (const [index, phase] of spec.coordination.phases.entries()) {
867
+ assertExactKeys(phase, fields, `canonical schema 2.1 coordination.phases[${index}]`);
868
+ }
869
+ }
870
+ if (hasOwn(spec, 'task_registry') && isPlainObject(spec.task_registry)) {
871
+ for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
872
+ assertExactKeys(entry, canonicalRegistryFields(entry), `canonical schema 2.1 task_registry.${taskPath}`);
873
+ }
874
+ }
875
+ }
876
+
877
+ function rejectUnknownKeys(value, allowed, label) {
878
+ if (!isPlainObject(value)) return;
879
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
880
+ if (unknown.length) {
881
+ failPrecondition(`${label} contains unsupported legacy authority field(s): ${unknown.join(', ')}; remove or explicitly migrate them before schema 2.1 promotion`);
882
+ }
883
+ }
884
+
885
+ function assertLegacyMigrationClosedWorld(spec) {
886
+ rejectUnknownKeys(spec, LEGACY_SPEC_FIELDS, 'schema 2.0 spec.json');
887
+ rejectUnknownKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'schema 2.0 scope_lock');
888
+ rejectUnknownKeys(spec.authoring, AUTHORING_FIELDS, 'schema 2.0 authoring');
889
+ rejectUnknownKeys(spec.coordination, LEGACY_COORDINATION_FIELDS, 'schema 2.0 coordination');
890
+ rejectUnknownKeys(spec.validation, LEGACY_VALIDATION_FIELDS, 'schema 2.0 validation');
891
+ rejectUnknownKeys(spec.validation?.semantic_review, LEGACY_SEMANTIC_REVIEW_FIELDS, 'schema 2.0 validation.semantic_review');
892
+ for (const [index, counterexample] of (spec.validation?.semantic_review?.counterexamples || []).entries()) {
893
+ rejectUnknownKeys(counterexample, ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'], `schema 2.0 semantic_review.counterexamples[${index}]`);
894
+ }
895
+ for (const [index, boundary] of (spec.coordination?.boundaries || []).entries()) {
896
+ if (!isPlainObject(boundary) || !hasOwn(BOUNDARY_FIELDS, boundary.type)) {
897
+ failPrecondition(`schema 2.0 coordination.boundaries[${index}] has an unknown authority type`);
898
+ }
899
+ rejectUnknownKeys(boundary, BOUNDARY_FIELDS[boundary.type], `schema 2.0 coordination.boundaries[${index}]`);
900
+ }
901
+ if (isPlainObject(spec.workflow_policy)) {
902
+ rejectUnknownKeys(spec.workflow_policy, LEGACY_POLICY_FIELDS, 'schema 2.0 workflow_policy');
903
+ }
904
+ if (hasOwn(spec, 'current_phase')
905
+ && (typeof spec.current_phase !== 'string' || spec.current_phase.trim() === '')) {
906
+ failPrecondition('schema 2.0 current_phase must be a non-empty string when present');
907
+ }
908
+ if (hasOwn(spec, 'timestamps')) {
909
+ if (!isPlainObject(spec.timestamps)) failPrecondition('schema 2.0 timestamps must be an object');
910
+ rejectUnknownKeys(spec.timestamps, LEGACY_TIMESTAMP_FIELDS, 'schema 2.0 timestamps');
911
+ for (const [field, value] of Object.entries(spec.timestamps)) {
912
+ if (value !== null && !isIsoTimestamp(value)) {
913
+ failPrecondition(`schema 2.0 timestamps.${field} must be null or a valid ISO 8601 timestamp`);
914
+ }
915
+ }
916
+ }
917
+ if (hasOwn(spec, 'approvals')) {
918
+ if (!isPlainObject(spec.approvals)) failPrecondition('schema 2.0 approvals must be an object');
919
+ rejectUnknownKeys(spec.approvals, LEGACY_APPROVAL_FIELDS, 'schema 2.0 approvals');
920
+ for (const [section, state] of Object.entries(spec.approvals)) {
921
+ if (!isPlainObject(state)) failPrecondition(`schema 2.0 approvals.${section} must be an object`);
922
+ rejectUnknownKeys(state, LEGACY_APPROVAL_STATE_FIELDS, `schema 2.0 approvals.${section}`);
923
+ for (const [field, value] of Object.entries(state)) {
924
+ if (typeof value !== 'boolean') {
925
+ failPrecondition(`schema 2.0 approvals.${section}.${field} must be boolean`);
926
+ }
927
+ }
928
+ }
929
+ }
930
+ if (isPlainObject(spec.task_registry)) for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
931
+ rejectUnknownKeys(entry, canonicalRegistryFields(entry), `schema 2.0 task_registry.${taskPath}`);
932
+ }
933
+ }
934
+
935
+ function isIsoTimestamp(value) {
936
+ return typeof value === 'string'
937
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
938
+ && Number.isFinite(Date.parse(value));
939
+ }
940
+
239
941
  function isWithin(root, target) {
240
942
  const relative = path.relative(path.resolve(root), path.resolve(target));
241
943
  return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
@@ -339,7 +1041,9 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
339
1041
  const identity = parseTaskPath(taskPath, `task_registry.${taskPath}`);
340
1042
  if (entry.id !== identity.id) failPrecondition(`task_registry.${taskPath}.id conflicts with its task path`);
341
1043
  if (typeof entry.title !== 'string' || entry.title.trim() === '') failPrecondition(`task_registry.${taskPath}.title must be a non-empty string`);
342
- if (typeof entry.status !== 'string' || entry.status.trim() === '') failPrecondition(`task_registry.${taskPath}.status must be a non-empty string`);
1044
+ if (!TASK_STATUS_VALUES.has(entry.status)) {
1045
+ failPrecondition(`task_registry.${taskPath}.status must be one of ${[...TASK_STATUS_VALUES].join(', ')}`);
1046
+ }
343
1047
  if (!Array.isArray(entry.dependencies)) failPrecondition(`task_registry.${taskPath}.dependencies must be an array`);
344
1048
  const dependencies = new Set();
345
1049
  for (const dependency of entry.dependencies) {
@@ -350,6 +1054,322 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
350
1054
  dependencies.add(dependency);
351
1055
  }
352
1056
  if (hasOwn(entry, 'artifacts')) validateArtifactList(entry.artifacts, `task_registry.${taskPath}.artifacts`);
1057
+ const label = `task_registry.${taskPath}`;
1058
+ if (entry.blocker !== null && (typeof entry.blocker !== 'string' || entry.blocker.trim() === '')) {
1059
+ failPrecondition(`${label}.blocker must be null or a non-empty string`);
1060
+ }
1061
+ for (const field of ['started_at', 'completed_at', 'last_updated_at']) {
1062
+ if (entry[field] !== null && !isIsoTimestamp(entry[field])) {
1063
+ failPrecondition(`${label}.${field} must be null or an ISO 8601 timestamp`);
1064
+ }
1065
+ }
1066
+ if (entry.status === 'pending') {
1067
+ if (entry.blocker !== null || entry.started_at !== null || entry.completed_at !== null) {
1068
+ failPrecondition(`${label} pending lifecycle is inconsistent`);
1069
+ }
1070
+ } else if (entry.status === 'in_progress') {
1071
+ if (entry.blocker !== null || !isIsoTimestamp(entry.started_at)
1072
+ || entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
1073
+ || Date.parse(entry.last_updated_at) < Date.parse(entry.started_at)) {
1074
+ failPrecondition(`${label} in_progress lifecycle is inconsistent`);
1075
+ }
1076
+ } else if (entry.status === 'blocked') {
1077
+ if (typeof entry.blocker !== 'string' || entry.blocker.trim() === ''
1078
+ || entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
1079
+ || (isIsoTimestamp(entry.started_at) && Date.parse(entry.last_updated_at) < Date.parse(entry.started_at))) {
1080
+ failPrecondition(`${label} blocked lifecycle is inconsistent`);
1081
+ }
1082
+ } else if (entry.status === 'done') {
1083
+ if (entry.blocker !== null
1084
+ || ![entry.started_at, entry.completed_at, entry.last_updated_at].every(isIsoTimestamp)
1085
+ || Date.parse(entry.completed_at) < Date.parse(entry.started_at)
1086
+ || Date.parse(entry.last_updated_at) < Date.parse(entry.completed_at)) {
1087
+ failPrecondition(`${label} done lifecycle is inconsistent`);
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ function semanticReviewShapeValid(review) {
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(','));
1123
+ }
1124
+
1125
+ function preflightSpec21Candidate(spec, evidence) {
1126
+ if (!isPlainObject(spec)) failPrecondition('schema 2.1 candidate must be an object');
1127
+ assertCanonicalSpecInputClosedWorld(spec);
1128
+ if (hasOwn(spec, 'override_receipt')) {
1129
+ failPrecondition('schema 2.1 candidate override_receipt is unsupported; omit the field');
1130
+ }
1131
+ const required = [
1132
+ 'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
1133
+ 'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
1134
+ 'workflow_policy',
1135
+ ];
1136
+ for (const field of required) if (!hasOwn(spec, field)) {
1137
+ failPrecondition(`schema 2.1 candidate is missing required machine field ${field}`);
1138
+ }
1139
+ if (spec.schema_version !== '2.1') failPrecondition('schema 2.1 candidate has an unsupported schema_version');
1140
+ if (spec.feature_name !== evidence.featureName) failPrecondition('feature_name conflicts with the scaffold feature identity');
1141
+ if (!isIsoTimestamp(spec.created_at) || !isIsoTimestamp(spec.updated_at)
1142
+ || Date.parse(spec.updated_at) < Date.parse(spec.created_at)) {
1143
+ failPrecondition('schema 2.1 candidate lifecycle timestamps are invalid');
1144
+ }
1145
+ if (typeof spec.language !== 'string' || spec.language.trim() === '') failPrecondition('schema 2.1 candidate language must be non-empty');
1146
+ if (!SPEC_STATUS_VALUES.has(spec.status)) failPrecondition('schema 2.1 candidate status is invalid');
1147
+ if (!isPlainObject(spec.scope_lock)) failPrecondition('schema 2.1 candidate scope_lock must be an object');
1148
+ if (!isPlainObject(spec.authoring)
1149
+ || Object.keys(spec.authoring).sort().join(',') !== [...AUTHORING_FIELDS].sort().join(',')) {
1150
+ failPrecondition('schema 2.1 candidate authoring must contain exactly requirements, design, research, and tasks');
1151
+ }
1152
+ for (const field of AUTHORING_FIELDS) if (!AUTHORING_STATES.has(spec.authoring[field])) {
1153
+ failPrecondition(`schema 2.1 candidate authoring.${field} is invalid`);
1154
+ }
1155
+ if (!evidence.requirementsExists || !evidence.designExists) {
1156
+ failPrecondition('schema 2.1 migration requires physical requirements.md and design.md files');
1157
+ }
1158
+ if (spec.authoring.requirements === 'absent' || spec.authoring.design === 'absent') {
1159
+ failPrecondition('physical requirements.md and design.md cannot be marked absent');
1160
+ }
1161
+ if (evidence.researchExists) {
1162
+ if (spec.research !== 'research.md' || spec.authoring.research === 'absent') {
1163
+ failPrecondition('research pointer, authoring state, and physical research.md must agree');
1164
+ }
1165
+ } else if (hasOwn(spec, 'research') || spec.authoring.research !== 'absent') {
1166
+ failPrecondition('absent physical research.md requires no pointer and authoring.research absent');
1167
+ }
1168
+ if (!isPlainObject(spec.coordination) || !Array.isArray(spec.coordination.boundaries)) {
1169
+ failPrecondition('schema 2.1 candidate coordination.boundaries must be an array');
1170
+ }
1171
+ if (!isPlainObject(spec.validation) || !VALIDATION_STATUS_VALUES.has(spec.validation.status)
1172
+ || !semanticReviewShapeValid(spec.validation.semantic_review)
1173
+ || !semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
1174
+ || !authoringValidationShapeValid(spec.validation.authoring_validation)) {
1175
+ failPrecondition('schema 2.1 candidate validation state is invalid');
1176
+ }
1177
+ if (typeof spec.ready_for_implementation !== 'boolean') failPrecondition('schema 2.1 candidate readiness must be boolean');
1178
+ if (spec.semantic_model !== null) {
1179
+ const semanticErrors = SEMANTIC.validateSemanticModel(spec.semantic_model, spec);
1180
+ if (semanticErrors.length) failPrecondition(`schema 2.1 candidate semantic_model is invalid (${semanticErrors.join('; ')})`);
1181
+ } else if (spec.ready_for_implementation === true) {
1182
+ failPrecondition('schema 2.1 readiness requires a promoted semantic_model');
1183
+ }
1184
+ if (isPlainObject(spec.workflow_policy) && hasOwn(spec.workflow_policy, 'override_receipt')) {
1185
+ failPrecondition('schema 2.1 candidate workflow_policy.override_receipt is unsupported; omit the field');
1186
+ }
1187
+ const policyValidation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
1188
+ if (!policyValidation.valid || spec.workflow_policy.version !== '2.1') {
1189
+ failPrecondition(`schema 2.1 candidate workflow_policy is invalid (${policyValidation.errors.join('; ')})`);
1190
+ }
1191
+ const taskFiles = validateTaskPathList(spec.task_files || [], 'task_files');
1192
+ if (JSON.stringify(taskFiles) !== JSON.stringify([...evidence.taskFiles].sort())) {
1193
+ failPrecondition('schema 2.1 candidate task_files do not match the resulting physical topology');
1194
+ }
1195
+ if (taskFiles.length === 0) {
1196
+ if (hasOwn(spec, 'task_files') || hasOwn(spec, 'task_registry') || spec.authoring.tasks !== 'absent') {
1197
+ failPrecondition('taskless schema 2.1 candidate must omit task topology and mark tasks absent');
1198
+ }
1199
+ } else {
1200
+ if (!isPlainObject(spec.task_registry)
1201
+ || Object.keys(spec.task_registry).sort().join(',') !== taskFiles.join(',')) {
1202
+ failPrecondition('schema 2.1 candidate task_registry must exactly match task_files');
1203
+ }
1204
+ if (!['draft', 'validated'].includes(spec.authoring.tasks)) {
1205
+ failPrecondition('physical tasks require draft or validated authoring state');
1206
+ }
1207
+ const declared = new Set(taskFiles);
1208
+ for (const taskPath of taskFiles) validateRegistryEntry(taskPath, spec.task_registry[taskPath], declared);
1209
+ validateDependencyCycles(taskFiles, spec.task_registry);
1210
+ }
1211
+ }
1212
+
1213
+ function normalizeSpec21Candidate(candidate, evidence) {
1214
+ try {
1215
+ if (!isPlainObject(candidate)) failPrecondition('migration candidate must be an object');
1216
+ if (!['2.0', '2.1'].includes(candidate.schema_version)) {
1217
+ failPrecondition('migration candidate schema_version must be exactly 2.0 or 2.1');
1218
+ }
1219
+ const isLegacy = candidate.schema_version === '2.0';
1220
+ if (isLegacy) assertLegacyMigrationClosedWorld(candidate);
1221
+ if (!isLegacy) assertCanonicalSpecInputClosedWorld(candidate);
1222
+ const featureName = !hasOwn(candidate, 'feature_name') && isLegacy
1223
+ ? evidence.featureName : candidate.feature_name;
1224
+ if (typeof featureName !== 'string' || featureName.trim() === ''
1225
+ || featureName !== evidence.featureName) {
1226
+ failPrecondition('existing feature_name conflicts with the scaffold feature identity');
1227
+ }
1228
+ if (!isIsoTimestamp(candidate.created_at) || !isIsoTimestamp(candidate.updated_at)) {
1229
+ failPrecondition('existing created_at and updated_at must be valid ISO 8601 timestamps');
1230
+ }
1231
+ if (typeof candidate.language !== 'string' || candidate.language.trim() === '') {
1232
+ failPrecondition('existing language must be a non-empty string');
1233
+ }
1234
+ const status = isLegacy && candidate.status === 'in-progress' ? 'in_progress' : candidate.status;
1235
+ if (!SPEC_STATUS_VALUES.has(status)) failPrecondition('existing spec status is invalid');
1236
+ if (!isPlainObject(candidate.scope_lock)) failPrecondition('existing scope_lock must be an object');
1237
+ const scopeLock = {
1238
+ source: candidate.scope_lock.source,
1239
+ in_scope: candidate.scope_lock.in_scope,
1240
+ out_of_scope: candidate.scope_lock.out_of_scope,
1241
+ expansion_policy: candidate.scope_lock.expansion_policy,
1242
+ };
1243
+ if (typeof scopeLock.source !== 'string' || scopeLock.source.trim() === ''
1244
+ || !Array.isArray(scopeLock.in_scope) || !Array.isArray(scopeLock.out_of_scope)
1245
+ || typeof scopeLock.expansion_policy !== 'string' || scopeLock.expansion_policy.trim() === '') {
1246
+ failPrecondition('existing scope_lock canonical fields are invalid');
1247
+ }
1248
+
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');
1280
+ const authoring = {};
1281
+ for (const field of ['requirements', 'design']) {
1282
+ if (!evidence[`${field}Exists`]) failPrecondition(`physical ${field}.md is required for schema 2.1 migration`);
1283
+ authoring[field] = carryForward(field);
1284
+ }
1285
+ if (evidence.researchExists) {
1286
+ const pointer = isPlainObject(candidate.research) ? candidate.research.path : candidate.research;
1287
+ if (pointer !== undefined && pointer !== null && pointer !== 'research.md') {
1288
+ failPrecondition('legacy research pointer is ambiguous; expected canonical research.md');
1289
+ }
1290
+ authoring.research = carryForward('research');
1291
+ } else {
1292
+ if (hasOwn(candidate, 'research') && candidate.research !== null) {
1293
+ failPrecondition('research pointer exists without a physical research.md artifact');
1294
+ }
1295
+ authoring.research = 'absent';
1296
+ }
1297
+ if (evidence.taskFiles.length > 0) {
1298
+ authoring.tasks = carryForward('tasks');
1299
+ } else {
1300
+ authoring.tasks = 'absent';
1301
+ }
1302
+ const projectedRegistry = {};
1303
+ for (const taskPath of evidence.taskFiles) {
1304
+ const entry = candidate.task_registry?.[taskPath];
1305
+ if (!isPlainObject(entry)) failPrecondition(`task_registry.${taskPath} must be an object`);
1306
+ projectedRegistry[taskPath] = {};
1307
+ for (const key of canonicalRegistryFields(entry)) {
1308
+ if (hasOwn(entry, key)) projectedRegistry[taskPath][key] = cloneJson(entry[key]);
1309
+ }
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);
1322
+ const validationValid = isPlainObject(candidate.validation)
1323
+ && VALIDATION_STATUS_VALUES.has(candidate.validation.status)
1324
+ && semanticReviewShapeValid(candidate.validation.semantic_review)
1325
+ && historyValid
1326
+ && authoringValidationValid;
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
+ };
1340
+ const next = {
1341
+ schema_version: '2.1',
1342
+ feature_name: featureName,
1343
+ created_at: candidate.created_at,
1344
+ updated_at: candidate.updated_at,
1345
+ language: candidate.language,
1346
+ status,
1347
+ scope_lock: scopeLock,
1348
+ authoring,
1349
+ coordination: cloneJson(evidence.coordination),
1350
+ validation: validationValid ? cloneJson(candidate.validation) : freshValidation,
1351
+ semantic_model: isLegacy ? null : cloneJson(candidate.semantic_model),
1352
+ ready_for_implementation: readinessValid ? candidate.ready_for_implementation : false,
1353
+ workflow_policy: cloneJson(evidence.workflowPolicy),
1354
+ ...(evidence.researchExists ? { research: 'research.md' } : {}),
1355
+ ...(evidence.taskFiles.length > 0 ? {
1356
+ task_files: [...evidence.taskFiles].sort(), task_registry: projectedRegistry,
1357
+ } : {}),
1358
+ };
1359
+ const semanticChanged = evidence.tasksChanged || evidence.researchChanged
1360
+ || isLegacy || JSON.stringify(next) !== JSON.stringify(candidate);
1361
+ if (semanticChanged || !validationValid || !readinessValid) {
1362
+ next.ready_for_implementation = false;
1363
+ next.validation = freshValidation;
1364
+ next.semantic_model = null;
1365
+ }
1366
+ const changed = JSON.stringify(next) !== JSON.stringify(candidate);
1367
+ if (changed) next.updated_at = evidence.timestamp;
1368
+ preflightSpec21Candidate(next, evidence);
1369
+ return { ok: true, spec: next, changed };
1370
+ } catch (error) {
1371
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1372
+ }
353
1373
  }
354
1374
 
355
1375
  function validateDependencyCycles(taskFiles, registry) {
@@ -388,7 +1408,12 @@ function listExistingTaskFiles(workRoot, specDir) {
388
1408
 
389
1409
  function readPersistedPolicy(spec) {
390
1410
  if (hasOwn(spec, 'override_receipt')) {
391
- failPrecondition('existing spec contains legacy override_receipt; persist only workflow_policy.override_receipt');
1411
+ if (spec.override_receipt !== null) {
1412
+ failPrecondition('existing top-level override_receipt is unsupported; omit the field');
1413
+ }
1414
+ if (spec.schema_version !== '2.0') {
1415
+ failPrecondition('top-level override_receipt is only readable as inert null during schema 2.0 migration; omit the field');
1416
+ }
392
1417
  }
393
1418
  if (!hasOwn(spec, 'workflow_policy')) {
394
1419
  const legacy = spec.design_context?.execution_tier;
@@ -398,6 +1423,11 @@ function readPersistedPolicy(spec) {
398
1423
  : 'existing spec must contain a persisted workflow_policy snapshot',
399
1424
  );
400
1425
  }
1426
+ if (isPlainObject(spec.workflow_policy)
1427
+ && hasOwn(spec.workflow_policy, 'override_receipt')
1428
+ && spec.workflow_policy.override_receipt !== null) {
1429
+ failPrecondition('existing workflow_policy.override_receipt is unsupported; omit the field');
1430
+ }
401
1431
  try {
402
1432
  return POLICY.readWorkflowPolicySnapshot(spec);
403
1433
  } catch (error) {
@@ -415,7 +1445,7 @@ function validateResearchTopology(workRoot, specDir, spec, needsResearch) {
415
1445
  assertNoSymlinkPath(workRoot, pointerPath, 'spec.research path');
416
1446
  if (!isWithin(specDir, pointerPath)) failPrecondition('spec.research path must stay inside the spec directory');
417
1447
  if (needsResearch && safePointer !== canonical) {
418
- failPrecondition('spec.research path must be research.md when needsResearchGrounding is required');
1448
+ failPrecondition('spec.research path must be research.md for an explicit --research request');
419
1449
  }
420
1450
  assertRegularFileOrMissing(workRoot, pointerPath, 'spec.research path');
421
1451
  }
@@ -462,7 +1492,7 @@ function inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclaratio
462
1492
  for (const task of parsed) {
463
1493
  if (!existingSet.has(task.file)) continue;
464
1494
  const entry = registry[task.file];
465
- const expected = registryEntry(task, new Map());
1495
+ const expected = registryEntry(task, artifactDeclarations);
466
1496
  if (entry.id !== expected.id || entry.title !== expected.title) {
467
1497
  failPrecondition(`conflicting duplicate task ${task.raw}: existing registry entry differs`);
468
1498
  }
@@ -504,20 +1534,191 @@ function parseFailureInjection() {
504
1534
  return Number(raw);
505
1535
  }
506
1536
 
507
- function stageChanges(workRoot, specDir, changes) {
508
- const parent = path.dirname(specDir);
509
- assertExistingDirectory(workRoot, parent, 'spec parent directory');
510
- const stageDir = fs.mkdtempSync(path.join(parent, '.cafekit-scaffold-stage-'));
1537
+ function parseCrashInjection() {
1538
+ const raw = process.env.CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES;
1539
+ if (raw === undefined) return null;
1540
+ if (!/^[1-9]\d*$/.test(raw)) {
1541
+ failPrecondition('CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES must be a positive integer');
1542
+ }
1543
+ return Number(raw);
1544
+ }
1545
+
1546
+ function digest(bytes) {
1547
+ return crypto.createHash('sha256').update(bytes).digest('hex');
1548
+ }
1549
+
1550
+ function fsyncPath(filePath) {
1551
+ const fd = fs.openSync(filePath, 'r');
1552
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
1553
+ }
1554
+
1555
+ function fsyncDirectory(directory) {
1556
+ try { fsyncPath(directory); } catch (error) {
1557
+ if (!['EINVAL', 'ENOTSUP', 'EISDIR'].includes(error.code)) throw error;
1558
+ }
1559
+ }
1560
+
1561
+ function fileDigestOrNull(filePath) {
1562
+ try {
1563
+ const stat = fs.lstatSync(filePath);
1564
+ if (!stat.isFile()) throw new Error(`transaction path is not a regular file: ${filePath}`);
1565
+ return digest(fs.readFileSync(filePath));
1566
+ } catch (error) {
1567
+ if (error.code === 'ENOENT') return null;
1568
+ throw error;
1569
+ }
1570
+ }
1571
+
1572
+ function processIsAlive(pid) {
1573
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return pid === process.pid;
1574
+ try { process.kill(pid, 0); return true; } catch (error) {
1575
+ return error.code === 'EPERM';
1576
+ }
1577
+ }
1578
+
1579
+ function validateTransactionManifest(workRoot, transactionDir, manifest) {
1580
+ if (!isPlainObject(manifest) || manifest.format !== TRANSACTION_FORMAT) {
1581
+ throw new Error(`unrecognized scaffold transaction manifest: ${transactionDir}`);
1582
+ }
1583
+ const transactionId = path.basename(transactionDir).slice(TRANSACTION_PREFIX.length);
1584
+ if (manifest.transaction_id !== transactionId || !/^[a-f0-9-]{20,}$/i.test(transactionId)) {
1585
+ throw new Error(`scaffold transaction identity mismatch: ${transactionDir}`);
1586
+ }
1587
+ if (path.resolve(manifest.work_root || '') !== workRoot) {
1588
+ throw new Error(`scaffold transaction work root mismatch: ${transactionDir}`);
1589
+ }
1590
+ const specRelative = safeRelativePath(manifest.spec_dir, 'transaction spec_dir');
1591
+ const specDir = path.resolve(workRoot, specRelative);
1592
+ if (!isWithin(workRoot, specDir) || !Array.isArray(manifest.entries) || manifest.entries.length === 0) {
1593
+ throw new Error(`invalid scaffold transaction topology: ${transactionDir}`);
1594
+ }
1595
+ const seen = new Set();
1596
+ for (const entry of manifest.entries) {
1597
+ if (!isPlainObject(entry)) throw new Error(`invalid scaffold transaction entry: ${transactionDir}`);
1598
+ const relativePath = safeRelativePath(entry.path, 'transaction entry path');
1599
+ if (seen.has(relativePath)) throw new Error(`duplicate scaffold transaction path: ${relativePath}`);
1600
+ seen.add(relativePath);
1601
+ if (!isPlainObject(entry.before) || typeof entry.before.exists !== 'boolean'
1602
+ || (entry.before.exists && !/^[a-f0-9]{64}$/.test(entry.before.digest || ''))
1603
+ || !/^[a-f0-9]{64}$/.test(entry.after_digest || '')) {
1604
+ throw new Error(`invalid scaffold transaction digest evidence: ${relativePath}`);
1605
+ }
1606
+ }
1607
+ return { ...manifest, specDir };
1608
+ }
1609
+
1610
+ function removeEmptyTransactionParents(specDir, target) {
1611
+ let current = path.dirname(target);
1612
+ while (isWithin(specDir, current) && current !== path.dirname(specDir)) {
1613
+ try { fs.rmdirSync(current); } catch (error) {
1614
+ if (error.code === 'ENOENT') break;
1615
+ if (['ENOTEMPTY', 'EEXIST'].includes(error.code)) break;
1616
+ throw error;
1617
+ }
1618
+ if (current === specDir) break;
1619
+ current = path.dirname(current);
1620
+ }
1621
+ }
1622
+
1623
+ function recoverTransaction(workRoot, transactionDir, rawManifest) {
1624
+ const manifest = validateTransactionManifest(workRoot, transactionDir, rawManifest);
1625
+ if (processIsAlive(manifest.pid)) return false;
1626
+ for (let index = manifest.entries.length - 1; index >= 0; index -= 1) {
1627
+ const entry = manifest.entries[index];
1628
+ const target = path.join(manifest.specDir, entry.path);
1629
+ const backup = path.join(transactionDir, 'backup', entry.path);
1630
+ const targetDigest = fileDigestOrNull(target);
1631
+ const backupDigest = fileDigestOrNull(backup);
1632
+ if (entry.before.exists) {
1633
+ if (backupDigest !== null && backupDigest !== entry.before.digest) {
1634
+ throw new Error(`recovery cannot prove backup ownership for ${entry.path}`);
1635
+ }
1636
+ if (targetDigest === entry.after_digest) {
1637
+ removeTarget(target);
1638
+ } else if (targetDigest !== null && targetDigest !== entry.before.digest) {
1639
+ throw new Error(`recovery refuses to overwrite unowned target ${entry.path}`);
1640
+ }
1641
+ if (fileDigestOrNull(target) === null) {
1642
+ if (backupDigest !== entry.before.digest) {
1643
+ throw new Error(`recovery is missing the owned backup for ${entry.path}`);
1644
+ }
1645
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1646
+ fs.renameSync(backup, target);
1647
+ }
1648
+ } else if (targetDigest === entry.after_digest) {
1649
+ removeTarget(target);
1650
+ } else if (targetDigest !== null) {
1651
+ throw new Error(`recovery refuses to delete unowned target ${entry.path}`);
1652
+ }
1653
+ removeEmptyTransactionParents(manifest.specDir, target);
1654
+ }
1655
+ fs.rmSync(transactionDir, { recursive: true, force: true });
1656
+ fsyncDirectory(workRoot);
1657
+ return true;
1658
+ }
1659
+
1660
+ function recoverTransactions(workRoot) {
1661
+ let recovered = 0;
1662
+ for (const entry of fs.readdirSync(workRoot, { withFileTypes: true })) {
1663
+ if (!entry.name.startsWith(TRANSACTION_PREFIX)) continue;
1664
+ const transactionDir = path.join(workRoot, entry.name);
1665
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
1666
+ failPrecondition(`scaffold transaction identity is not a directory: ${transactionDir}`);
1667
+ }
1668
+ const manifestPath = path.join(transactionDir, 'manifest.json');
1669
+ let manifest;
1670
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (error) {
1671
+ failPrecondition(`scaffold transaction cannot be recovered (${error.message}): ${transactionDir}`);
1672
+ }
1673
+ try {
1674
+ if (recoverTransaction(workRoot, transactionDir, manifest)) recovered += 1;
1675
+ } catch (error) {
1676
+ failPrecondition(`scaffold transaction recovery stopped safely (${error.message})`);
1677
+ }
1678
+ }
1679
+ return recovered;
1680
+ }
1681
+
1682
+ function prepareTransaction(workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir) {
1683
+ assertExistingDirectory(workRoot, transactionRoot, 'scaffold transaction directory');
1684
+ const transactionId = crypto.randomUUID();
1685
+ const transactionDir = path.join(transactionRoot, `${TRANSACTION_PREFIX}${transactionId}`);
1686
+ const stageDir = path.join(transactionDir, 'stage');
1687
+ const backupDir = path.join(transactionDir, 'backup');
1688
+ fs.mkdirSync(stageDir, { recursive: true });
1689
+ fs.mkdirSync(backupDir);
1690
+ const entries = [];
511
1691
  try {
512
1692
  for (const change of changes) {
513
1693
  safeRelativePath(change.path, `staged path ${change.path}`);
514
1694
  const staged = path.join(stageDir, change.path);
515
1695
  fs.mkdirSync(path.dirname(staged), { recursive: true });
516
1696
  fs.writeFileSync(staged, change.body, { encoding: 'utf8', flag: 'wx' });
1697
+ fsyncPath(staged);
1698
+ const before = snapshots.get(change.path);
1699
+ entries.push({
1700
+ path: change.path,
1701
+ before: { exists: before.exists, ...(before.exists ? { digest: digest(before.bytes) } : {}) },
1702
+ after_digest: digest(Buffer.from(change.body, 'utf8')),
1703
+ });
517
1704
  }
518
- return stageDir;
1705
+ const manifest = {
1706
+ format: TRANSACTION_FORMAT,
1707
+ transaction_id: transactionId,
1708
+ pid: process.pid,
1709
+ work_root: workRoot,
1710
+ spec_dir: path.relative(workRoot, specDir),
1711
+ create_spec_dir: createSpecDir,
1712
+ entries,
1713
+ };
1714
+ const manifestPath = path.join(transactionDir, 'manifest.json');
1715
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
1716
+ fsyncPath(manifestPath);
1717
+ fsyncDirectory(transactionDir);
1718
+ fsyncDirectory(transactionRoot);
1719
+ return { transactionDir, stageDir, backupDir, manifest };
519
1720
  } catch (error) {
520
- fs.rmSync(stageDir, { recursive: true, force: true });
1721
+ fs.rmSync(transactionDir, { recursive: true, force: true });
521
1722
  throw error;
522
1723
  }
523
1724
  }
@@ -538,6 +1739,21 @@ function ensureTargetParent(workRoot, specDir, relativePath, createdDirs) {
538
1739
  }
539
1740
  }
540
1741
 
1742
+ function ensureDirectoryTree(workRoot, directory, createdDirs, label) {
1743
+ assertNoSymlinkPath(workRoot, directory, label);
1744
+ const relative = path.relative(path.resolve(workRoot), path.resolve(directory));
1745
+ let current = path.resolve(workRoot);
1746
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
1747
+ current = path.join(current, segment);
1748
+ if (fs.existsSync(current)) {
1749
+ assertExistingDirectory(workRoot, current, label);
1750
+ } else {
1751
+ fs.mkdirSync(current);
1752
+ createdDirs.push(current);
1753
+ }
1754
+ }
1755
+ }
1756
+
541
1757
  function removeTarget(target) {
542
1758
  try {
543
1759
  const stat = fs.lstatSync(target);
@@ -548,40 +1764,32 @@ function removeTarget(target) {
548
1764
  }
549
1765
  }
550
1766
 
551
- function rollbackChanges(applied, createdDirs, backupDir) {
552
- for (let index = applied.length - 1; index >= 0; index -= 1) {
553
- const record = applied[index];
554
- const target = record.target;
555
- if (record.targetInstalled) removeTarget(target);
556
- if (record.backupMoved) {
557
- fs.mkdirSync(path.dirname(target), { recursive: true });
558
- fs.renameSync(record.backup, target);
559
- }
560
- }
561
- for (let index = createdDirs.length - 1; index >= 0; index -= 1) {
562
- try {
563
- fs.rmdirSync(createdDirs[index]);
564
- } catch (error) {
565
- if (error.code !== 'ENOENT') throw error;
566
- }
567
- }
568
- fs.rmSync(backupDir, { recursive: true, force: true });
569
- }
570
-
571
- function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
572
- const stageDir = stageChanges(workRoot, specDir, changes);
573
- const parent = path.dirname(specDir);
574
- let backupDir;
575
- try {
576
- backupDir = fs.mkdtempSync(path.join(parent, '.cafekit-scaffold-backup-'));
577
- } catch (error) {
578
- fs.rmSync(stageDir, { recursive: true, force: true });
579
- throw error;
580
- }
581
- const applied = [];
1767
+ function commitChanges(
1768
+ workRoot,
1769
+ specDir,
1770
+ changes,
1771
+ snapshots,
1772
+ failureAfter,
1773
+ { createSpecDir = false, afterApply = null, transactionRoot = path.dirname(specDir) } = {},
1774
+ ) {
1775
+ const transaction = prepareTransaction(
1776
+ workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir,
1777
+ );
582
1778
  const createdDirs = [];
583
1779
  let writes = 0;
1780
+ let renames = 0;
1781
+ const crashAfter = parseCrashInjection();
1782
+ const renameBoundary = (from, to) => {
1783
+ fs.renameSync(from, to);
1784
+ renames += 1;
1785
+ fsyncDirectory(path.dirname(to));
1786
+ if (crashAfter !== null && renames >= crashAfter) process.kill(process.pid, 'SIGKILL');
1787
+ };
584
1788
  try {
1789
+ if (createSpecDir) {
1790
+ if (fs.existsSync(specDir)) failPrecondition(`spec dir already exists: ${specDir}`);
1791
+ ensureDirectoryTree(workRoot, specDir, createdDirs, 'spec directory');
1792
+ }
585
1793
  for (const change of changes) {
586
1794
  const target = path.join(specDir, change.path);
587
1795
  const before = captureTarget(workRoot, specDir, change.path);
@@ -590,50 +1798,68 @@ function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
590
1798
  failPrecondition(`target changed during preflight: ${change.path}`);
591
1799
  }
592
1800
  ensureTargetParent(workRoot, specDir, change.path, createdDirs);
593
- const record = {
594
- target,
595
- backup: path.join(backupDir, change.path),
596
- targetInstalled: false,
597
- backupMoved: false,
598
- };
599
- applied.push(record);
1801
+ const backup = path.join(transaction.backupDir, change.path);
600
1802
  if (before.exists) {
601
- fs.mkdirSync(path.dirname(record.backup), { recursive: true });
602
- fs.renameSync(target, record.backup);
603
- record.backupMoved = true;
1803
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
1804
+ renameBoundary(target, backup);
604
1805
  }
605
- fs.renameSync(path.join(stageDir, change.path), target);
606
- record.targetInstalled = true;
1806
+ renameBoundary(path.join(transaction.stageDir, change.path), target);
607
1807
  writes += 1;
608
1808
  if (failureAfter !== null && writes >= failureAfter) {
609
1809
  throw new Error(`injected failure after ${writes} staged write(s)`);
610
1810
  }
611
1811
  }
1812
+ if (typeof afterApply === 'function') afterApply();
612
1813
  } catch (error) {
613
1814
  let rollbackError = null;
614
1815
  try {
615
- rollbackChanges(applied, createdDirs, backupDir);
1816
+ const manifest = { ...transaction.manifest, pid: -1 };
1817
+ recoverTransaction(workRoot, transaction.transactionDir, manifest);
616
1818
  } catch (rollbackFailure) {
617
1819
  rollbackError = rollbackFailure;
618
1820
  }
619
- fs.rmSync(stageDir, { recursive: true, force: true });
620
1821
  if (rollbackError) throw new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
621
1822
  throw new Error(`${error.message}; transaction rolled back`);
622
1823
  }
623
- fs.rmSync(backupDir, { recursive: true, force: true });
624
- fs.rmSync(stageDir, { recursive: true, force: true });
1824
+ fs.rmSync(transaction.transactionDir, { recursive: true, force: true });
1825
+ fsyncDirectory(transactionRoot);
625
1826
  }
626
1827
 
627
1828
  function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, parsed, artifactDeclarations, taskTpl, ts }) {
628
1829
  const workRoot = path.resolve(process.cwd());
629
1830
  assertExistingDirectory(workRoot, specDir, 'spec directory');
630
1831
  assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
1832
+ const requirementsPath = path.join(specDir, 'requirements.md');
1833
+ const designPath = path.join(specDir, 'design.md');
1834
+ assertRegularFileOrMissing(workRoot, requirementsPath, 'requirements.md');
1835
+ assertRegularFileOrMissing(workRoot, designPath, 'design.md');
631
1836
  const topology = inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations);
1837
+ const allTaskFiles = [...new Set([...topology.declared, ...parsed.map((task) => task.file)])].sort();
1838
+ if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
1839
+ failPrecondition('schema 2.1 mutation uses --boundaries as the only topology authority');
1840
+ }
1841
+ const boundaries = parseBoundaryDeclarations(args.boundaries, allTaskFiles);
1842
+ if (boundaries.length === 0) {
1843
+ failPrecondition('--tasks-only requires a complete non-empty --boundaries array for the resulting task bundle');
1844
+ }
1845
+ const dependencyDeclarations = dependenciesFromBoundaries(boundaries, allTaskFiles);
1846
+ const phases = args.phases === null && Array.isArray(spec.coordination?.phases)
1847
+ ? cloneJson(spec.coordination.phases)
1848
+ : parsePhaseDeclarations(args.phases, allTaskFiles);
1849
+ validatePhaseBoundaryRefs(phases, boundaries);
1850
+ for (const task of parsed) {
1851
+ if (!topology.existingSet.has(task.file) || !dependencyDeclarations.has(task.file)) continue;
1852
+ const existing = topology.registry[task.file].dependencies;
1853
+ const requested = dependencyDeclarations.get(task.file);
1854
+ if (JSON.stringify(existing) !== JSON.stringify(requested)) {
1855
+ failPrecondition(`conflicting duplicate task ${task.raw}: existing registry dependencies differ`);
1856
+ }
1857
+ }
632
1858
 
633
- let nextPolicy = persistedPolicy;
1859
+ let nextPolicy = POLICY.canonicalWorkflowPolicySnapshot(spec);
634
1860
  if (args.risks && args.risks.length > 0) {
635
1861
  try {
636
- nextPolicy = POLICY.escalateWorkflowPolicy(spec, { risks: args.risks });
1862
+ nextPolicy = POLICY.escalateWorkflowPolicy(nextPolicy, { risks: args.risks });
637
1863
  } catch (error) {
638
1864
  failPrecondition(`--tasks-only cannot escalate workflow policy (${error.message})`);
639
1865
  }
@@ -641,40 +1867,62 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
641
1867
  if (!validation.valid) failPrecondition(`--tasks-only escalation produced an invalid workflow_policy (${validation.errors.join('; ')})`);
642
1868
  }
643
1869
 
644
- const needsResearch = nextPolicy.proof_obligations.includes('needsResearchGrounding');
1870
+ const needsResearch = args.research === true;
645
1871
  const researchPath = validateResearchTopology(workRoot, specDir, spec, needsResearch);
646
1872
  const researchExists = fs.existsSync(researchPath);
647
1873
  const researchTemplate = needsResearch && !researchExists
648
- ? readTemplate('research.md').replace(/\{\{FEATURE_NAME\}\}/g, args.feature)
1874
+ ? renderResearchTemplate(args.feature, args.uncertainty)
649
1875
  : null;
650
1876
  const newTasks = parsed.filter((task) => !topology.existingSet.has(task.file));
651
1877
  const nextSpec = cloneJson(spec);
652
- let specChanged = false;
653
1878
  if (!topology.hasTaskTopology && newTasks.length > 0) {
654
1879
  nextSpec.task_files = [];
655
1880
  nextSpec.task_registry = {};
656
- specChanged = true;
657
1881
  }
658
1882
  for (const task of newTasks) {
659
1883
  nextSpec.task_files.push(task.file);
660
- nextSpec.task_registry[task.file] = registryEntry(task, artifactDeclarations);
661
- specChanged = true;
1884
+ nextSpec.task_registry[task.file] = registryEntry(
1885
+ task,
1886
+ artifactDeclarations,
1887
+ dependencyDeclarations.get(task.file) || [],
1888
+ );
662
1889
  }
663
- if (JSON.stringify(nextPolicy) !== JSON.stringify(persistedPolicy)) {
664
- nextSpec.workflow_policy = nextPolicy;
665
- specChanged = true;
1890
+ if (needsResearch && nextSpec.research !== 'research.md') {
1891
+ nextSpec.research = 'research.md';
666
1892
  }
667
- if (specChanged) {
668
- nextSpec.task_files.sort();
669
- nextSpec.updated_at = ts;
1893
+ nextSpec.task_files.sort();
1894
+ const normalized = normalizeSpec21Candidate(nextSpec, {
1895
+ featureName: args.feature,
1896
+ timestamp: ts,
1897
+ specDir,
1898
+ requirementsExists: fs.existsSync(requirementsPath),
1899
+ designExists: fs.existsSync(designPath),
1900
+ researchExists: researchExists || researchTemplate !== null,
1901
+ researchChanged: researchTemplate !== null,
1902
+ taskFiles: allTaskFiles,
1903
+ tasksChanged: newTasks.length > 0,
1904
+ coordination: { boundaries, ...(phases.length > 0 ? { phases } : {}) },
1905
+ workflowPolicy: nextPolicy,
1906
+ });
1907
+ if (!normalized.ok) failPrecondition(`schema 2.1 migration rejected candidate (${normalized.error})`);
1908
+ const normalizedSpec = normalized.spec;
1909
+ const specChanged = JSON.stringify(normalizedSpec) !== JSON.stringify(spec);
1910
+ if (specChanged && normalizedSpec.updated_at === spec.created_at && ts !== spec.created_at) {
1911
+ failPrecondition('schema 2.1 migration produced a stale updated_at timestamp');
670
1912
  }
671
1913
 
672
1914
  const changes = newTasks.map((task) => ({
673
1915
  path: task.file,
674
- body: fillTask(taskTpl, task, args.feature),
1916
+ body: fillTask(
1917
+ taskTpl,
1918
+ task,
1919
+ args.feature,
1920
+ dependencyDeclarations.get(task.file) || [],
1921
+ taskHasDeclaredImplementationObligation(normalizedSpec, task.file),
1922
+ ),
675
1923
  }));
676
1924
  if (researchTemplate !== null) changes.push({ path: 'research.md', body: researchTemplate });
677
- if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(nextSpec, null, 2)}\n` });
1925
+ if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(normalizedSpec, null, 2)}\n` });
678
1926
  if (changes.length === 0) return { createdTasks: [], researchCreated: false };
679
1927
 
680
1928
  const expectedAbsent = new Set(newTasks.map((task) => task.file));
@@ -687,22 +1935,163 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
687
1935
  }
688
1936
  snapshots.set(change.path, snapshot);
689
1937
  }
690
- commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection());
1938
+ commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
1939
+ transactionRoot: workRoot,
1940
+ afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec: normalizedSpec }),
1941
+ });
691
1942
  return { createdTasks: newTasks.map((task) => task.file), researchCreated: researchTemplate !== null };
692
1943
  }
693
1944
 
1945
+ function syncSemanticModel({ workRoot, args, specDir, ts }) {
1946
+ assertExistingDirectory(workRoot, specDir, 'spec directory');
1947
+ const specPath = path.join(specDir, 'spec.json');
1948
+ assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
1949
+ if (!fs.existsSync(specPath)) failPrecondition('--sync-semantic-model requires an existing spec.json');
1950
+ let spec;
1951
+ try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
1952
+ catch (error) { failPrecondition(`--sync-semantic-model spec.json is invalid JSON (${error.message})`); }
1953
+ if (!isPlainObject(spec) || spec.schema_version !== '2.1') failPrecondition('--sync-semantic-model accepts canonical schema 2.1 only');
1954
+ assertCanonicalSpecInputClosedWorld(spec, { allowLegacyReview: true });
1955
+ const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
1956
+ if (projection.errors.length) failPrecondition(`semantic projection is invalid (${projection.errors.join('; ')})`);
1957
+ const modelUnchanged = SEMANTIC.stableJson(spec.semantic_model) === SEMANTIC.stableJson(projection.model);
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) {
1964
+ console.log(`SEMANTIC_MODEL_UNCHANGED ${path.relative(process.cwd(), specDir) || specDir}`);
1965
+ return;
1966
+ }
1967
+ const next = cloneJson(spec);
1968
+ next.semantic_model = projection.model;
1969
+ next.updated_at = ts;
1970
+ next.ready_for_implementation = false;
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
+ };
1991
+ preflightSpec21Candidate(next, {
1992
+ featureName: args.feature,
1993
+ requirementsExists: fs.existsSync(path.join(specDir, 'requirements.md')),
1994
+ designExists: fs.existsSync(path.join(specDir, 'design.md')),
1995
+ researchExists: fs.existsSync(path.join(specDir, 'research.md')),
1996
+ taskFiles: listExistingTaskFiles(workRoot, specDir),
1997
+ });
1998
+ const snapshot = captureTarget(workRoot, specDir, 'spec.json');
1999
+ commitChanges(
2000
+ workRoot,
2001
+ specDir,
2002
+ [{ path: 'spec.json', body: `${JSON.stringify(next, null, 2)}\n` }],
2003
+ new Map([['spec.json', snapshot]]),
2004
+ parseFailureInjection(),
2005
+ { transactionRoot: workRoot },
2006
+ );
2007
+ console.log(`SEMANTIC_MODEL_SYNCED ${path.relative(process.cwd(), specDir) || specDir}`);
2008
+ }
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
+
694
2047
  function main() {
2048
+ if (respondToInfoFlags(process.argv)) return;
2049
+ const workRoot = path.resolve(process.cwd());
2050
+ const recoveredTransactions = recoverTransactions(workRoot);
2051
+ if (recoveredTransactions > 0) {
2052
+ console.error(`recovered ${recoveredTransactions} interrupted scaffold transaction(s)`);
2053
+ }
695
2054
  const args = parseArgs(process.argv);
696
- if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.lane === 'Critical' && !args.tasks)) {
2055
+ if (args.syncSemanticModel) {
2056
+ if (!args.feature || args.tasks !== null || args.tasksOnly || args.boundaries !== null || args.research
2057
+ || args.planningDepth || args.assuranceLevel || args.lane) {
2058
+ failPrecondition('--sync-semantic-model is an isolated explicit promotion mode and cannot be combined with authoring mutations');
2059
+ }
2060
+ safeRelativePath(args.specsRoot, '--specs-root');
2061
+ safeRelativePath(args.feature, 'feature');
2062
+ const specDir = path.resolve(workRoot, args.specsRoot, args.feature);
2063
+ if (!isWithin(workRoot, specDir)) failPrecondition(`spec directory escapes the work root: ${specDir}`);
2064
+ syncSemanticModel({ workRoot, args, specDir, ts: nowIso() });
2065
+ return;
2066
+ }
2067
+ if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.coordination && !args.tasks)) {
697
2068
  usage();
698
2069
  failPrecondition('missing feature or required task arguments');
699
2070
  }
2071
+ if (args.research && !concreteUncertainty(args.uncertainty)) {
2072
+ failPrecondition('--research requires --uncertainty with one concrete unresolved question (at least 12 trimmed characters)');
2073
+ }
2074
+ if (!args.research && args.uncertainty !== null) {
2075
+ failPrecondition('--uncertainty is only valid with --research');
2076
+ }
700
2077
  if (typeof args.lane === 'string' && args.lane.toLowerCase() === 'direct') {
701
2078
  failPrecondition('Direct lane does not create a spec; run targeted verification from the Direct workflow instead');
702
2079
  }
703
2080
  if (args.lane && !POLICY.LANES.includes(args.lane)) {
704
2081
  failPrecondition(`--lane must be one of ${POLICY.LANES.join(', ')}`);
705
2082
  }
2083
+ if (args.planningDepth && !POLICY.PLANNING_DEPTHS.includes(args.planningDepth)) {
2084
+ failPrecondition(`--planning-depth must be one of ${POLICY.PLANNING_DEPTHS.join(', ')}`);
2085
+ }
2086
+ if (args.planningDepth === 'None') {
2087
+ failPrecondition('None planning depth does not create a durable spec; use the Direct workflow instead');
2088
+ }
2089
+ if (args.assuranceLevel && !POLICY.ASSURANCE_LEVELS.includes(args.assuranceLevel)) {
2090
+ failPrecondition(`--assurance-level must be one of ${POLICY.ASSURANCE_LEVELS.join(', ')}`);
2091
+ }
2092
+ if (args.lane && (args.planningDepth || args.assuranceLevel)) {
2093
+ failPrecondition('--lane is a compatibility adapter and cannot be combined with Specs v2 axis flags');
2094
+ }
706
2095
 
707
2096
  const ids = (args.tasks || '').split(',').map((s) => s.trim()).filter(Boolean);
708
2097
  if (args.tasksOnly && ids.length === 0) {
@@ -720,10 +2109,29 @@ function main() {
720
2109
  seenIds.add(logicalId);
721
2110
  parsed.push({ raw: id, req: m[1], seq: m[2], slug: m[3], file: `tasks/task-${id}.md` });
722
2111
  }
2112
+ if (args.phases !== null && parsed.length === 0) {
2113
+ failPrecondition('--phases requires --tasks because phase grouping needs a task graph');
2114
+ }
2115
+ if (args.phases !== null && args.planningDepth !== 'Full') {
2116
+ failPrecondition('--phases requires explicit --planning-depth Full before any scaffold writes');
2117
+ }
723
2118
  const artifactDeclarations = parseArtifactDeclarations(args.artifacts, parsed);
2119
+ if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
2120
+ failPrecondition('schema 2.1 authoring uses --boundaries as the only topology authority; task triggers, coordination markers, dependencies, and artifact maps are read compatibility only');
2121
+ }
2122
+ const boundaries = args.tasksOnly ? null : parseBoundaryDeclarations(
2123
+ args.boundaries,
2124
+ parsed.map((task) => task.file).sort(),
2125
+ );
2126
+ const dependencyDeclarations = args.tasksOnly
2127
+ ? null
2128
+ : dependenciesFromBoundaries(boundaries, parsed.map((task) => task.file).sort());
724
2129
  const riskNames = parseRiskNames(args.risks);
2130
+ const phases = args.tasksOnly
2131
+ ? []
2132
+ : parsePhaseDeclarations(args.phases, parsed.map((task) => task.file));
2133
+ if (!args.tasksOnly) validatePhaseBoundaryRefs(phases, boundaries);
725
2134
 
726
- const workRoot = path.resolve(process.cwd());
727
2135
  safeRelativePath(args.specsRoot, '--specs-root');
728
2136
  safeRelativePath(args.feature, 'feature');
729
2137
  if (!FEATURE_NAME_RE.test(args.feature)) {
@@ -750,7 +2158,12 @@ function main() {
750
2158
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
751
2159
  failPrecondition('--tasks-only existing spec.json must contain a JSON object');
752
2160
  }
2161
+ assertCanonicalSpecInputClosedWorld(spec);
2162
+ if (spec.schema_version === '2.0') assertLegacyMigrationClosedWorld(spec);
753
2163
  const persistedPolicy = readPersistedPolicy(spec);
2164
+ if (args.planningDepth && args.planningDepth !== persistedPolicy.planning_depth) {
2165
+ failPrecondition(`--tasks-only --planning-depth ${args.planningDepth} does not match persisted workflow_policy.planning_depth ${persistedPolicy.planning_depth}`);
2166
+ }
754
2167
  if (args.lane && args.lane !== persistedPolicy.lane) {
755
2168
  failPrecondition(`--tasks-only --lane ${args.lane} does not match persisted workflow_policy.lane ${persistedPolicy.lane}; omit --lane or use the persisted lane (downgrade is not allowed)`);
756
2169
  }
@@ -769,89 +2182,131 @@ function main() {
769
2182
  const relTo = path.relative(process.cwd(), specDir) || specDir;
770
2183
  console.log(`SCAFFOLDED (tasks-only) ${relTo}`);
771
2184
  console.log(`- ${createdTasks.length} new task stub(s); task_files + task_registry merged.`);
772
- if (result.researchCreated) console.log('- research.md created for needsResearchGrounding.');
2185
+ if (result.researchCreated) console.log('- research.md created by explicit --research request.');
773
2186
  console.log(`- NEXT: Edit-fill {{...}} in each stub; set dependencies; run validator + spec-ground.`);
774
2187
  return;
775
2188
  }
776
2189
 
777
- const policyInput = args.lane ? { risks: riskNames, override: args.lane } : { risks: riskNames };
2190
+ const policyInput = args.lane
2191
+ ? { risks: riskNames, override: args.lane }
2192
+ : {
2193
+ risks: riskNames,
2194
+ ...(args.planningDepth ? { planning_depth: args.planningDepth } : {}),
2195
+ ...(args.assuranceLevel ? { assurance_level: args.assuranceLevel } : {}),
2196
+ };
778
2197
  let selectedPolicy;
779
2198
  try {
780
2199
  selectedPolicy = POLICY.workflowPolicySnapshot(policyInput);
781
2200
  } catch (error) {
782
2201
  failPrecondition(error.message);
783
2202
  }
784
- if (selectedPolicy.lane === 'Critical' && parsed.length === 0) {
785
- console.error('precondition: Critical workflow policy requires a task bundle; pass --tasks with at least one task id');
786
- process.exit(2);
2203
+ if (parsed.length > 0 && boundaries.length === 0) {
2204
+ failPrecondition('physical task bundle requires at least one typed coordination.boundaries entry with semantic evidence');
2205
+ }
2206
+ if (parsed.length === 0 && args.boundaries !== null) {
2207
+ failPrecondition('--boundaries requires --tasks');
787
2208
  }
788
-
789
2209
  if (fs.existsSync(specDir)) {
790
2210
  console.error(`precondition: spec dir already exists, refusing to overwrite: ${specDir}`);
791
2211
  process.exit(2);
792
2212
  }
793
2213
 
794
- fs.mkdirSync(specDir, { recursive: true });
795
- if (parsed.length > 0) {
796
- fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
797
- }
798
-
799
2214
  // --- spec.json (from spec-state.json template) ---
800
2215
  const spec = JSON.parse(readTemplate('spec-state.json'));
801
2216
  spec.feature_name = args.feature;
802
2217
  spec.created_at = ts;
803
2218
  spec.updated_at = ts;
804
2219
  spec.language = args.lang;
805
- spec.timestamps = spec.timestamps || {};
806
- spec.timestamps.init = ts;
807
2220
  spec.scope_lock = spec.scope_lock || {};
808
2221
  spec.scope_lock.source = args.title || `{{PROJECT_DESCRIPTION}}`;
809
2222
  delete spec.workflow_policy;
810
2223
  delete spec.override_receipt;
811
2224
  Object.assign(spec, POLICY.persistWorkflowPolicySnapshot(spec, policyInput));
2225
+ spec.validation = isPlainObject(spec.validation) ? spec.validation : {};
2226
+ spec.validation.semantic_review = semanticReviewNotRun();
2227
+ spec.validation.semantic_review_history = semanticReviewHistoryInitial(args.feature, ts);
2228
+ spec.validation.authoring_validation = null;
2229
+ if (args.research) {
2230
+ spec.research = 'research.md';
2231
+ spec.authoring.research = 'draft';
2232
+ }
2233
+ spec.coordination = {
2234
+ boundaries,
2235
+ ...(phases.length > 0 ? { phases } : {}),
2236
+ };
812
2237
  if (parsed.length > 0) {
2238
+ spec.authoring.tasks = 'draft';
813
2239
  spec.task_files = parsed.map((t) => t.file);
814
2240
  spec.task_registry = {};
815
- for (const t of parsed) spec.task_registry[t.file] = registryEntry(t, artifactDeclarations);
2241
+ for (const t of parsed) {
2242
+ spec.task_registry[t.file] = registryEntry(
2243
+ t,
2244
+ artifactDeclarations,
2245
+ dependencyDeclarations.get(t.file) || [],
2246
+ );
2247
+ }
816
2248
  } else {
817
2249
  delete spec.task_files;
818
2250
  delete spec.task_registry;
819
2251
  }
820
- fs.writeFileSync(path.join(specDir, 'spec.json'), JSON.stringify(spec, null, 2) + '\n');
821
- observeInstalledPolicyBaseline({ args, specDir, spec });
2252
+ if (args.research) spec.authoring.research = 'draft';
2253
+ preflightSpec21Candidate(spec, {
2254
+ featureName: args.feature,
2255
+ requirementsExists: true,
2256
+ designExists: true,
2257
+ researchExists: args.research,
2258
+ taskFiles: parsed.map((task) => task.file),
2259
+ });
2260
+ const changes = [{ path: 'spec.json', body: `${JSON.stringify(spec, null, 2)}\n` }];
822
2261
  created.push('spec.json');
823
2262
 
824
2263
  // --- doc templates (placeholders left for the model to fill) ---
825
2264
  const docs = [
826
2265
  ['requirements.md', 'requirements.md'],
827
- ...(selectedPolicy.proof_obligations.includes('needsResearchGrounding')
828
- ? [['research.md', 'research.md']]
829
- : []),
2266
+ ...(args.research ? [['research.md', 'research.md']] : []),
830
2267
  ['design.md', 'design.md'],
831
2268
  ];
832
2269
  for (const [tpl, out] of docs) {
833
- const body = readTemplate(tpl).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
834
- fs.writeFileSync(path.join(specDir, out), body);
2270
+ const source = tpl === 'research.md'
2271
+ ? renderResearchTemplate(args.feature, args.uncertainty)
2272
+ : readTemplate(tpl);
2273
+ const body = applyExecutionClosureProfile(
2274
+ source,
2275
+ tpl === 'design.md'
2276
+ && (selectedPolicy.lane === 'Critical' || declaredImplementationObligations(spec).length > 0),
2277
+ ).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
2278
+ changes.push({ path: out, body });
835
2279
  created.push(out);
836
2280
  }
837
2281
 
838
- fs.writeFileSync(
839
- path.join(specDir, 'feature-receipt.md'),
840
- '# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n\nBlocker: awaiting execution proof\n',
841
- );
842
- created.push('feature-receipt.md');
843
-
844
2282
  // --- task stubs (fill the cheap placeholders; leave the rest) ---
845
2283
  for (const t of parsed) {
846
- fs.writeFileSync(path.join(specDir, t.file), fillTask(taskTpl, t, args.feature));
2284
+ changes.push({
2285
+ path: t.file,
2286
+ body: fillTask(
2287
+ taskTpl,
2288
+ t,
2289
+ args.feature,
2290
+ dependencyDeclarations.get(t.file) || [],
2291
+ taskHasDeclaredImplementationObligation(spec, t.file),
2292
+ ),
2293
+ });
847
2294
  created.push(t.file);
848
2295
  }
849
2296
 
2297
+ const snapshots = new Map();
2298
+ for (const change of changes) snapshots.set(change.path, captureTarget(workRoot, specDir, change.path));
2299
+ commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
2300
+ createSpecDir: true,
2301
+ transactionRoot: workRoot,
2302
+ afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec }),
2303
+ });
2304
+
850
2305
  const rel = path.relative(process.cwd(), specDir) || specDir;
851
2306
  console.log(`SCAFFOLDED ${rel}`);
852
- console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + feature-receipt.md + ${parsed.length} task stub(s)`);
2307
+ console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + ${parsed.length} task stub(s)`);
853
2308
  if (parsed.length > 0) console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
854
- console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready; keep feature-receipt.md PENDING until implementation evidence, then complete its canonical receipt at closeout.');
2309
+ console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready.');
855
2310
  }
856
2311
 
857
2312
  try {