@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.4

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 (91) hide show
  1. package/bin/__tests__/codex-hooks.test.js +444 -14
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +615 -185
  4. package/bin/__tests__/installer-safety.test.js +39 -0
  5. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  6. package/bin/__tests__/package-inventory.test.js +687 -13
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +1914 -221
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +143 -99
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  22. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  23. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  24. package/src/claude/hooks/completion-authority.cjs +13 -7
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/spec-gate.cjs +47 -117
  31. package/src/claude/hooks/spec-state.cjs +8 -2
  32. package/src/claude/migration-manifest.json +9 -1
  33. package/src/claude/rules/state-sync.md +52 -21
  34. package/src/claude/rules/workflow.md +17 -9
  35. package/src/claude/scripts/spec-final-state.cjs +244 -0
  36. package/src/claude/scripts/spec-ground.cjs +535 -173
  37. package/src/claude/scripts/spec-readiness.cjs +118 -0
  38. package/src/claude/scripts/spec-receipt.cjs +162 -0
  39. package/src/claude/scripts/spec-resolver.cjs +67 -16
  40. package/src/claude/scripts/spec-scaffold.cjs +1354 -139
  41. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  42. package/src/claude/scripts/validate-spec-output.cjs +2782 -142
  43. package/src/claude/scripts/workflow-policy.cjs +501 -183
  44. package/src/claude/settings/settings.json +4 -0
  45. package/src/claude/skills/code-review/SKILL.md +12 -3
  46. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  47. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  48. package/src/claude/skills/develop/SKILL.md +47 -60
  49. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  50. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  51. package/src/claude/skills/specs/SKILL.md +183 -177
  52. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  53. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  54. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  55. package/src/claude/skills/specs/references/review.md +200 -260
  56. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  57. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  58. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  59. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  60. package/src/claude/skills/specs/rules/design-review.md +42 -97
  61. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  62. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  63. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  64. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  65. package/src/claude/skills/specs/templates/design.md +49 -263
  66. package/src/claude/skills/specs/templates/requirements.md +26 -36
  67. package/src/claude/skills/specs/templates/research.md +9 -98
  68. package/src/claude/skills/specs/templates/spec-state.json +23 -85
  69. package/src/claude/skills/specs/templates/task.md +30 -102
  70. package/src/claude/skills/sync/SKILL.md +33 -10
  71. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  72. package/src/claude/skills/test/SKILL.md +17 -7
  73. package/src/codex/AGENTS.md +6 -0
  74. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  75. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  76. package/src/codex/hooks/completion-authority.cjs +13 -7
  77. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  78. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  79. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  80. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  81. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  82. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  83. package/src/codex/hooks/privacy-approval.cjs +1 -1
  84. package/src/codex/hooks/privacy-block.cjs +67 -11
  85. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  86. package/src/codex/hooks/spec-gate.cjs +49 -34
  87. package/src/codex/hooks/spec-state.cjs +10 -3
  88. package/src/codex/hooks/state.cjs +2 -0
  89. package/src/codex/hooks.json +6 -1
  90. package/src/codex/rules/state-sync.md +47 -27
  91. package/src/common/AGENTS.md +1 -1
@@ -13,21 +13,52 @@
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 PATH_SAFETY = require('../hooks/lib/runtime-path-safety.cjs');
26
32
 
27
33
  const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
28
- const TASK_ID_RE = /^R(\d+)-(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
34
+
35
+ function resolveTemplatePath(name) {
36
+ const adapterRoot = path.resolve(__dirname, '..');
37
+ const projectRoot = path.resolve(adapterRoot, '..');
38
+ const candidates = [
39
+ { file: path.join(TEMPLATES, name), root: adapterRoot },
40
+ // Packed Codex: <project>/.codex/scripts/spec-scaffold.cjs -> <project>/.agents/skills/specs/templates
41
+ {
42
+ file: path.resolve(__dirname, '..', '..', '.agents', 'skills', 'specs', 'templates', name),
43
+ root: projectRoot,
44
+ },
45
+ ];
46
+ for (const candidate of candidates) {
47
+ try {
48
+ return PATH_SAFETY.canonicalRegularFile(candidate.root, candidate.file, 'spec template');
49
+ } catch (e) {
50
+ if (e.code !== 'ENOENT' && !/cannot be inspected \(ENOENT\)/.test(e.message)) {
51
+ throw new Error(`template check failed: ${e.message} (${candidate.file})`);
52
+ }
53
+ continue;
54
+ }
55
+ }
56
+ throw new Error(`template not found: ${name} (checked ${candidates.map((item) => item.file).join(', ')})`);
57
+ }
58
+ const TASK_ID_RE = /^R(\d+)-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
29
59
  const TASK_PATH_RE = /^tasks\/task-(R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
30
60
  const FEATURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
61
+ const EXECUTION_CLOSURE_BLOCK_RE = /<!-- EXECUTION_CLOSURE_START -->[\s\S]*?<!-- EXECUTION_CLOSURE_END -->\n?/g;
31
62
  const REGISTRY_KEYS = [
32
63
  'id',
33
64
  'title',
@@ -38,11 +69,47 @@ const REGISTRY_KEYS = [
38
69
  'completed_at',
39
70
  'last_updated_at',
40
71
  ];
72
+ const AUTHORING_FIELDS = ['requirements', 'design', 'research', 'tasks'];
73
+ const AUTHORING_STATES = new Set(['draft', 'validated', 'absent']);
74
+ const SPEC_STATUS_VALUES = new Set(['in_progress', 'paused', 'blocked', 'done']);
75
+ const TASK_STATUS_VALUES = new Set(['pending', 'in_progress', 'blocked', 'done']);
76
+ const VALIDATION_STATUS_VALUES = new Set(['not-run', 'in_progress', 'completed']);
77
+ const CANONICAL_SPEC_FIELDS = new Set([
78
+ 'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
79
+ 'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
80
+ 'workflow_policy', 'research', 'task_files', 'task_registry', 'decisions',
81
+ ]);
82
+ const SCOPE_LOCK_FIELDS = ['source', 'in_scope', 'out_of_scope', 'expansion_policy'];
83
+ const COORDINATION_FIELDS = new Set(['boundaries', 'phases']);
84
+ const VALIDATION_FIELDS = ['status', 'semantic_review'];
85
+ const SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples'];
86
+ const LEGACY_SEMANTIC_REVIEW_FIELDS = [...SEMANTIC_REVIEW_FIELDS, 'reviewed_artifact_digest'];
87
+ const LEGACY_SPEC_FIELDS = [
88
+ ...CANONICAL_SPEC_FIELDS,
89
+ 'approvals', 'current_phase', 'override_receipt', 'timestamps',
90
+ ];
91
+ const LEGACY_COORDINATION_FIELDS = [
92
+ ...COORDINATION_FIELDS,
93
+ 'tasks_required', 'phases_required', 'reason', 'task_triggers',
94
+ ];
95
+ const LEGACY_TIMESTAMP_FIELDS = [
96
+ 'init', 'requirements_done', 'research_done', 'design_done', 'tasks_done',
97
+ 'code_done', 'test_done', 'review_done', 'validation_done',
98
+ ];
99
+ const LEGACY_APPROVAL_FIELDS = ['requirements', 'design', 'research', 'tasks'];
100
+ const LEGACY_APPROVAL_STATE_FIELDS = ['generated', 'agent_validated', 'user_approved'];
101
+ const LEGACY_POLICY_FIELDS = [
102
+ 'version', 'planning_depth', 'automatic_planning_depth', 'assurance_level',
103
+ 'automatic_assurance_level', 'lane', 'automatic_lane', 'risks', 'artifact_profile',
104
+ 'planning_obligations', 'proof_obligations', 'actor_needs', 'classified_minimum', 'override_receipt',
105
+ ];
106
+ const TRANSACTION_PREFIX = '.cafekit-scaffold-tx-';
107
+ const TRANSACTION_FORMAT = 'cafekit-spec-scaffold-transaction/1';
41
108
 
42
109
  class ScaffoldPreconditionError extends Error {}
43
110
 
44
111
  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"]}\']');
112
+ console.error('Usage: node spec-scaffold.cjs <feature> [--sync-semantic-model] [--tasks "R0-01-slug,R1-01-slug" --boundaries \'[{"id":"B-OWN","type":"ownership",...}]\'] [--lane Standard|Critical] [--planning-depth Compact|Full] [--assurance-level Routine|Elevated|Strict] [--research --uncertainty "..."] [--phases \'[{"id":"phase-1","task_ids":["R1-01"],"entry_condition":"...","exit_condition":"...","owner_boundary":"B-OWN"}]\'] [--risks auth,privacy] [--lang en] [--title "..."] [--specs-root specs]');
46
113
  }
47
114
 
48
115
  function failPrecondition(message) {
@@ -53,17 +120,19 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
53
120
  const adapterDir = path.dirname(__dirname);
54
121
  const adapterName = path.basename(adapterDir);
55
122
  if (path.basename(__dirname) !== 'scripts' || (adapterName !== '.claude' && adapterName !== '.codex')) return;
56
-
57
123
  const statePath = path.join(adapterDir, 'hooks', 'completion-authority-state.cjs');
58
124
  const checkPath = path.join(adapterDir, 'hooks', 'completion-authority-check.cjs');
59
125
  if (![statePath, checkPath].every((filePath) => {
60
126
  try {
61
- return fs.statSync(filePath).isFile();
62
- } catch {
63
- return false;
64
- }
127
+ const stat = fs.lstatSync(filePath);
128
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
129
+ const real = fs.realpathSync(filePath);
130
+ const realDir = fs.realpathSync(path.dirname(filePath));
131
+ const rel = path.relative(realDir, real);
132
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
133
+ return true;
134
+ } catch { return false; }
65
135
  })) return;
66
-
67
136
  let installedRoot;
68
137
  let scaffoldCwd;
69
138
  let specFile;
@@ -78,54 +147,93 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
78
147
  } catch (error) {
79
148
  throw new Error(`installed ${adapterName} scaffold authority baseline could not be verified: ${error.message}`);
80
149
  }
81
-
82
150
  let STATE;
83
151
  let check;
152
+ let runtime = {};
84
153
  try {
85
- STATE = require(statePath);
86
- check = require(checkPath);
154
+ const realStatePath = fs.realpathSync(statePath);
155
+ const realCheckPath = fs.realpathSync(checkPath);
156
+ // Ensure real paths are still inside adapterDir's realpath (no symlink escape)
157
+ const realAdapterDir = fs.realpathSync(adapterDir);
158
+ const relState = path.relative(realAdapterDir, realStatePath);
159
+ const relCheck = path.relative(realAdapterDir, realCheckPath);
160
+ if (relState.startsWith('..') || path.isAbsolute(relState) || relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
161
+ throw new Error('scaffold authority path escapes adapter');
162
+ }
163
+ STATE = require(realStatePath);
164
+ check = require(realCheckPath);
165
+ const runtimePath = path.join(installedRoot, adapterName, 'runtime.json');
166
+ if (fs.existsSync(runtimePath)) runtime = JSON.parse(fs.readFileSync(runtimePath, 'utf8'));
87
167
  } catch (error) {
88
168
  throw new Error(`installed ${adapterName} scaffold authority hooks are unavailable: ${error.message}`);
89
169
  }
90
- if (typeof check.observePolicyBaseline !== 'function') {
91
- throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
92
- }
93
-
170
+ if (typeof check.observePolicyBaseline !== 'function') throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
94
171
  let result;
95
172
  try {
96
- result = check.observePolicyBaseline({
97
- STATE,
98
- policy: POLICY,
99
- projectRoot: installedRoot,
100
- candidate: { spec, specFile, featureName: args.feature },
101
- });
173
+ result = check.observePolicyBaseline({ STATE, policy: POLICY, projectRoot: installedRoot, candidate: { spec, specFile, featureName: args.feature }, runtime });
102
174
  } catch (error) {
103
175
  throw new Error(`installed ${adapterName} scaffold authority baseline failed: ${error.message}`);
104
176
  }
105
- if (!result || result.ok !== true) {
106
- throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
107
- }
177
+ if (!result || result.ok !== true) throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
108
178
  }
109
179
 
110
180
  function parseArgs(argv) {
111
- const a = { feature: null, tasks: null, lane: null, risks: null, lang: 'en', title: null, specsRoot: 'specs', tasksOnly: false, artifacts: null };
181
+ const a = {
182
+ feature: null,
183
+ tasks: null,
184
+ lane: null,
185
+ risks: null,
186
+ lang: 'en',
187
+ title: null,
188
+ specsRoot: 'specs',
189
+ tasksOnly: false,
190
+ artifacts: null,
191
+ dependencies: null,
192
+ planningDepth: null,
193
+ assuranceLevel: null,
194
+ coordination: false,
195
+ taskTriggers: null,
196
+ boundaries: null,
197
+ phases: null,
198
+ research: false,
199
+ uncertainty: null,
200
+ syncSemanticModel: false,
201
+ };
202
+ let dependenciesSeen = false;
112
203
  for (let i = 2; i < argv.length; i++) {
113
204
  const v = argv[i];
114
205
  if (v === '--tasks') a.tasks = argv[++i];
115
206
  else if (v === '--lane') a.lane = argv[++i];
207
+ else if (v === '--planning-depth') a.planningDepth = argv[++i];
208
+ else if (v === '--assurance-level') a.assuranceLevel = argv[++i];
209
+ else if (v === '--coordination') a.coordination = true;
210
+ else if (v === '--task-triggers') a.taskTriggers = argv[++i];
211
+ else if (v === '--boundaries') a.boundaries = argv[++i];
212
+ else if (v === '--phases') a.phases = argv[++i];
213
+ else if (v === '--research') a.research = true;
214
+ else if (v === '--uncertainty') a.uncertainty = argv[++i];
116
215
  else if (v === '--risks') a.risks = argv[++i];
117
216
  else if (v === '--lang') a.lang = argv[++i];
118
217
  else if (v === '--title') a.title = argv[++i];
119
218
  else if (v === '--specs-root') a.specsRoot = argv[++i];
120
219
  else if (v === '--tasks-only') a.tasksOnly = true;
220
+ else if (v === '--sync-semantic-model') a.syncSemanticModel = true;
121
221
  else if (v === '--artifacts') a.artifacts = argv[++i];
222
+ else if (v === '--dependencies') {
223
+ if (dependenciesSeen) failPrecondition('--dependencies may be provided only once');
224
+ dependenciesSeen = true;
225
+ if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) {
226
+ failPrecondition('--dependencies requires one JSON object value');
227
+ }
228
+ a.dependencies = argv[++i];
229
+ }
122
230
  else if (!a.feature) a.feature = v;
123
231
  }
124
232
  return a;
125
233
  }
126
234
 
127
235
  function readTemplate(name) {
128
- const p = path.join(TEMPLATES, name);
236
+ const p = resolveTemplatePath(name);
129
237
  if (!fs.existsSync(p)) {
130
238
  failPrecondition(`template not found: ${p}`);
131
239
  }
@@ -137,12 +245,25 @@ function titleFromSlug(slug) {
137
245
  return s.charAt(0).toUpperCase() + s.slice(1);
138
246
  }
139
247
 
140
- function registryEntry(t, artifactDeclarations) {
248
+ function concreteUncertainty(value) {
249
+ return typeof value === 'string'
250
+ && value === value.trim()
251
+ && value.length >= 12
252
+ && !/^(?:tbd|todo|unknown|n\/a|none|placeholder)$/i.test(value);
253
+ }
254
+
255
+ function renderResearchTemplate(feature, uncertainty) {
256
+ return readTemplate('research.md')
257
+ .replace(/\{\{FEATURE_NAME\}\}/g, feature)
258
+ .replace(/\{\{UNCERTAINTY\}\}/g, uncertainty);
259
+ }
260
+
261
+ function registryEntry(t, artifactDeclarations, dependencies = []) {
141
262
  const entry = {
142
263
  id: `R${t.req}-${t.seq}`,
143
264
  title: titleFromSlug(t.slug),
144
265
  status: 'pending',
145
- dependencies: [],
266
+ dependencies: [...dependencies],
146
267
  blocker: null,
147
268
  started_at: null,
148
269
  completed_at: null,
@@ -153,6 +274,19 @@ function registryEntry(t, artifactDeclarations) {
153
274
  return entry;
154
275
  }
155
276
 
277
+ function pendingApproval() {
278
+ return 'draft';
279
+ }
280
+
281
+ function semanticReviewNotRun() {
282
+ return {
283
+ status: 'not-run',
284
+ semantic_digest: null,
285
+ reviewed_criteria: [],
286
+ counterexamples: [],
287
+ };
288
+ }
289
+
156
290
  function parseArtifactDeclarations(raw, parsedTasks) {
157
291
  if (raw === null || raw === undefined) return new Map();
158
292
  let declarations;
@@ -188,15 +322,344 @@ function parseArtifactDeclarations(raw, parsedTasks) {
188
322
  return result;
189
323
  }
190
324
 
191
- function fillTask(taskTpl, t, feature) {
192
- return taskTpl
325
+ function parseDependencyDeclarations(raw, parsedTasks, knownTaskFiles, existingRegistry = {}) {
326
+ if (raw === null || raw === undefined) return new Map();
327
+ let declarations;
328
+ try {
329
+ declarations = JSON.parse(raw);
330
+ } catch (error) {
331
+ failPrecondition(`--dependencies must be valid JSON (${error.message})`);
332
+ }
333
+ if (!isPlainObject(declarations)) {
334
+ failPrecondition('--dependencies must be an object keyed by requested task id or task path');
335
+ }
336
+
337
+ const requested = new Map();
338
+ for (const task of parsedTasks) {
339
+ requested.set(task.raw, task);
340
+ requested.set(task.file, task);
341
+ }
342
+ const known = new Map();
343
+ for (const taskPath of knownTaskFiles) {
344
+ const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
345
+ known.set(taskPath, taskPath);
346
+ known.set(identity.rawId, taskPath);
347
+ }
348
+
349
+ const result = new Map();
350
+ const declaredTargets = new Map();
351
+ for (const [key, dependencyRefs] of Object.entries(declarations)) {
352
+ const task = requested.get(key);
353
+ if (!task) failPrecondition(`--dependencies references unknown requested task "${key}"`);
354
+ const previousKey = declaredTargets.get(task.file);
355
+ if (previousKey) {
356
+ failPrecondition(
357
+ `--dependencies declares task "${task.file}" more than once after canonical resolution ` +
358
+ `("${previousKey}" and "${key}")`,
359
+ );
360
+ }
361
+ declaredTargets.set(task.file, key);
362
+ if (!Array.isArray(dependencyRefs)) {
363
+ failPrecondition(`--dependencies for "${key}" must be an array`);
364
+ }
365
+ const dependencies = [];
366
+ const seen = new Set();
367
+ for (const dependencyRef of dependencyRefs) {
368
+ if (typeof dependencyRef !== 'string') {
369
+ failPrecondition(`--dependencies for "${key}" must contain only task ids or task paths`);
370
+ }
371
+ const dependency = known.get(dependencyRef);
372
+ if (!dependency) {
373
+ failPrecondition(`--dependencies for "${key}" references unknown task "${dependencyRef}"`);
374
+ }
375
+ if (dependency === task.file) {
376
+ failPrecondition(`--dependencies for "${key}" cannot contain itself`);
377
+ }
378
+ if (seen.has(dependency)) {
379
+ failPrecondition(`--dependencies for "${key}" must not contain duplicate edges`);
380
+ }
381
+ seen.add(dependency);
382
+ dependencies.push(dependency);
383
+ }
384
+ result.set(task.file, dependencies);
385
+ }
386
+
387
+ const candidateRegistry = {};
388
+ for (const taskPath of knownTaskFiles) {
389
+ candidateRegistry[taskPath] = {
390
+ dependencies: [...(existingRegistry[taskPath]?.dependencies || [])],
391
+ };
392
+ }
393
+ for (const task of parsedTasks) {
394
+ if (!candidateRegistry[task.file]) candidateRegistry[task.file] = { dependencies: [] };
395
+ }
396
+ for (const [taskPath, dependencies] of result) {
397
+ candidateRegistry[taskPath].dependencies = dependencies;
398
+ }
399
+ validateDependencyCycles(knownTaskFiles, candidateRegistry);
400
+ return result;
401
+ }
402
+
403
+ function parsePhaseDeclarations(raw, knownTaskFiles) {
404
+ if (raw === null) return [];
405
+ let phases;
406
+ try {
407
+ phases = JSON.parse(raw);
408
+ } catch (error) {
409
+ failPrecondition(`--phases must be valid JSON (${error.message})`);
410
+ }
411
+ if (!Array.isArray(phases) || phases.length === 0) {
412
+ failPrecondition('--phases must be a non-empty JSON array');
413
+ }
414
+ const requiredKeys = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
415
+ const knownTaskIds = new Set(knownTaskFiles.map((taskPath) => parseTaskPath(taskPath, `known task ${taskPath}`).id));
416
+ const phaseIds = new Set();
417
+ return phases.map((phase, index) => {
418
+ const label = `--phases[${index}]`;
419
+ if (!isPlainObject(phase) || JSON.stringify(Object.keys(phase).sort()) !== JSON.stringify(requiredKeys)) {
420
+ failPrecondition(`${label} must contain exactly ${requiredKeys.join(', ')}`);
421
+ }
422
+ for (const key of ['id', 'entry_condition', 'exit_condition', 'owner_boundary']) {
423
+ if (typeof phase[key] !== 'string' || phase[key].trim() === '' || phase[key] !== phase[key].trim()) {
424
+ failPrecondition(`${label}.${key} must be a non-empty trimmed string`);
425
+ }
426
+ }
427
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phase.id)) {
428
+ failPrecondition(`${label}.id must be one safe identifier`);
429
+ }
430
+ if (phaseIds.has(phase.id)) failPrecondition(`--phases contains duplicate id ${phase.id}`);
431
+ phaseIds.add(phase.id);
432
+ if (!Array.isArray(phase.task_ids) || phase.task_ids.length === 0) {
433
+ failPrecondition(`${label}.task_ids must be a non-empty array`);
434
+ }
435
+ const taskIds = new Set();
436
+ for (const taskId of phase.task_ids) {
437
+ if (typeof taskId !== 'string' || !knownTaskIds.has(taskId)) {
438
+ failPrecondition(`${label}.task_ids references unknown task ${String(taskId)}`);
439
+ }
440
+ if (taskIds.has(taskId)) failPrecondition(`${label}.task_ids must not contain duplicate task ids`);
441
+ taskIds.add(taskId);
442
+ }
443
+ return cloneJson(phase);
444
+ });
445
+ }
446
+
447
+ const BOUNDARY_FIELDS = Object.freeze({
448
+ ownership: ['id', 'type', 'tasks', 'write_sets'],
449
+ dependency: ['id', 'type', 'producer', 'consumer', 'deliverable'],
450
+ transition: ['id', 'type', 'design_ref', 'owner', 'consumers', 'precondition', 'postcondition', 'failure', 'recovery'],
451
+ proof: ['id', 'type', 'subject', 'verifier', 'verification_ref', 'artifact_anchor'],
452
+ parallel: ['id', 'type', 'tasks', 'resources'],
453
+ });
454
+
455
+ function exactTarget(value, label) {
456
+ safeRelativePath(value, label);
457
+ if (/[*?{}[\]]/.test(value) || /[\\/]$/.test(value)) {
458
+ failPrecondition(`${label} must be one exact grounded target, not a glob or parent directory`);
459
+ }
460
+ return value;
461
+ }
462
+
463
+ function targetsContend(left, right) {
464
+ const a = left.replace(/\\/g, '/').replace(/\/+$/, '');
465
+ const b = right.replace(/\\/g, '/').replace(/\/+$/, '');
466
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
467
+ }
468
+
469
+ function boundaryTaskList(value, label, knownTaskIds, minimum = 1) {
470
+ if (!Array.isArray(value) || value.length < minimum) {
471
+ failPrecondition(`${label} must contain at least ${minimum} task id(s)`);
472
+ }
473
+ const seen = new Set();
474
+ for (const taskId of value) {
475
+ if (!knownTaskIds.has(taskId)) failPrecondition(`${label} references unknown task ${String(taskId)}`);
476
+ if (seen.has(taskId)) failPrecondition(`${label} must not contain duplicate task ids`);
477
+ seen.add(taskId);
478
+ }
479
+ return [...value];
480
+ }
481
+
482
+ function boundaryTargetMap(value, tasks, label) {
483
+ if (!isPlainObject(value) || JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...tasks].sort())) {
484
+ failPrecondition(`${label} must be an object keyed exactly by its task ids`);
485
+ }
486
+ const result = {};
487
+ for (const taskId of tasks) {
488
+ if (!Array.isArray(value[taskId]) || value[taskId].length === 0) {
489
+ failPrecondition(`${label}.${taskId} must be a non-empty exact target array`);
490
+ }
491
+ result[taskId] = value[taskId].map((target, index) => exactTarget(target, `${label}.${taskId}[${index}]`));
492
+ if (new Set(result[taskId]).size !== result[taskId].length) {
493
+ failPrecondition(`${label}.${taskId} must not contain duplicate targets`);
494
+ }
495
+ }
496
+ for (let left = 0; left < tasks.length; left += 1) {
497
+ for (let right = left + 1; right < tasks.length; right += 1) {
498
+ for (const a of result[tasks[left]]) {
499
+ for (const b of result[tasks[right]]) {
500
+ if (targetsContend(a, b)) {
501
+ failPrecondition(`${label} has resource contention between ${tasks[left]}:${a} and ${tasks[right]}:${b}`);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ return result;
508
+ }
509
+
510
+ function parseBoundaryDeclarations(raw, knownTaskFiles) {
511
+ if (raw === null || raw === undefined) return [];
512
+ let boundaries;
513
+ try { boundaries = JSON.parse(raw); } catch (error) {
514
+ failPrecondition(`--boundaries must be valid JSON (${error.message})`);
515
+ }
516
+ if (!Array.isArray(boundaries) || boundaries.length === 0) {
517
+ failPrecondition('--boundaries must be a non-empty JSON array');
518
+ }
519
+ const taskPathById = new Map(knownTaskFiles.map((taskPath) => {
520
+ const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
521
+ return [identity.id, taskPath];
522
+ }));
523
+ const knownTaskIds = new Set(taskPathById.keys());
524
+ const ids = new Set();
525
+ const result = boundaries.map((boundary, index) => {
526
+ const label = `--boundaries[${index}]`;
527
+ if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
528
+ failPrecondition(`${label}.type must be ownership, dependency, transition, proof, or parallel`);
529
+ }
530
+ if (JSON.stringify(Object.keys(boundary).sort()) !== JSON.stringify([...BOUNDARY_FIELDS[boundary.type]].sort())) {
531
+ failPrecondition(`${label} ${boundary.type} fields must be exactly ${BOUNDARY_FIELDS[boundary.type].join(', ')}`);
532
+ }
533
+ if (typeof boundary.id !== 'string' || !/^B-[A-Z0-9][A-Z0-9._-]*$/i.test(boundary.id)) {
534
+ failPrecondition(`${label}.id must be a safe B-* identifier`);
535
+ }
536
+ if (ids.has(boundary.id)) failPrecondition(`--boundaries contains duplicate id ${boundary.id}`);
537
+ ids.add(boundary.id);
538
+ if (boundary.type === 'ownership') {
539
+ const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
540
+ boundaryTargetMap(boundary.write_sets, tasks, `${label}.write_sets`);
541
+ } else if (boundary.type === 'dependency') {
542
+ boundaryTaskList([boundary.producer, boundary.consumer], label, knownTaskIds, 2);
543
+ if (boundary.producer === boundary.consumer) failPrecondition(`${label} producer and consumer must differ`);
544
+ exactTarget(boundary.deliverable, `${label}.deliverable`);
545
+ } else if (boundary.type === 'transition') {
546
+ boundaryTaskList([boundary.owner], `${label}.owner`, knownTaskIds);
547
+ boundaryTaskList(boundary.consumers, `${label}.consumers`, knownTaskIds);
548
+ if (boundary.consumers.includes(boundary.owner)) failPrecondition(`${label}.consumers cannot include transition owner`);
549
+ if (!/^T\d+(?:\.\d+)?$/i.test(boundary.design_ref)) failPrecondition(`${label}.design_ref must be a T id`);
550
+ for (const field of ['precondition', 'postcondition', 'failure', 'recovery']) {
551
+ if (typeof boundary[field] !== 'string' || boundary[field].trim().length < 8) {
552
+ failPrecondition(`${label}.${field} must be a concrete definition`);
553
+ }
554
+ }
555
+ } else if (boundary.type === 'proof') {
556
+ boundaryTaskList([boundary.subject, boundary.verifier], label, knownTaskIds, 2);
557
+ if (boundary.subject === boundary.verifier) failPrecondition(`${label} subject and verifier must differ`);
558
+ if (!/^V\d+(?:\.\d+)?$/i.test(boundary.verification_ref)) failPrecondition(`${label}.verification_ref must be a V id`);
559
+ if (!new RegExp(`^A-${boundary.verifier}-\\d{2}$`).test(boundary.artifact_anchor)) {
560
+ failPrecondition(`${label}.artifact_anchor must be owned by verifier ${boundary.verifier}`);
561
+ }
562
+ } else if (boundary.type === 'parallel') {
563
+ const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
564
+ boundaryTargetMap(boundary.resources, tasks, `${label}.resources`);
565
+ }
566
+ return cloneJson(boundary);
567
+ });
568
+
569
+ const dependencies = result.filter((boundary) => boundary.type === 'dependency');
570
+ const graph = new Map([...knownTaskIds].map((taskId) => [taskId, []]));
571
+ for (const boundary of dependencies) graph.get(boundary.producer).push(boundary.consumer);
572
+ const visiting = new Set();
573
+ const visited = new Set();
574
+ const visit = (taskId, stack = []) => {
575
+ if (visiting.has(taskId)) {
576
+ const start = stack.indexOf(taskId);
577
+ failPrecondition(`coordination.boundaries dependency cycle: ${stack.slice(start).concat(taskId).join(' -> ')}`);
578
+ }
579
+ if (visited.has(taskId)) return;
580
+ visiting.add(taskId);
581
+ for (const consumer of graph.get(taskId)) visit(consumer, [...stack, taskId]);
582
+ visiting.delete(taskId);
583
+ visited.add(taskId);
584
+ };
585
+ for (const taskId of knownTaskIds) visit(taskId);
586
+ const pathExists = (from, to, seen = new Set()) => {
587
+ if (from === to) return true;
588
+ if (seen.has(from)) return false;
589
+ seen.add(from);
590
+ return graph.get(from).some((next) => pathExists(next, to, seen));
591
+ };
592
+ for (const boundary of result.filter((candidate) => candidate.type === 'parallel')) {
593
+ for (let left = 0; left < boundary.tasks.length; left += 1) {
594
+ for (let right = left + 1; right < boundary.tasks.length; right += 1) {
595
+ if (pathExists(boundary.tasks[left], boundary.tasks[right])
596
+ || pathExists(boundary.tasks[right], boundary.tasks[left])) {
597
+ failPrecondition(`${boundary.id}: parallel tasks must have no dependency path`);
598
+ }
599
+ }
600
+ }
601
+ const authorities = result.filter((candidate) => ['transition', 'proof'].includes(candidate.type));
602
+ for (const authority of authorities) {
603
+ const owners = authority.type === 'transition'
604
+ ? [authority.owner, ...authority.consumers]
605
+ : [authority.subject, authority.verifier];
606
+ if (boundary.tasks.filter((taskId) => owners.includes(taskId)).length > 1) {
607
+ failPrecondition(`${boundary.id}: parallel tasks cannot share ${authority.type} authority ${authority.id}`);
608
+ }
609
+ }
610
+ }
611
+ return result;
612
+ }
613
+
614
+ function dependenciesFromBoundaries(boundaries, knownTaskFiles) {
615
+ const pathById = new Map(knownTaskFiles.map((taskPath) => [parseTaskPath(taskPath, taskPath).id, taskPath]));
616
+ const result = new Map(knownTaskFiles.map((taskPath) => [taskPath, []]));
617
+ for (const boundary of boundaries) {
618
+ if (boundary.type !== 'dependency') continue;
619
+ result.get(pathById.get(boundary.consumer)).push(pathById.get(boundary.producer));
620
+ }
621
+ for (const [taskPath, dependencies] of result) result.set(taskPath, [...new Set(dependencies)].sort());
622
+ return result;
623
+ }
624
+
625
+ function validatePhaseBoundaryRefs(phases, boundaries) {
626
+ const boundaryIds = new Set(boundaries.map((boundary) => boundary.id));
627
+ for (const phase of phases) {
628
+ if (!boundaryIds.has(phase.owner_boundary)) {
629
+ failPrecondition(`phase ${phase.id}.owner_boundary references unknown boundary ${phase.owner_boundary}`);
630
+ }
631
+ }
632
+ }
633
+
634
+ function applyExecutionClosureProfile(content, includeExecutionClosure) {
635
+ if (!includeExecutionClosure) return content.replace(EXECUTION_CLOSURE_BLOCK_RE, '');
636
+ return content
637
+ .replace('<!-- EXECUTION_CLOSURE_START -->\n', '')
638
+ .replace('<!-- EXECUTION_CLOSURE_END -->\n', '');
639
+ }
640
+
641
+ function declaredImplementationObligations(spec) {
642
+ return Array.isArray(spec?.implementation_obligations)
643
+ ? spec.implementation_obligations.filter(isPlainObject)
644
+ : [];
645
+ }
646
+
647
+ function taskHasDeclaredImplementationObligation(spec, taskPath) {
648
+ return declaredImplementationObligations(spec).some((obligation) => (
649
+ obligation.owner_task === taskPath
650
+ || (Array.isArray(obligation.consumer_tasks) && obligation.consumer_tasks.includes(taskPath))
651
+ ));
652
+ }
653
+
654
+ function fillTask(taskTpl, t, feature, dependencies = [], includeExecutionClosure = false) {
655
+ return applyExecutionClosureProfile(taskTpl, includeExecutionClosure)
193
656
  .replace(/\{\{REQ_NUMBER\}\}/g, t.req)
194
657
  .replace(/\{\{SEQ\}\}/g, t.seq)
195
658
  .replace(/\{\{TITLE\}\}/g, titleFromSlug(t.slug))
196
659
  .replace(/\{\{FEATURE_NAME\}\}/g, feature)
197
660
  .replace(/\{\{PRIORITY\}\}/g, 'P2')
198
661
  .replace(/\{\{EFFORT\}\}/g, 'TBD')
199
- .replace(/\{\{DEPENDENCIES\}\}/g, 'none');
662
+ .replace(/\{\{DEPENDENCIES\}\}/g, dependencies.length > 0 ? dependencies.join(', ') : 'none');
200
663
  }
201
664
 
202
665
  function parseRiskNames(raw) {
@@ -236,6 +699,148 @@ function cloneJson(value) {
236
699
  return JSON.parse(JSON.stringify(value));
237
700
  }
238
701
 
702
+ function assertExactKeys(value, expected, label) {
703
+ if (!isPlainObject(value)) failPrecondition(`${label} must be an object`);
704
+ const actual = Object.keys(value).sort();
705
+ const wanted = [...expected].sort();
706
+ if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
707
+ const unknown = actual.filter((key) => !wanted.includes(key));
708
+ const missing = wanted.filter((key) => !actual.includes(key));
709
+ failPrecondition(
710
+ `${label} must be closed-world; unknown=[${unknown.join(', ')}], missing=[${missing.join(', ')}]`,
711
+ );
712
+ }
713
+ }
714
+
715
+ function canonicalRegistryFields(entry) {
716
+ return hasOwn(entry, 'artifacts') ? [...REGISTRY_KEYS, 'artifacts'] : REGISTRY_KEYS;
717
+ }
718
+
719
+ function assertCanonicalSpecInputClosedWorld(spec) {
720
+ if (!isPlainObject(spec) || spec.schema_version !== '2.1') return;
721
+ const topLevel = [...CANONICAL_SPEC_FIELDS].filter((field) => {
722
+ if (field === 'research') return hasOwn(spec, 'research');
723
+ if (field === 'task_files' || field === 'task_registry') return hasOwn(spec, field);
724
+ if (field === 'decisions') return hasOwn(spec, 'decisions');
725
+ return true;
726
+ });
727
+ assertExactKeys(spec, topLevel, 'canonical schema 2.1 spec.json');
728
+ assertExactKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'canonical schema 2.1 scope_lock');
729
+ assertExactKeys(spec.authoring, AUTHORING_FIELDS, 'canonical schema 2.1 authoring');
730
+ const coordinationFields = hasOwn(spec.coordination, 'phases')
731
+ ? [...COORDINATION_FIELDS] : ['boundaries'];
732
+ assertExactKeys(spec.coordination, coordinationFields, 'canonical schema 2.1 coordination');
733
+ assertExactKeys(spec.validation, VALIDATION_FIELDS, 'canonical schema 2.1 validation');
734
+ assertExactKeys(
735
+ spec.validation.semantic_review,
736
+ SEMANTIC_REVIEW_FIELDS,
737
+ 'canonical schema 2.1 validation.semantic_review',
738
+ );
739
+ if (Array.isArray(spec.validation.semantic_review?.counterexamples)) {
740
+ const fields = ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'];
741
+ for (const [index, counterexample] of spec.validation.semantic_review.counterexamples.entries()) {
742
+ assertExactKeys(
743
+ counterexample,
744
+ fields,
745
+ `canonical schema 2.1 validation.semantic_review.counterexamples[${index}]`,
746
+ );
747
+ }
748
+ }
749
+ if (hasOwn(spec, 'decisions')) {
750
+ if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) failPrecondition('canonical schema 2.1 decisions must be omitted or a non-empty array');
751
+ for (const [index, decision] of spec.decisions.entries()) {
752
+ assertExactKeys(decision, ['id', 'classification', 'statement', 'status', 'evidence'], `canonical schema 2.1 decisions[${index}]`);
753
+ }
754
+ }
755
+ if (Array.isArray(spec.coordination?.boundaries)) {
756
+ for (const [index, boundary] of spec.coordination.boundaries.entries()) {
757
+ if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
758
+ failPrecondition(`canonical schema 2.1 coordination.boundaries[${index}] has an unknown type`);
759
+ }
760
+ assertExactKeys(
761
+ boundary,
762
+ BOUNDARY_FIELDS[boundary.type],
763
+ `canonical schema 2.1 coordination.boundaries[${index}]`,
764
+ );
765
+ }
766
+ }
767
+ if (Array.isArray(spec.coordination?.phases)) {
768
+ const fields = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
769
+ for (const [index, phase] of spec.coordination.phases.entries()) {
770
+ assertExactKeys(phase, fields, `canonical schema 2.1 coordination.phases[${index}]`);
771
+ }
772
+ }
773
+ if (hasOwn(spec, 'task_registry') && isPlainObject(spec.task_registry)) {
774
+ for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
775
+ assertExactKeys(entry, canonicalRegistryFields(entry), `canonical schema 2.1 task_registry.${taskPath}`);
776
+ }
777
+ }
778
+ }
779
+
780
+ function rejectUnknownKeys(value, allowed, label) {
781
+ if (!isPlainObject(value)) return;
782
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
783
+ if (unknown.length) {
784
+ failPrecondition(`${label} contains unsupported legacy authority field(s): ${unknown.join(', ')}; remove or explicitly migrate them before schema 2.1 promotion`);
785
+ }
786
+ }
787
+
788
+ function assertLegacyMigrationClosedWorld(spec) {
789
+ rejectUnknownKeys(spec, LEGACY_SPEC_FIELDS, 'schema 2.0 spec.json');
790
+ rejectUnknownKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'schema 2.0 scope_lock');
791
+ rejectUnknownKeys(spec.authoring, AUTHORING_FIELDS, 'schema 2.0 authoring');
792
+ rejectUnknownKeys(spec.coordination, LEGACY_COORDINATION_FIELDS, 'schema 2.0 coordination');
793
+ rejectUnknownKeys(spec.validation, VALIDATION_FIELDS, 'schema 2.0 validation');
794
+ rejectUnknownKeys(spec.validation?.semantic_review, LEGACY_SEMANTIC_REVIEW_FIELDS, 'schema 2.0 validation.semantic_review');
795
+ for (const [index, counterexample] of (spec.validation?.semantic_review?.counterexamples || []).entries()) {
796
+ rejectUnknownKeys(counterexample, ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'], `schema 2.0 semantic_review.counterexamples[${index}]`);
797
+ }
798
+ for (const [index, boundary] of (spec.coordination?.boundaries || []).entries()) {
799
+ if (!isPlainObject(boundary) || !hasOwn(BOUNDARY_FIELDS, boundary.type)) {
800
+ failPrecondition(`schema 2.0 coordination.boundaries[${index}] has an unknown authority type`);
801
+ }
802
+ rejectUnknownKeys(boundary, BOUNDARY_FIELDS[boundary.type], `schema 2.0 coordination.boundaries[${index}]`);
803
+ }
804
+ if (isPlainObject(spec.workflow_policy)) {
805
+ rejectUnknownKeys(spec.workflow_policy, LEGACY_POLICY_FIELDS, 'schema 2.0 workflow_policy');
806
+ }
807
+ if (hasOwn(spec, 'current_phase')
808
+ && (typeof spec.current_phase !== 'string' || spec.current_phase.trim() === '')) {
809
+ failPrecondition('schema 2.0 current_phase must be a non-empty string when present');
810
+ }
811
+ if (hasOwn(spec, 'timestamps')) {
812
+ if (!isPlainObject(spec.timestamps)) failPrecondition('schema 2.0 timestamps must be an object');
813
+ rejectUnknownKeys(spec.timestamps, LEGACY_TIMESTAMP_FIELDS, 'schema 2.0 timestamps');
814
+ for (const [field, value] of Object.entries(spec.timestamps)) {
815
+ if (value !== null && !isIsoTimestamp(value)) {
816
+ failPrecondition(`schema 2.0 timestamps.${field} must be null or a valid ISO 8601 timestamp`);
817
+ }
818
+ }
819
+ }
820
+ if (hasOwn(spec, 'approvals')) {
821
+ if (!isPlainObject(spec.approvals)) failPrecondition('schema 2.0 approvals must be an object');
822
+ rejectUnknownKeys(spec.approvals, LEGACY_APPROVAL_FIELDS, 'schema 2.0 approvals');
823
+ for (const [section, state] of Object.entries(spec.approvals)) {
824
+ if (!isPlainObject(state)) failPrecondition(`schema 2.0 approvals.${section} must be an object`);
825
+ rejectUnknownKeys(state, LEGACY_APPROVAL_STATE_FIELDS, `schema 2.0 approvals.${section}`);
826
+ for (const [field, value] of Object.entries(state)) {
827
+ if (typeof value !== 'boolean') {
828
+ failPrecondition(`schema 2.0 approvals.${section}.${field} must be boolean`);
829
+ }
830
+ }
831
+ }
832
+ }
833
+ if (isPlainObject(spec.task_registry)) for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
834
+ rejectUnknownKeys(entry, canonicalRegistryFields(entry), `schema 2.0 task_registry.${taskPath}`);
835
+ }
836
+ }
837
+
838
+ function isIsoTimestamp(value) {
839
+ return typeof value === 'string'
840
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
841
+ && Number.isFinite(Date.parse(value));
842
+ }
843
+
239
844
  function isWithin(root, target) {
240
845
  const relative = path.relative(path.resolve(root), path.resolve(target));
241
846
  return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
@@ -339,7 +944,9 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
339
944
  const identity = parseTaskPath(taskPath, `task_registry.${taskPath}`);
340
945
  if (entry.id !== identity.id) failPrecondition(`task_registry.${taskPath}.id conflicts with its task path`);
341
946
  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`);
947
+ if (!TASK_STATUS_VALUES.has(entry.status)) {
948
+ failPrecondition(`task_registry.${taskPath}.status must be one of ${[...TASK_STATUS_VALUES].join(', ')}`);
949
+ }
343
950
  if (!Array.isArray(entry.dependencies)) failPrecondition(`task_registry.${taskPath}.dependencies must be an array`);
344
951
  const dependencies = new Set();
345
952
  for (const dependency of entry.dependencies) {
@@ -350,6 +957,244 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
350
957
  dependencies.add(dependency);
351
958
  }
352
959
  if (hasOwn(entry, 'artifacts')) validateArtifactList(entry.artifacts, `task_registry.${taskPath}.artifacts`);
960
+ const label = `task_registry.${taskPath}`;
961
+ if (entry.blocker !== null && (typeof entry.blocker !== 'string' || entry.blocker.trim() === '')) {
962
+ failPrecondition(`${label}.blocker must be null or a non-empty string`);
963
+ }
964
+ for (const field of ['started_at', 'completed_at', 'last_updated_at']) {
965
+ if (entry[field] !== null && !isIsoTimestamp(entry[field])) {
966
+ failPrecondition(`${label}.${field} must be null or an ISO 8601 timestamp`);
967
+ }
968
+ }
969
+ if (entry.status === 'pending') {
970
+ if (entry.blocker !== null || entry.started_at !== null || entry.completed_at !== null) {
971
+ failPrecondition(`${label} pending lifecycle is inconsistent`);
972
+ }
973
+ } else if (entry.status === 'in_progress') {
974
+ if (entry.blocker !== null || !isIsoTimestamp(entry.started_at)
975
+ || entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
976
+ || Date.parse(entry.last_updated_at) < Date.parse(entry.started_at)) {
977
+ failPrecondition(`${label} in_progress lifecycle is inconsistent`);
978
+ }
979
+ } else if (entry.status === 'blocked') {
980
+ if (typeof entry.blocker !== 'string' || entry.blocker.trim() === ''
981
+ || entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
982
+ || (isIsoTimestamp(entry.started_at) && Date.parse(entry.last_updated_at) < Date.parse(entry.started_at))) {
983
+ failPrecondition(`${label} blocked lifecycle is inconsistent`);
984
+ }
985
+ } else if (entry.status === 'done') {
986
+ if (entry.blocker !== null
987
+ || ![entry.started_at, entry.completed_at, entry.last_updated_at].every(isIsoTimestamp)
988
+ || Date.parse(entry.completed_at) < Date.parse(entry.started_at)
989
+ || Date.parse(entry.last_updated_at) < Date.parse(entry.completed_at)) {
990
+ failPrecondition(`${label} done lifecycle is inconsistent`);
991
+ }
992
+ }
993
+ }
994
+
995
+ function semanticReviewShapeValid(review) {
996
+ return isPlainObject(review)
997
+ && ['not-run', 'completed'].includes(review.status)
998
+ && (review.semantic_digest === null || typeof review.semantic_digest === 'string')
999
+ && Array.isArray(review.reviewed_criteria)
1000
+ && Array.isArray(review.counterexamples);
1001
+ }
1002
+
1003
+ function preflightSpec21Candidate(spec, evidence) {
1004
+ if (!isPlainObject(spec)) failPrecondition('schema 2.1 candidate must be an object');
1005
+ assertCanonicalSpecInputClosedWorld(spec);
1006
+ if (hasOwn(spec, 'override_receipt')) {
1007
+ failPrecondition('schema 2.1 candidate override_receipt is unsupported; omit the field');
1008
+ }
1009
+ const required = [
1010
+ 'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
1011
+ 'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
1012
+ 'workflow_policy',
1013
+ ];
1014
+ for (const field of required) if (!hasOwn(spec, field)) {
1015
+ failPrecondition(`schema 2.1 candidate is missing required machine field ${field}`);
1016
+ }
1017
+ if (spec.schema_version !== '2.1') failPrecondition('schema 2.1 candidate has an unsupported schema_version');
1018
+ if (spec.feature_name !== evidence.featureName) failPrecondition('feature_name conflicts with the scaffold feature identity');
1019
+ if (!isIsoTimestamp(spec.created_at) || !isIsoTimestamp(spec.updated_at)
1020
+ || Date.parse(spec.updated_at) < Date.parse(spec.created_at)) {
1021
+ failPrecondition('schema 2.1 candidate lifecycle timestamps are invalid');
1022
+ }
1023
+ if (typeof spec.language !== 'string' || spec.language.trim() === '') failPrecondition('schema 2.1 candidate language must be non-empty');
1024
+ if (!SPEC_STATUS_VALUES.has(spec.status)) failPrecondition('schema 2.1 candidate status is invalid');
1025
+ if (!isPlainObject(spec.scope_lock)) failPrecondition('schema 2.1 candidate scope_lock must be an object');
1026
+ if (!isPlainObject(spec.authoring)
1027
+ || Object.keys(spec.authoring).sort().join(',') !== [...AUTHORING_FIELDS].sort().join(',')) {
1028
+ failPrecondition('schema 2.1 candidate authoring must contain exactly requirements, design, research, and tasks');
1029
+ }
1030
+ for (const field of AUTHORING_FIELDS) if (!AUTHORING_STATES.has(spec.authoring[field])) {
1031
+ failPrecondition(`schema 2.1 candidate authoring.${field} is invalid`);
1032
+ }
1033
+ if (!evidence.requirementsExists || !evidence.designExists) {
1034
+ failPrecondition('schema 2.1 migration requires physical requirements.md and design.md files');
1035
+ }
1036
+ if (spec.authoring.requirements === 'absent' || spec.authoring.design === 'absent') {
1037
+ failPrecondition('physical requirements.md and design.md cannot be marked absent');
1038
+ }
1039
+ if (evidence.researchExists) {
1040
+ if (spec.research !== 'research.md' || spec.authoring.research === 'absent') {
1041
+ failPrecondition('research pointer, authoring state, and physical research.md must agree');
1042
+ }
1043
+ } else if (hasOwn(spec, 'research') || spec.authoring.research !== 'absent') {
1044
+ failPrecondition('absent physical research.md requires no pointer and authoring.research absent');
1045
+ }
1046
+ if (!isPlainObject(spec.coordination) || !Array.isArray(spec.coordination.boundaries)) {
1047
+ failPrecondition('schema 2.1 candidate coordination.boundaries must be an array');
1048
+ }
1049
+ if (!isPlainObject(spec.validation) || !VALIDATION_STATUS_VALUES.has(spec.validation.status)
1050
+ || !semanticReviewShapeValid(spec.validation.semantic_review)) {
1051
+ failPrecondition('schema 2.1 candidate validation state is invalid');
1052
+ }
1053
+ if (typeof spec.ready_for_implementation !== 'boolean') failPrecondition('schema 2.1 candidate readiness must be boolean');
1054
+ if (spec.semantic_model !== null) {
1055
+ const semanticErrors = SEMANTIC.validateSemanticModel(spec.semantic_model, spec);
1056
+ if (semanticErrors.length) failPrecondition(`schema 2.1 candidate semantic_model is invalid (${semanticErrors.join('; ')})`);
1057
+ } else if (spec.ready_for_implementation === true) {
1058
+ failPrecondition('schema 2.1 readiness requires a promoted semantic_model');
1059
+ }
1060
+ if (isPlainObject(spec.workflow_policy) && hasOwn(spec.workflow_policy, 'override_receipt')) {
1061
+ failPrecondition('schema 2.1 candidate workflow_policy.override_receipt is unsupported; omit the field');
1062
+ }
1063
+ const policyValidation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
1064
+ if (!policyValidation.valid || spec.workflow_policy.version !== '2.1') {
1065
+ failPrecondition(`schema 2.1 candidate workflow_policy is invalid (${policyValidation.errors.join('; ')})`);
1066
+ }
1067
+ const taskFiles = validateTaskPathList(spec.task_files || [], 'task_files');
1068
+ if (JSON.stringify(taskFiles) !== JSON.stringify([...evidence.taskFiles].sort())) {
1069
+ failPrecondition('schema 2.1 candidate task_files do not match the resulting physical topology');
1070
+ }
1071
+ if (taskFiles.length === 0) {
1072
+ if (hasOwn(spec, 'task_files') || hasOwn(spec, 'task_registry') || spec.authoring.tasks !== 'absent') {
1073
+ failPrecondition('taskless schema 2.1 candidate must omit task topology and mark tasks absent');
1074
+ }
1075
+ } else {
1076
+ if (!isPlainObject(spec.task_registry)
1077
+ || Object.keys(spec.task_registry).sort().join(',') !== taskFiles.join(',')) {
1078
+ failPrecondition('schema 2.1 candidate task_registry must exactly match task_files');
1079
+ }
1080
+ if (!['draft', 'validated'].includes(spec.authoring.tasks)) {
1081
+ failPrecondition('physical tasks require draft or validated authoring state');
1082
+ }
1083
+ const declared = new Set(taskFiles);
1084
+ for (const taskPath of taskFiles) validateRegistryEntry(taskPath, spec.task_registry[taskPath], declared);
1085
+ validateDependencyCycles(taskFiles, spec.task_registry);
1086
+ }
1087
+ }
1088
+
1089
+ function normalizeSpec21Candidate(candidate, evidence) {
1090
+ try {
1091
+ if (!isPlainObject(candidate)) failPrecondition('migration candidate must be an object');
1092
+ if (!['2.0', '2.1'].includes(candidate.schema_version)) {
1093
+ failPrecondition('migration candidate schema_version must be exactly 2.0 or 2.1');
1094
+ }
1095
+ const isLegacy = candidate.schema_version === '2.0';
1096
+ if (isLegacy) assertLegacyMigrationClosedWorld(candidate);
1097
+ if (!isLegacy) assertCanonicalSpecInputClosedWorld(candidate);
1098
+ const featureName = !hasOwn(candidate, 'feature_name') && isLegacy
1099
+ ? evidence.featureName : candidate.feature_name;
1100
+ if (typeof featureName !== 'string' || featureName.trim() === ''
1101
+ || featureName !== evidence.featureName) {
1102
+ failPrecondition('existing feature_name conflicts with the scaffold feature identity');
1103
+ }
1104
+ if (!isIsoTimestamp(candidate.created_at) || !isIsoTimestamp(candidate.updated_at)) {
1105
+ failPrecondition('existing created_at and updated_at must be valid ISO 8601 timestamps');
1106
+ }
1107
+ if (typeof candidate.language !== 'string' || candidate.language.trim() === '') {
1108
+ failPrecondition('existing language must be a non-empty string');
1109
+ }
1110
+ const status = isLegacy && candidate.status === 'in-progress' ? 'in_progress' : candidate.status;
1111
+ if (!SPEC_STATUS_VALUES.has(status)) failPrecondition('existing spec status is invalid');
1112
+ if (!isPlainObject(candidate.scope_lock)) failPrecondition('existing scope_lock must be an object');
1113
+ const scopeLock = {
1114
+ source: candidate.scope_lock.source,
1115
+ in_scope: candidate.scope_lock.in_scope,
1116
+ out_of_scope: candidate.scope_lock.out_of_scope,
1117
+ expansion_policy: candidate.scope_lock.expansion_policy,
1118
+ };
1119
+ if (typeof scopeLock.source !== 'string' || scopeLock.source.trim() === ''
1120
+ || !Array.isArray(scopeLock.in_scope) || !Array.isArray(scopeLock.out_of_scope)
1121
+ || typeof scopeLock.expansion_policy !== 'string' || scopeLock.expansion_policy.trim() === '') {
1122
+ failPrecondition('existing scope_lock canonical fields are invalid');
1123
+ }
1124
+
1125
+ const previousAuthoring = isPlainObject(candidate.authoring) ? candidate.authoring : {};
1126
+ const authoring = {};
1127
+ for (const field of ['requirements', 'design']) {
1128
+ if (!evidence[`${field}Exists`]) failPrecondition(`physical ${field}.md is required for schema 2.1 migration`);
1129
+ authoring[field] = previousAuthoring[field] === 'validated' ? 'validated' : 'draft';
1130
+ }
1131
+ if (evidence.researchExists) {
1132
+ const pointer = isPlainObject(candidate.research) ? candidate.research.path : candidate.research;
1133
+ if (pointer !== undefined && pointer !== null && pointer !== 'research.md') {
1134
+ failPrecondition('legacy research pointer is ambiguous; expected canonical research.md');
1135
+ }
1136
+ authoring.research = previousAuthoring.research === 'validated' && !evidence.researchChanged
1137
+ ? 'validated' : 'draft';
1138
+ } else {
1139
+ if (hasOwn(candidate, 'research') && candidate.research !== null) {
1140
+ failPrecondition('research pointer exists without a physical research.md artifact');
1141
+ }
1142
+ authoring.research = 'absent';
1143
+ }
1144
+ if (evidence.taskFiles.length > 0) {
1145
+ authoring.tasks = previousAuthoring.tasks === 'validated' && !evidence.tasksChanged
1146
+ ? 'validated' : 'draft';
1147
+ } else {
1148
+ authoring.tasks = 'absent';
1149
+ }
1150
+ const projectedRegistry = {};
1151
+ for (const taskPath of evidence.taskFiles) {
1152
+ const entry = candidate.task_registry?.[taskPath];
1153
+ if (!isPlainObject(entry)) failPrecondition(`task_registry.${taskPath} must be an object`);
1154
+ projectedRegistry[taskPath] = {};
1155
+ for (const key of canonicalRegistryFields(entry)) {
1156
+ if (hasOwn(entry, key)) projectedRegistry[taskPath][key] = cloneJson(entry[key]);
1157
+ }
1158
+ }
1159
+ const validationValid = isPlainObject(candidate.validation)
1160
+ && VALIDATION_STATUS_VALUES.has(candidate.validation.status)
1161
+ && semanticReviewShapeValid(candidate.validation.semantic_review);
1162
+ const readinessValid = typeof candidate.ready_for_implementation === 'boolean';
1163
+ const next = {
1164
+ schema_version: '2.1',
1165
+ feature_name: featureName,
1166
+ created_at: candidate.created_at,
1167
+ updated_at: candidate.updated_at,
1168
+ language: candidate.language,
1169
+ status,
1170
+ scope_lock: scopeLock,
1171
+ authoring,
1172
+ coordination: cloneJson(evidence.coordination),
1173
+ validation: validationValid ? cloneJson(candidate.validation) : {
1174
+ status: 'not-run', semantic_review: semanticReviewNotRun(),
1175
+ },
1176
+ semantic_model: isLegacy ? null : cloneJson(candidate.semantic_model),
1177
+ ready_for_implementation: readinessValid ? candidate.ready_for_implementation : false,
1178
+ workflow_policy: cloneJson(evidence.workflowPolicy),
1179
+ ...(evidence.researchExists ? { research: 'research.md' } : {}),
1180
+ ...(evidence.taskFiles.length > 0 ? {
1181
+ task_files: [...evidence.taskFiles].sort(), task_registry: projectedRegistry,
1182
+ } : {}),
1183
+ };
1184
+ const semanticChanged = evidence.tasksChanged || evidence.researchChanged
1185
+ || isLegacy || JSON.stringify(next) !== JSON.stringify(candidate);
1186
+ if (semanticChanged || !validationValid || !readinessValid) {
1187
+ next.ready_for_implementation = false;
1188
+ next.validation = { status: 'not-run', semantic_review: semanticReviewNotRun() };
1189
+ next.semantic_model = null;
1190
+ }
1191
+ const changed = JSON.stringify(next) !== JSON.stringify(candidate);
1192
+ if (changed) next.updated_at = evidence.timestamp;
1193
+ preflightSpec21Candidate(next, evidence);
1194
+ return { ok: true, spec: next, changed };
1195
+ } catch (error) {
1196
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1197
+ }
353
1198
  }
354
1199
 
355
1200
  function validateDependencyCycles(taskFiles, registry) {
@@ -388,7 +1233,12 @@ function listExistingTaskFiles(workRoot, specDir) {
388
1233
 
389
1234
  function readPersistedPolicy(spec) {
390
1235
  if (hasOwn(spec, 'override_receipt')) {
391
- failPrecondition('existing spec contains legacy override_receipt; persist only workflow_policy.override_receipt');
1236
+ if (spec.override_receipt !== null) {
1237
+ failPrecondition('existing top-level override_receipt is unsupported; omit the field');
1238
+ }
1239
+ if (spec.schema_version !== '2.0') {
1240
+ failPrecondition('top-level override_receipt is only readable as inert null during schema 2.0 migration; omit the field');
1241
+ }
392
1242
  }
393
1243
  if (!hasOwn(spec, 'workflow_policy')) {
394
1244
  const legacy = spec.design_context?.execution_tier;
@@ -398,6 +1248,11 @@ function readPersistedPolicy(spec) {
398
1248
  : 'existing spec must contain a persisted workflow_policy snapshot',
399
1249
  );
400
1250
  }
1251
+ if (isPlainObject(spec.workflow_policy)
1252
+ && hasOwn(spec.workflow_policy, 'override_receipt')
1253
+ && spec.workflow_policy.override_receipt !== null) {
1254
+ failPrecondition('existing workflow_policy.override_receipt is unsupported; omit the field');
1255
+ }
401
1256
  try {
402
1257
  return POLICY.readWorkflowPolicySnapshot(spec);
403
1258
  } catch (error) {
@@ -415,7 +1270,7 @@ function validateResearchTopology(workRoot, specDir, spec, needsResearch) {
415
1270
  assertNoSymlinkPath(workRoot, pointerPath, 'spec.research path');
416
1271
  if (!isWithin(specDir, pointerPath)) failPrecondition('spec.research path must stay inside the spec directory');
417
1272
  if (needsResearch && safePointer !== canonical) {
418
- failPrecondition('spec.research path must be research.md when needsResearchGrounding is required');
1273
+ failPrecondition('spec.research path must be research.md for an explicit --research request');
419
1274
  }
420
1275
  assertRegularFileOrMissing(workRoot, pointerPath, 'spec.research path');
421
1276
  }
@@ -462,7 +1317,7 @@ function inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclaratio
462
1317
  for (const task of parsed) {
463
1318
  if (!existingSet.has(task.file)) continue;
464
1319
  const entry = registry[task.file];
465
- const expected = registryEntry(task, new Map());
1320
+ const expected = registryEntry(task, artifactDeclarations);
466
1321
  if (entry.id !== expected.id || entry.title !== expected.title) {
467
1322
  failPrecondition(`conflicting duplicate task ${task.raw}: existing registry entry differs`);
468
1323
  }
@@ -504,20 +1359,191 @@ function parseFailureInjection() {
504
1359
  return Number(raw);
505
1360
  }
506
1361
 
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-'));
1362
+ function parseCrashInjection() {
1363
+ const raw = process.env.CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES;
1364
+ if (raw === undefined) return null;
1365
+ if (!/^[1-9]\d*$/.test(raw)) {
1366
+ failPrecondition('CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES must be a positive integer');
1367
+ }
1368
+ return Number(raw);
1369
+ }
1370
+
1371
+ function digest(bytes) {
1372
+ return crypto.createHash('sha256').update(bytes).digest('hex');
1373
+ }
1374
+
1375
+ function fsyncPath(filePath) {
1376
+ const fd = fs.openSync(filePath, 'r');
1377
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
1378
+ }
1379
+
1380
+ function fsyncDirectory(directory) {
1381
+ try { fsyncPath(directory); } catch (error) {
1382
+ if (!['EINVAL', 'ENOTSUP', 'EISDIR'].includes(error.code)) throw error;
1383
+ }
1384
+ }
1385
+
1386
+ function fileDigestOrNull(filePath) {
1387
+ try {
1388
+ const stat = fs.lstatSync(filePath);
1389
+ if (!stat.isFile()) throw new Error(`transaction path is not a regular file: ${filePath}`);
1390
+ return digest(fs.readFileSync(filePath));
1391
+ } catch (error) {
1392
+ if (error.code === 'ENOENT') return null;
1393
+ throw error;
1394
+ }
1395
+ }
1396
+
1397
+ function processIsAlive(pid) {
1398
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return pid === process.pid;
1399
+ try { process.kill(pid, 0); return true; } catch (error) {
1400
+ return error.code === 'EPERM';
1401
+ }
1402
+ }
1403
+
1404
+ function validateTransactionManifest(workRoot, transactionDir, manifest) {
1405
+ if (!isPlainObject(manifest) || manifest.format !== TRANSACTION_FORMAT) {
1406
+ throw new Error(`unrecognized scaffold transaction manifest: ${transactionDir}`);
1407
+ }
1408
+ const transactionId = path.basename(transactionDir).slice(TRANSACTION_PREFIX.length);
1409
+ if (manifest.transaction_id !== transactionId || !/^[a-f0-9-]{20,}$/i.test(transactionId)) {
1410
+ throw new Error(`scaffold transaction identity mismatch: ${transactionDir}`);
1411
+ }
1412
+ if (path.resolve(manifest.work_root || '') !== workRoot) {
1413
+ throw new Error(`scaffold transaction work root mismatch: ${transactionDir}`);
1414
+ }
1415
+ const specRelative = safeRelativePath(manifest.spec_dir, 'transaction spec_dir');
1416
+ const specDir = path.resolve(workRoot, specRelative);
1417
+ if (!isWithin(workRoot, specDir) || !Array.isArray(manifest.entries) || manifest.entries.length === 0) {
1418
+ throw new Error(`invalid scaffold transaction topology: ${transactionDir}`);
1419
+ }
1420
+ const seen = new Set();
1421
+ for (const entry of manifest.entries) {
1422
+ if (!isPlainObject(entry)) throw new Error(`invalid scaffold transaction entry: ${transactionDir}`);
1423
+ const relativePath = safeRelativePath(entry.path, 'transaction entry path');
1424
+ if (seen.has(relativePath)) throw new Error(`duplicate scaffold transaction path: ${relativePath}`);
1425
+ seen.add(relativePath);
1426
+ if (!isPlainObject(entry.before) || typeof entry.before.exists !== 'boolean'
1427
+ || (entry.before.exists && !/^[a-f0-9]{64}$/.test(entry.before.digest || ''))
1428
+ || !/^[a-f0-9]{64}$/.test(entry.after_digest || '')) {
1429
+ throw new Error(`invalid scaffold transaction digest evidence: ${relativePath}`);
1430
+ }
1431
+ }
1432
+ return { ...manifest, specDir };
1433
+ }
1434
+
1435
+ function removeEmptyTransactionParents(specDir, target) {
1436
+ let current = path.dirname(target);
1437
+ while (isWithin(specDir, current) && current !== path.dirname(specDir)) {
1438
+ try { fs.rmdirSync(current); } catch (error) {
1439
+ if (error.code === 'ENOENT') break;
1440
+ if (['ENOTEMPTY', 'EEXIST'].includes(error.code)) break;
1441
+ throw error;
1442
+ }
1443
+ if (current === specDir) break;
1444
+ current = path.dirname(current);
1445
+ }
1446
+ }
1447
+
1448
+ function recoverTransaction(workRoot, transactionDir, rawManifest) {
1449
+ const manifest = validateTransactionManifest(workRoot, transactionDir, rawManifest);
1450
+ if (processIsAlive(manifest.pid)) return false;
1451
+ for (let index = manifest.entries.length - 1; index >= 0; index -= 1) {
1452
+ const entry = manifest.entries[index];
1453
+ const target = path.join(manifest.specDir, entry.path);
1454
+ const backup = path.join(transactionDir, 'backup', entry.path);
1455
+ const targetDigest = fileDigestOrNull(target);
1456
+ const backupDigest = fileDigestOrNull(backup);
1457
+ if (entry.before.exists) {
1458
+ if (backupDigest !== null && backupDigest !== entry.before.digest) {
1459
+ throw new Error(`recovery cannot prove backup ownership for ${entry.path}`);
1460
+ }
1461
+ if (targetDigest === entry.after_digest) {
1462
+ removeTarget(target);
1463
+ } else if (targetDigest !== null && targetDigest !== entry.before.digest) {
1464
+ throw new Error(`recovery refuses to overwrite unowned target ${entry.path}`);
1465
+ }
1466
+ if (fileDigestOrNull(target) === null) {
1467
+ if (backupDigest !== entry.before.digest) {
1468
+ throw new Error(`recovery is missing the owned backup for ${entry.path}`);
1469
+ }
1470
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1471
+ fs.renameSync(backup, target);
1472
+ }
1473
+ } else if (targetDigest === entry.after_digest) {
1474
+ removeTarget(target);
1475
+ } else if (targetDigest !== null) {
1476
+ throw new Error(`recovery refuses to delete unowned target ${entry.path}`);
1477
+ }
1478
+ removeEmptyTransactionParents(manifest.specDir, target);
1479
+ }
1480
+ fs.rmSync(transactionDir, { recursive: true, force: true });
1481
+ fsyncDirectory(workRoot);
1482
+ return true;
1483
+ }
1484
+
1485
+ function recoverTransactions(workRoot) {
1486
+ let recovered = 0;
1487
+ for (const entry of fs.readdirSync(workRoot, { withFileTypes: true })) {
1488
+ if (!entry.name.startsWith(TRANSACTION_PREFIX)) continue;
1489
+ const transactionDir = path.join(workRoot, entry.name);
1490
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
1491
+ failPrecondition(`scaffold transaction identity is not a directory: ${transactionDir}`);
1492
+ }
1493
+ const manifestPath = path.join(transactionDir, 'manifest.json');
1494
+ let manifest;
1495
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (error) {
1496
+ failPrecondition(`scaffold transaction cannot be recovered (${error.message}): ${transactionDir}`);
1497
+ }
1498
+ try {
1499
+ if (recoverTransaction(workRoot, transactionDir, manifest)) recovered += 1;
1500
+ } catch (error) {
1501
+ failPrecondition(`scaffold transaction recovery stopped safely (${error.message})`);
1502
+ }
1503
+ }
1504
+ return recovered;
1505
+ }
1506
+
1507
+ function prepareTransaction(workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir) {
1508
+ assertExistingDirectory(workRoot, transactionRoot, 'scaffold transaction directory');
1509
+ const transactionId = crypto.randomUUID();
1510
+ const transactionDir = path.join(transactionRoot, `${TRANSACTION_PREFIX}${transactionId}`);
1511
+ const stageDir = path.join(transactionDir, 'stage');
1512
+ const backupDir = path.join(transactionDir, 'backup');
1513
+ fs.mkdirSync(stageDir, { recursive: true });
1514
+ fs.mkdirSync(backupDir);
1515
+ const entries = [];
511
1516
  try {
512
1517
  for (const change of changes) {
513
1518
  safeRelativePath(change.path, `staged path ${change.path}`);
514
1519
  const staged = path.join(stageDir, change.path);
515
1520
  fs.mkdirSync(path.dirname(staged), { recursive: true });
516
1521
  fs.writeFileSync(staged, change.body, { encoding: 'utf8', flag: 'wx' });
1522
+ fsyncPath(staged);
1523
+ const before = snapshots.get(change.path);
1524
+ entries.push({
1525
+ path: change.path,
1526
+ before: { exists: before.exists, ...(before.exists ? { digest: digest(before.bytes) } : {}) },
1527
+ after_digest: digest(Buffer.from(change.body, 'utf8')),
1528
+ });
517
1529
  }
518
- return stageDir;
1530
+ const manifest = {
1531
+ format: TRANSACTION_FORMAT,
1532
+ transaction_id: transactionId,
1533
+ pid: process.pid,
1534
+ work_root: workRoot,
1535
+ spec_dir: path.relative(workRoot, specDir),
1536
+ create_spec_dir: createSpecDir,
1537
+ entries,
1538
+ };
1539
+ const manifestPath = path.join(transactionDir, 'manifest.json');
1540
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
1541
+ fsyncPath(manifestPath);
1542
+ fsyncDirectory(transactionDir);
1543
+ fsyncDirectory(transactionRoot);
1544
+ return { transactionDir, stageDir, backupDir, manifest };
519
1545
  } catch (error) {
520
- fs.rmSync(stageDir, { recursive: true, force: true });
1546
+ fs.rmSync(transactionDir, { recursive: true, force: true });
521
1547
  throw error;
522
1548
  }
523
1549
  }
@@ -538,6 +1564,21 @@ function ensureTargetParent(workRoot, specDir, relativePath, createdDirs) {
538
1564
  }
539
1565
  }
540
1566
 
1567
+ function ensureDirectoryTree(workRoot, directory, createdDirs, label) {
1568
+ assertNoSymlinkPath(workRoot, directory, label);
1569
+ const relative = path.relative(path.resolve(workRoot), path.resolve(directory));
1570
+ let current = path.resolve(workRoot);
1571
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
1572
+ current = path.join(current, segment);
1573
+ if (fs.existsSync(current)) {
1574
+ assertExistingDirectory(workRoot, current, label);
1575
+ } else {
1576
+ fs.mkdirSync(current);
1577
+ createdDirs.push(current);
1578
+ }
1579
+ }
1580
+ }
1581
+
541
1582
  function removeTarget(target) {
542
1583
  try {
543
1584
  const stat = fs.lstatSync(target);
@@ -548,40 +1589,32 @@ function removeTarget(target) {
548
1589
  }
549
1590
  }
550
1591
 
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 = [];
1592
+ function commitChanges(
1593
+ workRoot,
1594
+ specDir,
1595
+ changes,
1596
+ snapshots,
1597
+ failureAfter,
1598
+ { createSpecDir = false, afterApply = null, transactionRoot = path.dirname(specDir) } = {},
1599
+ ) {
1600
+ const transaction = prepareTransaction(
1601
+ workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir,
1602
+ );
582
1603
  const createdDirs = [];
583
1604
  let writes = 0;
1605
+ let renames = 0;
1606
+ const crashAfter = parseCrashInjection();
1607
+ const renameBoundary = (from, to) => {
1608
+ fs.renameSync(from, to);
1609
+ renames += 1;
1610
+ fsyncDirectory(path.dirname(to));
1611
+ if (crashAfter !== null && renames >= crashAfter) process.kill(process.pid, 'SIGKILL');
1612
+ };
584
1613
  try {
1614
+ if (createSpecDir) {
1615
+ if (fs.existsSync(specDir)) failPrecondition(`spec dir already exists: ${specDir}`);
1616
+ ensureDirectoryTree(workRoot, specDir, createdDirs, 'spec directory');
1617
+ }
585
1618
  for (const change of changes) {
586
1619
  const target = path.join(specDir, change.path);
587
1620
  const before = captureTarget(workRoot, specDir, change.path);
@@ -590,50 +1623,68 @@ function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
590
1623
  failPrecondition(`target changed during preflight: ${change.path}`);
591
1624
  }
592
1625
  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);
1626
+ const backup = path.join(transaction.backupDir, change.path);
600
1627
  if (before.exists) {
601
- fs.mkdirSync(path.dirname(record.backup), { recursive: true });
602
- fs.renameSync(target, record.backup);
603
- record.backupMoved = true;
1628
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
1629
+ renameBoundary(target, backup);
604
1630
  }
605
- fs.renameSync(path.join(stageDir, change.path), target);
606
- record.targetInstalled = true;
1631
+ renameBoundary(path.join(transaction.stageDir, change.path), target);
607
1632
  writes += 1;
608
1633
  if (failureAfter !== null && writes >= failureAfter) {
609
1634
  throw new Error(`injected failure after ${writes} staged write(s)`);
610
1635
  }
611
1636
  }
1637
+ if (typeof afterApply === 'function') afterApply();
612
1638
  } catch (error) {
613
1639
  let rollbackError = null;
614
1640
  try {
615
- rollbackChanges(applied, createdDirs, backupDir);
1641
+ const manifest = { ...transaction.manifest, pid: -1 };
1642
+ recoverTransaction(workRoot, transaction.transactionDir, manifest);
616
1643
  } catch (rollbackFailure) {
617
1644
  rollbackError = rollbackFailure;
618
1645
  }
619
- fs.rmSync(stageDir, { recursive: true, force: true });
620
1646
  if (rollbackError) throw new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
621
1647
  throw new Error(`${error.message}; transaction rolled back`);
622
1648
  }
623
- fs.rmSync(backupDir, { recursive: true, force: true });
624
- fs.rmSync(stageDir, { recursive: true, force: true });
1649
+ fs.rmSync(transaction.transactionDir, { recursive: true, force: true });
1650
+ fsyncDirectory(transactionRoot);
625
1651
  }
626
1652
 
627
1653
  function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, parsed, artifactDeclarations, taskTpl, ts }) {
628
1654
  const workRoot = path.resolve(process.cwd());
629
1655
  assertExistingDirectory(workRoot, specDir, 'spec directory');
630
1656
  assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
1657
+ const requirementsPath = path.join(specDir, 'requirements.md');
1658
+ const designPath = path.join(specDir, 'design.md');
1659
+ assertRegularFileOrMissing(workRoot, requirementsPath, 'requirements.md');
1660
+ assertRegularFileOrMissing(workRoot, designPath, 'design.md');
631
1661
  const topology = inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations);
1662
+ const allTaskFiles = [...new Set([...topology.declared, ...parsed.map((task) => task.file)])].sort();
1663
+ if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
1664
+ failPrecondition('schema 2.1 mutation uses --boundaries as the only topology authority');
1665
+ }
1666
+ const boundaries = parseBoundaryDeclarations(args.boundaries, allTaskFiles);
1667
+ if (boundaries.length === 0) {
1668
+ failPrecondition('--tasks-only requires a complete non-empty --boundaries array for the resulting task bundle');
1669
+ }
1670
+ const dependencyDeclarations = dependenciesFromBoundaries(boundaries, allTaskFiles);
1671
+ const phases = args.phases === null && Array.isArray(spec.coordination?.phases)
1672
+ ? cloneJson(spec.coordination.phases)
1673
+ : parsePhaseDeclarations(args.phases, allTaskFiles);
1674
+ validatePhaseBoundaryRefs(phases, boundaries);
1675
+ for (const task of parsed) {
1676
+ if (!topology.existingSet.has(task.file) || !dependencyDeclarations.has(task.file)) continue;
1677
+ const existing = topology.registry[task.file].dependencies;
1678
+ const requested = dependencyDeclarations.get(task.file);
1679
+ if (JSON.stringify(existing) !== JSON.stringify(requested)) {
1680
+ failPrecondition(`conflicting duplicate task ${task.raw}: existing registry dependencies differ`);
1681
+ }
1682
+ }
632
1683
 
633
- let nextPolicy = persistedPolicy;
1684
+ let nextPolicy = POLICY.canonicalWorkflowPolicySnapshot(spec);
634
1685
  if (args.risks && args.risks.length > 0) {
635
1686
  try {
636
- nextPolicy = POLICY.escalateWorkflowPolicy(spec, { risks: args.risks });
1687
+ nextPolicy = POLICY.escalateWorkflowPolicy(nextPolicy, { risks: args.risks });
637
1688
  } catch (error) {
638
1689
  failPrecondition(`--tasks-only cannot escalate workflow policy (${error.message})`);
639
1690
  }
@@ -641,40 +1692,61 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
641
1692
  if (!validation.valid) failPrecondition(`--tasks-only escalation produced an invalid workflow_policy (${validation.errors.join('; ')})`);
642
1693
  }
643
1694
 
644
- const needsResearch = nextPolicy.proof_obligations.includes('needsResearchGrounding');
1695
+ const needsResearch = args.research === true;
645
1696
  const researchPath = validateResearchTopology(workRoot, specDir, spec, needsResearch);
646
1697
  const researchExists = fs.existsSync(researchPath);
647
1698
  const researchTemplate = needsResearch && !researchExists
648
- ? readTemplate('research.md').replace(/\{\{FEATURE_NAME\}\}/g, args.feature)
1699
+ ? renderResearchTemplate(args.feature, args.uncertainty)
649
1700
  : null;
650
1701
  const newTasks = parsed.filter((task) => !topology.existingSet.has(task.file));
651
1702
  const nextSpec = cloneJson(spec);
652
- let specChanged = false;
653
1703
  if (!topology.hasTaskTopology && newTasks.length > 0) {
654
1704
  nextSpec.task_files = [];
655
1705
  nextSpec.task_registry = {};
656
- specChanged = true;
657
1706
  }
658
1707
  for (const task of newTasks) {
659
1708
  nextSpec.task_files.push(task.file);
660
- nextSpec.task_registry[task.file] = registryEntry(task, artifactDeclarations);
661
- specChanged = true;
1709
+ nextSpec.task_registry[task.file] = registryEntry(
1710
+ task,
1711
+ artifactDeclarations,
1712
+ dependencyDeclarations.get(task.file) || [],
1713
+ );
662
1714
  }
663
- if (JSON.stringify(nextPolicy) !== JSON.stringify(persistedPolicy)) {
664
- nextSpec.workflow_policy = nextPolicy;
665
- specChanged = true;
1715
+ if (needsResearch && nextSpec.research !== 'research.md') {
1716
+ nextSpec.research = 'research.md';
666
1717
  }
667
- if (specChanged) {
668
- nextSpec.task_files.sort();
669
- nextSpec.updated_at = ts;
1718
+ nextSpec.task_files.sort();
1719
+ const normalized = normalizeSpec21Candidate(nextSpec, {
1720
+ featureName: args.feature,
1721
+ timestamp: ts,
1722
+ requirementsExists: fs.existsSync(requirementsPath),
1723
+ designExists: fs.existsSync(designPath),
1724
+ researchExists: researchExists || researchTemplate !== null,
1725
+ researchChanged: researchTemplate !== null,
1726
+ taskFiles: allTaskFiles,
1727
+ tasksChanged: newTasks.length > 0,
1728
+ coordination: { boundaries, ...(phases.length > 0 ? { phases } : {}) },
1729
+ workflowPolicy: nextPolicy,
1730
+ });
1731
+ if (!normalized.ok) failPrecondition(`schema 2.1 migration rejected candidate (${normalized.error})`);
1732
+ const normalizedSpec = normalized.spec;
1733
+ const specChanged = JSON.stringify(normalizedSpec) !== JSON.stringify(spec);
1734
+ if (specChanged && normalizedSpec.updated_at === spec.created_at && ts !== spec.created_at) {
1735
+ failPrecondition('schema 2.1 migration produced a stale updated_at timestamp');
670
1736
  }
671
1737
 
672
1738
  const changes = newTasks.map((task) => ({
673
1739
  path: task.file,
674
- body: fillTask(taskTpl, task, args.feature),
1740
+ body: fillTask(
1741
+ taskTpl,
1742
+ task,
1743
+ args.feature,
1744
+ dependencyDeclarations.get(task.file) || [],
1745
+ taskHasDeclaredImplementationObligation(normalizedSpec, task.file),
1746
+ ),
675
1747
  }));
676
1748
  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` });
1749
+ if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(normalizedSpec, null, 2)}\n` });
678
1750
  if (changes.length === 0) return { createdTasks: [], researchCreated: false };
679
1751
 
680
1752
  const expectedAbsent = new Set(newTasks.map((task) => task.file));
@@ -687,22 +1759,101 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
687
1759
  }
688
1760
  snapshots.set(change.path, snapshot);
689
1761
  }
690
- commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection());
1762
+ commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
1763
+ transactionRoot: workRoot,
1764
+ afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec: normalizedSpec }),
1765
+ });
691
1766
  return { createdTasks: newTasks.map((task) => task.file), researchCreated: researchTemplate !== null };
692
1767
  }
693
1768
 
1769
+ function syncSemanticModel({ workRoot, args, specDir, ts }) {
1770
+ assertExistingDirectory(workRoot, specDir, 'spec directory');
1771
+ const specPath = path.join(specDir, 'spec.json');
1772
+ assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
1773
+ if (!fs.existsSync(specPath)) failPrecondition('--sync-semantic-model requires an existing spec.json');
1774
+ let spec;
1775
+ try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
1776
+ catch (error) { failPrecondition(`--sync-semantic-model spec.json is invalid JSON (${error.message})`); }
1777
+ if (!isPlainObject(spec) || spec.schema_version !== '2.1') failPrecondition('--sync-semantic-model accepts canonical schema 2.1 only');
1778
+ assertCanonicalSpecInputClosedWorld(spec);
1779
+ const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
1780
+ if (projection.errors.length) failPrecondition(`semantic projection is invalid (${projection.errors.join('; ')})`);
1781
+ const modelUnchanged = SEMANTIC.stableJson(spec.semantic_model) === SEMANTIC.stableJson(projection.model);
1782
+ if (modelUnchanged) {
1783
+ console.log(`SEMANTIC_MODEL_UNCHANGED ${path.relative(process.cwd(), specDir) || specDir}`);
1784
+ return;
1785
+ }
1786
+ const next = cloneJson(spec);
1787
+ next.semantic_model = projection.model;
1788
+ next.updated_at = ts;
1789
+ next.ready_for_implementation = false;
1790
+ next.validation = { status: 'not-run', semantic_review: semanticReviewNotRun() };
1791
+ preflightSpec21Candidate(next, {
1792
+ featureName: args.feature,
1793
+ requirementsExists: fs.existsSync(path.join(specDir, 'requirements.md')),
1794
+ designExists: fs.existsSync(path.join(specDir, 'design.md')),
1795
+ researchExists: fs.existsSync(path.join(specDir, 'research.md')),
1796
+ taskFiles: listExistingTaskFiles(workRoot, specDir),
1797
+ });
1798
+ const snapshot = captureTarget(workRoot, specDir, 'spec.json');
1799
+ commitChanges(
1800
+ workRoot,
1801
+ specDir,
1802
+ [{ path: 'spec.json', body: `${JSON.stringify(next, null, 2)}\n` }],
1803
+ new Map([['spec.json', snapshot]]),
1804
+ parseFailureInjection(),
1805
+ { transactionRoot: workRoot },
1806
+ );
1807
+ console.log(`SEMANTIC_MODEL_SYNCED ${path.relative(process.cwd(), specDir) || specDir}`);
1808
+ }
1809
+
694
1810
  function main() {
1811
+ const workRoot = path.resolve(process.cwd());
1812
+ const recoveredTransactions = recoverTransactions(workRoot);
1813
+ if (recoveredTransactions > 0) {
1814
+ console.error(`recovered ${recoveredTransactions} interrupted scaffold transaction(s)`);
1815
+ }
695
1816
  const args = parseArgs(process.argv);
696
- if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.lane === 'Critical' && !args.tasks)) {
1817
+ if (args.syncSemanticModel) {
1818
+ if (!args.feature || args.tasks !== null || args.tasksOnly || args.boundaries !== null || args.research
1819
+ || args.planningDepth || args.assuranceLevel || args.lane) {
1820
+ failPrecondition('--sync-semantic-model is an isolated explicit promotion mode and cannot be combined with authoring mutations');
1821
+ }
1822
+ safeRelativePath(args.specsRoot, '--specs-root');
1823
+ safeRelativePath(args.feature, 'feature');
1824
+ const specDir = path.resolve(workRoot, args.specsRoot, args.feature);
1825
+ if (!isWithin(workRoot, specDir)) failPrecondition(`spec directory escapes the work root: ${specDir}`);
1826
+ syncSemanticModel({ workRoot, args, specDir, ts: nowIso() });
1827
+ return;
1828
+ }
1829
+ if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.coordination && !args.tasks)) {
697
1830
  usage();
698
1831
  failPrecondition('missing feature or required task arguments');
699
1832
  }
1833
+ if (args.research && !concreteUncertainty(args.uncertainty)) {
1834
+ failPrecondition('--research requires --uncertainty with one concrete unresolved question (at least 12 trimmed characters)');
1835
+ }
1836
+ if (!args.research && args.uncertainty !== null) {
1837
+ failPrecondition('--uncertainty is only valid with --research');
1838
+ }
700
1839
  if (typeof args.lane === 'string' && args.lane.toLowerCase() === 'direct') {
701
1840
  failPrecondition('Direct lane does not create a spec; run targeted verification from the Direct workflow instead');
702
1841
  }
703
1842
  if (args.lane && !POLICY.LANES.includes(args.lane)) {
704
1843
  failPrecondition(`--lane must be one of ${POLICY.LANES.join(', ')}`);
705
1844
  }
1845
+ if (args.planningDepth && !POLICY.PLANNING_DEPTHS.includes(args.planningDepth)) {
1846
+ failPrecondition(`--planning-depth must be one of ${POLICY.PLANNING_DEPTHS.join(', ')}`);
1847
+ }
1848
+ if (args.planningDepth === 'None') {
1849
+ failPrecondition('None planning depth does not create a durable spec; use the Direct workflow instead');
1850
+ }
1851
+ if (args.assuranceLevel && !POLICY.ASSURANCE_LEVELS.includes(args.assuranceLevel)) {
1852
+ failPrecondition(`--assurance-level must be one of ${POLICY.ASSURANCE_LEVELS.join(', ')}`);
1853
+ }
1854
+ if (args.lane && (args.planningDepth || args.assuranceLevel)) {
1855
+ failPrecondition('--lane is a compatibility adapter and cannot be combined with Specs v2 axis flags');
1856
+ }
706
1857
 
707
1858
  const ids = (args.tasks || '').split(',').map((s) => s.trim()).filter(Boolean);
708
1859
  if (args.tasksOnly && ids.length === 0) {
@@ -720,10 +1871,29 @@ function main() {
720
1871
  seenIds.add(logicalId);
721
1872
  parsed.push({ raw: id, req: m[1], seq: m[2], slug: m[3], file: `tasks/task-${id}.md` });
722
1873
  }
1874
+ if (args.phases !== null && parsed.length === 0) {
1875
+ failPrecondition('--phases requires --tasks because phase grouping needs a task graph');
1876
+ }
1877
+ if (args.phases !== null && args.planningDepth !== 'Full') {
1878
+ failPrecondition('--phases requires explicit --planning-depth Full before any scaffold writes');
1879
+ }
723
1880
  const artifactDeclarations = parseArtifactDeclarations(args.artifacts, parsed);
1881
+ if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
1882
+ failPrecondition('schema 2.1 authoring uses --boundaries as the only topology authority; task triggers, coordination markers, dependencies, and artifact maps are read compatibility only');
1883
+ }
1884
+ const boundaries = args.tasksOnly ? null : parseBoundaryDeclarations(
1885
+ args.boundaries,
1886
+ parsed.map((task) => task.file).sort(),
1887
+ );
1888
+ const dependencyDeclarations = args.tasksOnly
1889
+ ? null
1890
+ : dependenciesFromBoundaries(boundaries, parsed.map((task) => task.file).sort());
724
1891
  const riskNames = parseRiskNames(args.risks);
1892
+ const phases = args.tasksOnly
1893
+ ? []
1894
+ : parsePhaseDeclarations(args.phases, parsed.map((task) => task.file));
1895
+ if (!args.tasksOnly) validatePhaseBoundaryRefs(phases, boundaries);
725
1896
 
726
- const workRoot = path.resolve(process.cwd());
727
1897
  safeRelativePath(args.specsRoot, '--specs-root');
728
1898
  safeRelativePath(args.feature, 'feature');
729
1899
  if (!FEATURE_NAME_RE.test(args.feature)) {
@@ -750,7 +1920,12 @@ function main() {
750
1920
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
751
1921
  failPrecondition('--tasks-only existing spec.json must contain a JSON object');
752
1922
  }
1923
+ assertCanonicalSpecInputClosedWorld(spec);
1924
+ if (spec.schema_version === '2.0') assertLegacyMigrationClosedWorld(spec);
753
1925
  const persistedPolicy = readPersistedPolicy(spec);
1926
+ if (args.planningDepth && args.planningDepth !== persistedPolicy.planning_depth) {
1927
+ failPrecondition(`--tasks-only --planning-depth ${args.planningDepth} does not match persisted workflow_policy.planning_depth ${persistedPolicy.planning_depth}`);
1928
+ }
754
1929
  if (args.lane && args.lane !== persistedPolicy.lane) {
755
1930
  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
1931
  }
@@ -769,89 +1944,129 @@ function main() {
769
1944
  const relTo = path.relative(process.cwd(), specDir) || specDir;
770
1945
  console.log(`SCAFFOLDED (tasks-only) ${relTo}`);
771
1946
  console.log(`- ${createdTasks.length} new task stub(s); task_files + task_registry merged.`);
772
- if (result.researchCreated) console.log('- research.md created for needsResearchGrounding.');
1947
+ if (result.researchCreated) console.log('- research.md created by explicit --research request.');
773
1948
  console.log(`- NEXT: Edit-fill {{...}} in each stub; set dependencies; run validator + spec-ground.`);
774
1949
  return;
775
1950
  }
776
1951
 
777
- const policyInput = args.lane ? { risks: riskNames, override: args.lane } : { risks: riskNames };
1952
+ const policyInput = args.lane
1953
+ ? { risks: riskNames, override: args.lane }
1954
+ : {
1955
+ risks: riskNames,
1956
+ ...(args.planningDepth ? { planning_depth: args.planningDepth } : {}),
1957
+ ...(args.assuranceLevel ? { assurance_level: args.assuranceLevel } : {}),
1958
+ };
778
1959
  let selectedPolicy;
779
1960
  try {
780
1961
  selectedPolicy = POLICY.workflowPolicySnapshot(policyInput);
781
1962
  } catch (error) {
782
1963
  failPrecondition(error.message);
783
1964
  }
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);
1965
+ if (parsed.length > 0 && boundaries.length === 0) {
1966
+ failPrecondition('physical task bundle requires at least one typed coordination.boundaries entry with semantic evidence');
1967
+ }
1968
+ if (parsed.length === 0 && args.boundaries !== null) {
1969
+ failPrecondition('--boundaries requires --tasks');
787
1970
  }
788
-
789
1971
  if (fs.existsSync(specDir)) {
790
1972
  console.error(`precondition: spec dir already exists, refusing to overwrite: ${specDir}`);
791
1973
  process.exit(2);
792
1974
  }
793
1975
 
794
- fs.mkdirSync(specDir, { recursive: true });
795
- if (parsed.length > 0) {
796
- fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
797
- }
798
-
799
1976
  // --- spec.json (from spec-state.json template) ---
800
1977
  const spec = JSON.parse(readTemplate('spec-state.json'));
801
1978
  spec.feature_name = args.feature;
802
1979
  spec.created_at = ts;
803
1980
  spec.updated_at = ts;
804
1981
  spec.language = args.lang;
805
- spec.timestamps = spec.timestamps || {};
806
- spec.timestamps.init = ts;
807
1982
  spec.scope_lock = spec.scope_lock || {};
808
1983
  spec.scope_lock.source = args.title || `{{PROJECT_DESCRIPTION}}`;
809
1984
  delete spec.workflow_policy;
810
1985
  delete spec.override_receipt;
811
1986
  Object.assign(spec, POLICY.persistWorkflowPolicySnapshot(spec, policyInput));
1987
+ spec.validation = isPlainObject(spec.validation) ? spec.validation : {};
1988
+ spec.validation.semantic_review = semanticReviewNotRun();
1989
+ if (args.research) {
1990
+ spec.research = 'research.md';
1991
+ spec.authoring.research = 'draft';
1992
+ }
1993
+ spec.coordination = {
1994
+ boundaries,
1995
+ ...(phases.length > 0 ? { phases } : {}),
1996
+ };
812
1997
  if (parsed.length > 0) {
1998
+ spec.authoring.tasks = 'draft';
813
1999
  spec.task_files = parsed.map((t) => t.file);
814
2000
  spec.task_registry = {};
815
- for (const t of parsed) spec.task_registry[t.file] = registryEntry(t, artifactDeclarations);
2001
+ for (const t of parsed) {
2002
+ spec.task_registry[t.file] = registryEntry(
2003
+ t,
2004
+ artifactDeclarations,
2005
+ dependencyDeclarations.get(t.file) || [],
2006
+ );
2007
+ }
816
2008
  } else {
817
2009
  delete spec.task_files;
818
2010
  delete spec.task_registry;
819
2011
  }
820
- fs.writeFileSync(path.join(specDir, 'spec.json'), JSON.stringify(spec, null, 2) + '\n');
821
- observeInstalledPolicyBaseline({ args, specDir, spec });
2012
+ if (args.research) spec.authoring.research = 'draft';
2013
+ preflightSpec21Candidate(spec, {
2014
+ featureName: args.feature,
2015
+ requirementsExists: true,
2016
+ designExists: true,
2017
+ researchExists: args.research,
2018
+ taskFiles: parsed.map((task) => task.file),
2019
+ });
2020
+ const changes = [{ path: 'spec.json', body: `${JSON.stringify(spec, null, 2)}\n` }];
822
2021
  created.push('spec.json');
823
2022
 
824
2023
  // --- doc templates (placeholders left for the model to fill) ---
825
2024
  const docs = [
826
2025
  ['requirements.md', 'requirements.md'],
827
- ...(selectedPolicy.proof_obligations.includes('needsResearchGrounding')
828
- ? [['research.md', 'research.md']]
829
- : []),
2026
+ ...(args.research ? [['research.md', 'research.md']] : []),
830
2027
  ['design.md', 'design.md'],
831
2028
  ];
832
2029
  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);
2030
+ const source = tpl === 'research.md'
2031
+ ? renderResearchTemplate(args.feature, args.uncertainty)
2032
+ : readTemplate(tpl);
2033
+ const body = applyExecutionClosureProfile(
2034
+ source,
2035
+ tpl === 'design.md'
2036
+ && (selectedPolicy.lane === 'Critical' || declaredImplementationObligations(spec).length > 0),
2037
+ ).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
2038
+ changes.push({ path: out, body });
835
2039
  created.push(out);
836
2040
  }
837
2041
 
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
2042
  // --- task stubs (fill the cheap placeholders; leave the rest) ---
845
2043
  for (const t of parsed) {
846
- fs.writeFileSync(path.join(specDir, t.file), fillTask(taskTpl, t, args.feature));
2044
+ changes.push({
2045
+ path: t.file,
2046
+ body: fillTask(
2047
+ taskTpl,
2048
+ t,
2049
+ args.feature,
2050
+ dependencyDeclarations.get(t.file) || [],
2051
+ taskHasDeclaredImplementationObligation(spec, t.file),
2052
+ ),
2053
+ });
847
2054
  created.push(t.file);
848
2055
  }
849
2056
 
2057
+ const snapshots = new Map();
2058
+ for (const change of changes) snapshots.set(change.path, captureTarget(workRoot, specDir, change.path));
2059
+ commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
2060
+ createSpecDir: true,
2061
+ transactionRoot: workRoot,
2062
+ afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec }),
2063
+ });
2064
+
850
2065
  const rel = path.relative(process.cwd(), specDir) || specDir;
851
2066
  console.log(`SCAFFOLDED ${rel}`);
852
- console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + feature-receipt.md + ${parsed.length} task stub(s)`);
2067
+ console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + ${parsed.length} task stub(s)`);
853
2068
  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.');
2069
+ console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready.');
855
2070
  }
856
2071
 
857
2072
  try {