@haposoft/cafekit 0.16.0-rc.2 → 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 (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  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 +2469 -71
  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 +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  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 +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  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/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -12,41 +12,230 @@
12
12
  * for the model to fill. It NEVER overwrites an existing spec directory.
13
13
  *
14
14
  * Usage:
15
- * node spec-scaffold.cjs <feature> --tasks "R0-01-slug,R1-01-slug,..." \
16
- * [--lang en] [--title "..."] [--specs-root specs]
15
+ * node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug,..."] \
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] \
19
+ * [--lang en] [--title "..."] [--specs-root specs] \
20
+ * [--artifacts '{"R1-01-slug":["dist/output.js"]}'] \
21
+ * [--dependencies '{"R1-02-consumer":["R1-01-owner"]}']
17
22
  *
18
23
  * Exit: 0 = scaffolded, 2 = usage/precondition error.
19
24
  */
20
25
 
21
26
  const fs = require('fs');
22
27
  const path = require('path');
28
+ const crypto = require('crypto');
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');
23
32
 
24
33
  const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
25
- 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]+)*)$/;
59
+ const TASK_PATH_RE = /^tasks\/task-(R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
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;
62
+ const REGISTRY_KEYS = [
63
+ 'id',
64
+ 'title',
65
+ 'status',
66
+ 'dependencies',
67
+ 'blocker',
68
+ 'started_at',
69
+ 'completed_at',
70
+ 'last_updated_at',
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';
108
+
109
+ class ScaffoldPreconditionError extends Error {}
26
110
 
27
111
  function usage() {
28
- console.error('Usage: node spec-scaffold.cjs <feature> --tasks "R0-01-slug,R1-01-slug" [--lang en] [--title "..."] [--specs-root specs]');
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]');
113
+ }
114
+
115
+ function failPrecondition(message) {
116
+ throw new ScaffoldPreconditionError(message);
117
+ }
118
+
119
+ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
120
+ const adapterDir = path.dirname(__dirname);
121
+ const adapterName = path.basename(adapterDir);
122
+ if (path.basename(__dirname) !== 'scripts' || (adapterName !== '.claude' && adapterName !== '.codex')) return;
123
+ const statePath = path.join(adapterDir, 'hooks', 'completion-authority-state.cjs');
124
+ const checkPath = path.join(adapterDir, 'hooks', 'completion-authority-check.cjs');
125
+ if (![statePath, checkPath].every((filePath) => {
126
+ try {
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; }
135
+ })) return;
136
+ let installedRoot;
137
+ let scaffoldCwd;
138
+ let specFile;
139
+ try {
140
+ installedRoot = fs.realpathSync(path.resolve(adapterDir, '..'));
141
+ scaffoldCwd = fs.realpathSync(path.resolve(process.cwd()));
142
+ specFile = path.join(specDir, 'spec.json');
143
+ const canonicalSpecFile = fs.realpathSync(specFile);
144
+ if (!isWithin(installedRoot, scaffoldCwd) || !isWithin(installedRoot, canonicalSpecFile)) {
145
+ throw new Error(`scaffold cwd and spec path must stay inside installed project root ${installedRoot}`);
146
+ }
147
+ } catch (error) {
148
+ throw new Error(`installed ${adapterName} scaffold authority baseline could not be verified: ${error.message}`);
149
+ }
150
+ let STATE;
151
+ let check;
152
+ let runtime = {};
153
+ try {
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'));
167
+ } catch (error) {
168
+ throw new Error(`installed ${adapterName} scaffold authority hooks are unavailable: ${error.message}`);
169
+ }
170
+ if (typeof check.observePolicyBaseline !== 'function') throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
171
+ let result;
172
+ try {
173
+ result = check.observePolicyBaseline({ STATE, policy: POLICY, projectRoot: installedRoot, candidate: { spec, specFile, featureName: args.feature }, runtime });
174
+ } catch (error) {
175
+ throw new Error(`installed ${adapterName} scaffold authority baseline failed: ${error.message}`);
176
+ }
177
+ if (!result || result.ok !== true) throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
29
178
  }
30
179
 
31
180
  function parseArgs(argv) {
32
- const a = { feature: null, tasks: null, lang: 'en', title: null, specsRoot: 'specs', tasksOnly: false };
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;
33
203
  for (let i = 2; i < argv.length; i++) {
34
204
  const v = argv[i];
35
205
  if (v === '--tasks') a.tasks = argv[++i];
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];
215
+ else if (v === '--risks') a.risks = argv[++i];
36
216
  else if (v === '--lang') a.lang = argv[++i];
37
217
  else if (v === '--title') a.title = argv[++i];
38
218
  else if (v === '--specs-root') a.specsRoot = argv[++i];
39
219
  else if (v === '--tasks-only') a.tasksOnly = true;
220
+ else if (v === '--sync-semantic-model') a.syncSemanticModel = true;
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
+ }
40
230
  else if (!a.feature) a.feature = v;
41
231
  }
42
232
  return a;
43
233
  }
44
234
 
45
235
  function readTemplate(name) {
46
- const p = path.join(TEMPLATES, name);
236
+ const p = resolveTemplatePath(name);
47
237
  if (!fs.existsSync(p)) {
48
- console.error(`precondition: template not found: ${p}`);
49
- process.exit(2);
238
+ failPrecondition(`template not found: ${p}`);
50
239
  }
51
240
  return fs.readFileSync(p, 'utf8');
52
241
  }
@@ -56,28 +245,435 @@ function titleFromSlug(slug) {
56
245
  return s.charAt(0).toUpperCase() + s.slice(1);
57
246
  }
58
247
 
59
- function registryEntry(t) {
60
- return {
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 = []) {
262
+ const entry = {
61
263
  id: `R${t.req}-${t.seq}`,
62
264
  title: titleFromSlug(t.slug),
63
265
  status: 'pending',
64
- dependencies: [],
266
+ dependencies: [...dependencies],
65
267
  blocker: null,
66
268
  started_at: null,
67
269
  completed_at: null,
68
270
  last_updated_at: null,
69
271
  };
272
+ const artifacts = artifactDeclarations.get(t.file);
273
+ if (artifacts) entry.artifacts = artifacts;
274
+ return entry;
275
+ }
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
+
290
+ function parseArtifactDeclarations(raw, parsedTasks) {
291
+ if (raw === null || raw === undefined) return new Map();
292
+ let declarations;
293
+ try {
294
+ declarations = JSON.parse(raw);
295
+ } catch (error) {
296
+ failPrecondition(`--artifacts must be valid JSON (${error.message})`);
297
+ }
298
+ if (!declarations || typeof declarations !== 'object' || Array.isArray(declarations)) {
299
+ failPrecondition('--artifacts must be an object keyed by task id or task path');
300
+ }
301
+
302
+ const result = new Map();
303
+ for (const [key, artifacts] of Object.entries(declarations)) {
304
+ const task = parsedTasks.find((candidate) => key === candidate.raw || key === candidate.file);
305
+ if (!task) {
306
+ failPrecondition(`--artifacts references unknown task "${key}"`);
307
+ }
308
+ if (!Array.isArray(artifacts) || artifacts.length === 0 || artifacts.some((artifact) => (
309
+ typeof artifact !== 'string'
310
+ || artifact.trim() === ''
311
+ || artifact !== artifact.trim()
312
+ || /^(?:[A-Za-z]:[\\/]|[\\/]|https?:\/\/)/.test(artifact)
313
+ || artifact.split(/[\\/]+/).includes('..')
314
+ ))) {
315
+ failPrecondition(`--artifacts for "${key}" must be a non-empty array of safe relative paths`);
316
+ }
317
+ if (new Set(artifacts).size !== artifacts.length) {
318
+ failPrecondition(`--artifacts for "${key}" must not contain duplicate paths`);
319
+ }
320
+ result.set(task.file, artifacts);
321
+ }
322
+ return result;
323
+ }
324
+
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;
70
612
  }
71
613
 
72
- function fillTask(taskTpl, t, feature) {
73
- return taskTpl
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)
74
656
  .replace(/\{\{REQ_NUMBER\}\}/g, t.req)
75
657
  .replace(/\{\{SEQ\}\}/g, t.seq)
76
658
  .replace(/\{\{TITLE\}\}/g, titleFromSlug(t.slug))
77
659
  .replace(/\{\{FEATURE_NAME\}\}/g, feature)
78
660
  .replace(/\{\{PRIORITY\}\}/g, 'P2')
79
661
  .replace(/\{\{EFFORT\}\}/g, 'TBD')
80
- .replace(/\{\{DEPENDENCIES\}\}/g, 'none');
662
+ .replace(/\{\{DEPENDENCIES\}\}/g, dependencies.length > 0 ? dependencies.join(', ') : 'none');
663
+ }
664
+
665
+ function parseRiskNames(raw) {
666
+ if (!raw) return [];
667
+ const aliases = new Map([
668
+ ['auth', 'auth'],
669
+ ['authentication', 'auth'],
670
+ ['authorization', 'auth'],
671
+ ]);
672
+ return [...new Set(raw
673
+ .split(',')
674
+ .map((risk) => risk.trim().toLowerCase())
675
+ .filter(Boolean)
676
+ .map((risk) => aliases.get(risk) || risk))];
81
677
  }
82
678
 
83
679
  function nowIso() {
@@ -91,22 +687,1221 @@ function nowIso() {
91
687
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${oh}:${om}`;
92
688
  }
93
689
 
690
+ function hasOwn(value, key) {
691
+ return value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key);
692
+ }
693
+
694
+ function isPlainObject(value) {
695
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
696
+ }
697
+
698
+ function cloneJson(value) {
699
+ return JSON.parse(JSON.stringify(value));
700
+ }
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
+
844
+ function isWithin(root, target) {
845
+ const relative = path.relative(path.resolve(root), path.resolve(target));
846
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
847
+ }
848
+
849
+ function assertNoSymlinkPath(root, target, label) {
850
+ const resolvedRoot = path.resolve(root);
851
+ const resolvedTarget = path.resolve(target);
852
+ if (!isWithin(resolvedRoot, resolvedTarget)) {
853
+ failPrecondition(`${label} escapes the work root: ${target}`);
854
+ }
855
+
856
+ const relative = path.relative(resolvedRoot, resolvedTarget);
857
+ let current = resolvedRoot;
858
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
859
+ current = path.join(current, segment);
860
+ let stat;
861
+ try {
862
+ stat = fs.lstatSync(current);
863
+ } catch (error) {
864
+ if (error.code === 'ENOENT') break;
865
+ failPrecondition(`${label} cannot be inspected (${error.message})`);
866
+ }
867
+ if (stat.isSymbolicLink()) failPrecondition(`${label} cannot traverse a symlink: ${current}`);
868
+ }
869
+ }
870
+
871
+ function assertExistingDirectory(root, directory, label) {
872
+ assertNoSymlinkPath(root, directory, label);
873
+ let stat;
874
+ try {
875
+ stat = fs.lstatSync(directory);
876
+ } catch (error) {
877
+ failPrecondition(`${label} is missing (${error.message})`);
878
+ }
879
+ if (!stat.isDirectory()) failPrecondition(`${label} must be a directory`);
880
+ }
881
+
882
+ function assertRegularFileOrMissing(root, filePath, label) {
883
+ assertNoSymlinkPath(root, filePath, label);
884
+ try {
885
+ const stat = fs.lstatSync(filePath);
886
+ if (!stat.isFile()) failPrecondition(`${label} must be a regular file`);
887
+ } catch (error) {
888
+ if (error instanceof ScaffoldPreconditionError) throw error;
889
+ if (error.code !== 'ENOENT') failPrecondition(`${label} cannot be inspected (${error.message})`);
890
+ }
891
+ }
892
+
893
+ function safeRelativePath(value, label) {
894
+ if (
895
+ typeof value !== 'string'
896
+ || value.trim() === ''
897
+ || value !== value.trim()
898
+ || path.isAbsolute(value)
899
+ || /^[A-Za-z]:[\\/]/.test(value)
900
+ || value.split(/[\\/]+/).includes('..')
901
+ ) {
902
+ failPrecondition(`${label} must be a safe relative path`);
903
+ }
904
+ return value;
905
+ }
906
+
907
+ function validateArtifactList(artifacts, label) {
908
+ if (!Array.isArray(artifacts) || artifacts.length === 0) {
909
+ failPrecondition(`${label} must be a non-empty array of safe relative paths`);
910
+ }
911
+ const seen = new Set();
912
+ for (const artifact of artifacts) {
913
+ safeRelativePath(artifact, `${label} entry`);
914
+ if (seen.has(artifact)) failPrecondition(`${label} must not contain duplicate paths`);
915
+ seen.add(artifact);
916
+ }
917
+ }
918
+
919
+ function parseTaskPath(taskPath, label) {
920
+ const match = typeof taskPath === 'string' && taskPath.match(TASK_PATH_RE);
921
+ if (!match) failPrecondition(`${label} must match tasks/task-R{N}-{SEQ}-<slug>.md`);
922
+ const rawId = match[1];
923
+ const idParts = rawId.match(TASK_ID_RE);
924
+ if (!idParts) failPrecondition(`${label} contains an invalid task id`);
925
+ return { id: `R${idParts[1]}-${idParts[2]}`, rawId, req: idParts[1], seq: idParts[2], slug: idParts[3] };
926
+ }
927
+
928
+ function validateTaskPathList(taskFiles, label) {
929
+ if (!Array.isArray(taskFiles)) failPrecondition(`${label} must be an array`);
930
+ const seen = new Set();
931
+ for (const taskPath of taskFiles) {
932
+ parseTaskPath(taskPath, `${label} entry`);
933
+ if (seen.has(taskPath)) failPrecondition(`${label} contains duplicate path ${taskPath}`);
934
+ seen.add(taskPath);
935
+ }
936
+ return [...taskFiles].sort();
937
+ }
938
+
939
+ function validateRegistryEntry(taskPath, entry, declaredSet) {
940
+ if (!isPlainObject(entry)) failPrecondition(`task_registry.${taskPath} must be an object`);
941
+ for (const key of REGISTRY_KEYS) {
942
+ if (!hasOwn(entry, key)) failPrecondition(`task_registry.${taskPath} is missing ${key}`);
943
+ }
944
+ const identity = parseTaskPath(taskPath, `task_registry.${taskPath}`);
945
+ if (entry.id !== identity.id) failPrecondition(`task_registry.${taskPath}.id conflicts with its task path`);
946
+ if (typeof entry.title !== 'string' || entry.title.trim() === '') failPrecondition(`task_registry.${taskPath}.title 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
+ }
950
+ if (!Array.isArray(entry.dependencies)) failPrecondition(`task_registry.${taskPath}.dependencies must be an array`);
951
+ const dependencies = new Set();
952
+ for (const dependency of entry.dependencies) {
953
+ parseTaskPath(dependency, `task_registry.${taskPath}.dependencies entry`);
954
+ if (dependencies.has(dependency)) failPrecondition(`task_registry.${taskPath}.dependencies contains a duplicate`);
955
+ if (dependency === taskPath) failPrecondition(`task_registry.${taskPath}.dependencies cannot contain itself`);
956
+ if (!declaredSet.has(dependency)) failPrecondition(`task_registry.${taskPath}.dependencies references unknown task ${dependency}`);
957
+ dependencies.add(dependency);
958
+ }
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
+ }
1198
+ }
1199
+
1200
+ function validateDependencyCycles(taskFiles, registry) {
1201
+ const visiting = new Set();
1202
+ const visited = new Set();
1203
+ function visit(taskPath, stack = []) {
1204
+ if (visiting.has(taskPath)) {
1205
+ const start = stack.indexOf(taskPath);
1206
+ failPrecondition(`task_registry.dependencies contains a cycle: ${stack.slice(start).concat(taskPath).join(' -> ')}`);
1207
+ }
1208
+ if (visited.has(taskPath)) return;
1209
+ visiting.add(taskPath);
1210
+ for (const dependency of registry[taskPath].dependencies) visit(dependency, [...stack, taskPath]);
1211
+ visiting.delete(taskPath);
1212
+ visited.add(taskPath);
1213
+ }
1214
+ for (const taskPath of taskFiles) visit(taskPath);
1215
+ }
1216
+
1217
+ function listExistingTaskFiles(workRoot, specDir) {
1218
+ const tasksDir = path.join(specDir, 'tasks');
1219
+ assertNoSymlinkPath(workRoot, tasksDir, 'tasks directory');
1220
+ if (!fs.existsSync(tasksDir)) return [];
1221
+ assertExistingDirectory(workRoot, tasksDir, 'tasks directory');
1222
+ const files = [];
1223
+ for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
1224
+ const fullPath = path.join(tasksDir, entry.name);
1225
+ if (entry.isSymbolicLink()) failPrecondition(`tasks entry cannot be a symlink: ${fullPath}`);
1226
+ if (!entry.isFile()) failPrecondition(`tasks entry must be a regular file: ${fullPath}`);
1227
+ const relative = `tasks/${entry.name}`;
1228
+ parseTaskPath(relative, `tasks entry ${relative}`);
1229
+ files.push(relative);
1230
+ }
1231
+ return files.sort();
1232
+ }
1233
+
1234
+ function readPersistedPolicy(spec) {
1235
+ if (hasOwn(spec, '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
+ }
1242
+ }
1243
+ if (!hasOwn(spec, 'workflow_policy')) {
1244
+ const legacy = spec.design_context?.execution_tier;
1245
+ failPrecondition(
1246
+ legacy
1247
+ ? 'existing spec has no persisted workflow_policy; design_context.execution_tier is read-only and cannot create policy'
1248
+ : 'existing spec must contain a persisted workflow_policy snapshot',
1249
+ );
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
+ }
1256
+ try {
1257
+ return POLICY.readWorkflowPolicySnapshot(spec);
1258
+ } catch (error) {
1259
+ failPrecondition(`existing workflow_policy is invalid (${error.message})`);
1260
+ }
1261
+ }
1262
+
1263
+ function validateResearchTopology(workRoot, specDir, spec, needsResearch) {
1264
+ const canonical = 'research.md';
1265
+ if (hasOwn(spec, 'research') && spec.research !== null) {
1266
+ const pointer = isPlainObject(spec.research) ? spec.research.path : spec.research;
1267
+ if (typeof pointer !== 'string') failPrecondition('spec.research must be null, a safe relative path, or an object with a path');
1268
+ const safePointer = safeRelativePath(pointer, 'spec.research');
1269
+ const pointerPath = path.resolve(specDir, safePointer);
1270
+ assertNoSymlinkPath(workRoot, pointerPath, 'spec.research path');
1271
+ if (!isWithin(specDir, pointerPath)) failPrecondition('spec.research path must stay inside the spec directory');
1272
+ if (needsResearch && safePointer !== canonical) {
1273
+ failPrecondition('spec.research path must be research.md for an explicit --research request');
1274
+ }
1275
+ assertRegularFileOrMissing(workRoot, pointerPath, 'spec.research path');
1276
+ }
1277
+ const researchPath = path.join(specDir, canonical);
1278
+ assertRegularFileOrMissing(workRoot, researchPath, 'research.md');
1279
+ return researchPath;
1280
+ }
1281
+
1282
+ function inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations) {
1283
+ const hasTaskFiles = hasOwn(spec, 'task_files');
1284
+ const hasRegistry = hasOwn(spec, 'task_registry');
1285
+ if (hasTaskFiles !== hasRegistry) failPrecondition('task_files and task_registry must be present together');
1286
+
1287
+ const declared = hasTaskFiles ? validateTaskPathList(spec.task_files, 'task_files') : [];
1288
+ const registry = hasRegistry ? spec.task_registry : {};
1289
+ if (hasRegistry && !isPlainObject(registry)) failPrecondition('task_registry must be an object keyed by task file path');
1290
+ const declaredSet = new Set(declared);
1291
+ if (hasRegistry) {
1292
+ const registryIds = new Set();
1293
+ for (const taskPath of Object.keys(registry)) {
1294
+ parseTaskPath(taskPath, `task_registry key ${taskPath}`);
1295
+ if (!declaredSet.has(taskPath)) failPrecondition(`task_registry has an unregistered entry ${taskPath}`);
1296
+ validateRegistryEntry(taskPath, registry[taskPath], declaredSet);
1297
+ if (registryIds.has(registry[taskPath].id)) failPrecondition(`task_registry contains duplicate task id ${registry[taskPath].id}`);
1298
+ registryIds.add(registry[taskPath].id);
1299
+ }
1300
+ for (const taskPath of declared) {
1301
+ if (!hasOwn(registry, taskPath)) failPrecondition(`task_registry is missing entry ${taskPath}`);
1302
+ }
1303
+ validateDependencyCycles(declared, registry);
1304
+ }
1305
+
1306
+ const existing = listExistingTaskFiles(workRoot, specDir);
1307
+ const existingSet = new Set(existing);
1308
+ if (!hasTaskFiles && existing.length > 0) failPrecondition('tasks directory contains files but task_files/task_registry are absent');
1309
+ for (const taskPath of existing) {
1310
+ if (!declaredSet.has(taskPath)) failPrecondition(`task_files is missing existing task file ${taskPath}`);
1311
+ }
1312
+ const requested = new Set(parsed.map((task) => task.file));
1313
+ for (const taskPath of declared) {
1314
+ if (!existingSet.has(taskPath)) failPrecondition(`task_files declares missing task file ${taskPath}`);
1315
+ }
1316
+
1317
+ for (const task of parsed) {
1318
+ if (!existingSet.has(task.file)) continue;
1319
+ const entry = registry[task.file];
1320
+ const expected = registryEntry(task, artifactDeclarations);
1321
+ if (entry.id !== expected.id || entry.title !== expected.title) {
1322
+ failPrecondition(`conflicting duplicate task ${task.raw}: existing registry entry differs`);
1323
+ }
1324
+ if (artifactDeclarations.has(task.file)) {
1325
+ if (!hasOwn(entry, 'artifacts') || JSON.stringify(entry.artifacts) !== JSON.stringify(expected.artifacts)) {
1326
+ failPrecondition(`conflicting duplicate task ${task.raw}: existing registry artifacts differ`);
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ return {
1332
+ declared,
1333
+ registry,
1334
+ existing,
1335
+ existingSet,
1336
+ requested,
1337
+ hasTaskTopology: hasTaskFiles,
1338
+ };
1339
+ }
1340
+
1341
+ function captureTarget(workRoot, specDir, relativePath) {
1342
+ const target = path.join(specDir, relativePath);
1343
+ assertNoSymlinkPath(workRoot, target, `target ${relativePath}`);
1344
+ try {
1345
+ const stat = fs.lstatSync(target);
1346
+ if (!stat.isFile()) failPrecondition(`target ${relativePath} must be a regular file`);
1347
+ return { exists: true, bytes: fs.readFileSync(target) };
1348
+ } catch (error) {
1349
+ if (error instanceof ScaffoldPreconditionError) throw error;
1350
+ if (error.code !== 'ENOENT') failPrecondition(`target ${relativePath} cannot be inspected (${error.message})`);
1351
+ return { exists: false, bytes: null };
1352
+ }
1353
+ }
1354
+
1355
+ function parseFailureInjection() {
1356
+ const raw = process.env.CAFEKIT_SCAFFOLD_FAIL_AFTER_WRITES;
1357
+ if (raw === undefined) return null;
1358
+ if (!/^[1-9]\d*$/.test(raw)) failPrecondition('CAFEKIT_SCAFFOLD_FAIL_AFTER_WRITES must be a positive integer');
1359
+ return Number(raw);
1360
+ }
1361
+
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 = [];
1516
+ try {
1517
+ for (const change of changes) {
1518
+ safeRelativePath(change.path, `staged path ${change.path}`);
1519
+ const staged = path.join(stageDir, change.path);
1520
+ fs.mkdirSync(path.dirname(staged), { recursive: true });
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
+ });
1529
+ }
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 };
1545
+ } catch (error) {
1546
+ fs.rmSync(transactionDir, { recursive: true, force: true });
1547
+ throw error;
1548
+ }
1549
+ }
1550
+
1551
+ function ensureTargetParent(workRoot, specDir, relativePath, createdDirs) {
1552
+ const parent = path.dirname(relativePath);
1553
+ if (parent === '.') return;
1554
+ let current = specDir;
1555
+ for (const segment of parent.split(path.sep)) {
1556
+ current = path.join(current, segment);
1557
+ assertNoSymlinkPath(workRoot, current, `target parent ${current}`);
1558
+ if (fs.existsSync(current)) {
1559
+ assertExistingDirectory(workRoot, current, `target parent ${current}`);
1560
+ } else {
1561
+ fs.mkdirSync(current);
1562
+ createdDirs.push(current);
1563
+ }
1564
+ }
1565
+ }
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
+
1582
+ function removeTarget(target) {
1583
+ try {
1584
+ const stat = fs.lstatSync(target);
1585
+ if (stat.isDirectory()) throw new Error(`rollback target is a directory: ${target}`);
1586
+ fs.unlinkSync(target);
1587
+ } catch (error) {
1588
+ if (error.code !== 'ENOENT') throw error;
1589
+ }
1590
+ }
1591
+
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
+ );
1603
+ const createdDirs = [];
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
+ };
1613
+ try {
1614
+ if (createSpecDir) {
1615
+ if (fs.existsSync(specDir)) failPrecondition(`spec dir already exists: ${specDir}`);
1616
+ ensureDirectoryTree(workRoot, specDir, createdDirs, 'spec directory');
1617
+ }
1618
+ for (const change of changes) {
1619
+ const target = path.join(specDir, change.path);
1620
+ const before = captureTarget(workRoot, specDir, change.path);
1621
+ const expected = snapshots.get(change.path);
1622
+ if (before.exists !== expected.exists || (before.exists && !before.bytes.equals(expected.bytes))) {
1623
+ failPrecondition(`target changed during preflight: ${change.path}`);
1624
+ }
1625
+ ensureTargetParent(workRoot, specDir, change.path, createdDirs);
1626
+ const backup = path.join(transaction.backupDir, change.path);
1627
+ if (before.exists) {
1628
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
1629
+ renameBoundary(target, backup);
1630
+ }
1631
+ renameBoundary(path.join(transaction.stageDir, change.path), target);
1632
+ writes += 1;
1633
+ if (failureAfter !== null && writes >= failureAfter) {
1634
+ throw new Error(`injected failure after ${writes} staged write(s)`);
1635
+ }
1636
+ }
1637
+ if (typeof afterApply === 'function') afterApply();
1638
+ } catch (error) {
1639
+ let rollbackError = null;
1640
+ try {
1641
+ const manifest = { ...transaction.manifest, pid: -1 };
1642
+ recoverTransaction(workRoot, transaction.transactionDir, manifest);
1643
+ } catch (rollbackFailure) {
1644
+ rollbackError = rollbackFailure;
1645
+ }
1646
+ if (rollbackError) throw new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
1647
+ throw new Error(`${error.message}; transaction rolled back`);
1648
+ }
1649
+ fs.rmSync(transaction.transactionDir, { recursive: true, force: true });
1650
+ fsyncDirectory(transactionRoot);
1651
+ }
1652
+
1653
+ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, parsed, artifactDeclarations, taskTpl, ts }) {
1654
+ const workRoot = path.resolve(process.cwd());
1655
+ assertExistingDirectory(workRoot, specDir, 'spec directory');
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');
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
+ }
1683
+
1684
+ let nextPolicy = POLICY.canonicalWorkflowPolicySnapshot(spec);
1685
+ if (args.risks && args.risks.length > 0) {
1686
+ try {
1687
+ nextPolicy = POLICY.escalateWorkflowPolicy(nextPolicy, { risks: args.risks });
1688
+ } catch (error) {
1689
+ failPrecondition(`--tasks-only cannot escalate workflow policy (${error.message})`);
1690
+ }
1691
+ const validation = POLICY.validateWorkflowPolicySnapshot(nextPolicy);
1692
+ if (!validation.valid) failPrecondition(`--tasks-only escalation produced an invalid workflow_policy (${validation.errors.join('; ')})`);
1693
+ }
1694
+
1695
+ const needsResearch = args.research === true;
1696
+ const researchPath = validateResearchTopology(workRoot, specDir, spec, needsResearch);
1697
+ const researchExists = fs.existsSync(researchPath);
1698
+ const researchTemplate = needsResearch && !researchExists
1699
+ ? renderResearchTemplate(args.feature, args.uncertainty)
1700
+ : null;
1701
+ const newTasks = parsed.filter((task) => !topology.existingSet.has(task.file));
1702
+ const nextSpec = cloneJson(spec);
1703
+ if (!topology.hasTaskTopology && newTasks.length > 0) {
1704
+ nextSpec.task_files = [];
1705
+ nextSpec.task_registry = {};
1706
+ }
1707
+ for (const task of newTasks) {
1708
+ nextSpec.task_files.push(task.file);
1709
+ nextSpec.task_registry[task.file] = registryEntry(
1710
+ task,
1711
+ artifactDeclarations,
1712
+ dependencyDeclarations.get(task.file) || [],
1713
+ );
1714
+ }
1715
+ if (needsResearch && nextSpec.research !== 'research.md') {
1716
+ nextSpec.research = 'research.md';
1717
+ }
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');
1736
+ }
1737
+
1738
+ const changes = newTasks.map((task) => ({
1739
+ path: task.file,
1740
+ body: fillTask(
1741
+ taskTpl,
1742
+ task,
1743
+ args.feature,
1744
+ dependencyDeclarations.get(task.file) || [],
1745
+ taskHasDeclaredImplementationObligation(normalizedSpec, task.file),
1746
+ ),
1747
+ }));
1748
+ if (researchTemplate !== null) changes.push({ path: 'research.md', body: researchTemplate });
1749
+ if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(normalizedSpec, null, 2)}\n` });
1750
+ if (changes.length === 0) return { createdTasks: [], researchCreated: false };
1751
+
1752
+ const expectedAbsent = new Set(newTasks.map((task) => task.file));
1753
+ if (researchTemplate !== null) expectedAbsent.add('research.md');
1754
+ const snapshots = new Map();
1755
+ for (const change of changes) {
1756
+ const snapshot = captureTarget(workRoot, specDir, change.path);
1757
+ if (expectedAbsent.has(change.path) && snapshot.exists) {
1758
+ failPrecondition(`target appeared after preflight: ${change.path}`);
1759
+ }
1760
+ snapshots.set(change.path, snapshot);
1761
+ }
1762
+ commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
1763
+ transactionRoot: workRoot,
1764
+ afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec: normalizedSpec }),
1765
+ });
1766
+ return { createdTasks: newTasks.map((task) => task.file), researchCreated: researchTemplate !== null };
1767
+ }
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
+
94
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
+ }
95
1816
  const args = parseArgs(process.argv);
96
- if (!args.feature || !args.tasks) { usage(); process.exit(2); }
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)) {
1830
+ usage();
1831
+ failPrecondition('missing feature or required task arguments');
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
+ }
1839
+ if (typeof args.lane === 'string' && args.lane.toLowerCase() === 'direct') {
1840
+ failPrecondition('Direct lane does not create a spec; run targeted verification from the Direct workflow instead');
1841
+ }
1842
+ if (args.lane && !POLICY.LANES.includes(args.lane)) {
1843
+ failPrecondition(`--lane must be one of ${POLICY.LANES.join(', ')}`);
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
+ }
97
1857
 
98
- const ids = args.tasks.split(',').map((s) => s.trim()).filter(Boolean);
1858
+ const ids = (args.tasks || '').split(',').map((s) => s.trim()).filter(Boolean);
1859
+ if (args.tasksOnly && ids.length === 0) {
1860
+ failPrecondition('--tasks-only requires at least one task id');
1861
+ }
99
1862
  const parsed = [];
1863
+ const seenIds = new Set();
100
1864
  for (const id of ids) {
101
1865
  const m = id.match(TASK_ID_RE);
102
1866
  if (!m) {
103
- console.error(`precondition: invalid task id "${id}" (want R{N}-{SEQ}-<slug>, SEQ 2 digits)`);
104
- process.exit(2);
1867
+ failPrecondition(`invalid task id "${id}" (want R{N}-{SEQ}-<slug>, SEQ 2 digits)`);
105
1868
  }
1869
+ const logicalId = `R${m[1]}-${m[2]}`;
1870
+ if (seenIds.has(logicalId)) failPrecondition(`duplicate task id "${logicalId}"`);
1871
+ seenIds.add(logicalId);
106
1872
  parsed.push({ raw: id, req: m[1], seq: m[2], slug: m[3], file: `tasks/task-${id}.md` });
107
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
+ }
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());
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);
108
1896
 
1897
+ safeRelativePath(args.specsRoot, '--specs-root');
1898
+ safeRelativePath(args.feature, 'feature');
1899
+ if (!FEATURE_NAME_RE.test(args.feature)) {
1900
+ failPrecondition('feature must be one safe path segment starting with an alphanumeric character');
1901
+ }
109
1902
  const specDir = path.resolve(process.cwd(), args.specsRoot, args.feature);
1903
+ if (!isWithin(workRoot, specDir)) failPrecondition(`spec directory escapes the work root: ${specDir}`);
1904
+ assertNoSymlinkPath(workRoot, specDir, 'spec directory');
110
1905
  const taskTpl = readTemplate('task.md');
111
1906
  const ts = nowIso();
112
1907
  const created = [];
@@ -114,74 +1909,169 @@ function main() {
114
1909
  // --- --tasks-only: add task stubs + merge registry into an existing spec ---
115
1910
  if (args.tasksOnly) {
116
1911
  const specPath = path.join(specDir, 'spec.json');
117
- if (!fs.existsSync(specPath)) {
118
- console.error(`precondition: --tasks-only needs an existing spec.json at ${specPath}`);
119
- process.exit(2);
120
- }
121
- const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
122
- spec.task_files = spec.task_files || [];
123
- spec.task_registry = spec.task_registry || {};
124
- fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
125
- for (const t of parsed) {
126
- const abs = path.join(specDir, t.file);
127
- if (fs.existsSync(abs)) continue; // never overwrite an already-filled task
128
- fs.writeFileSync(abs, fillTask(taskTpl, t, args.feature));
129
- if (!spec.task_files.includes(t.file)) spec.task_files.push(t.file);
130
- spec.task_registry[t.file] = registryEntry(t);
131
- created.push(t.file);
132
- }
133
- spec.task_files.sort();
134
- spec.updated_at = ts;
135
- fs.writeFileSync(specPath, JSON.stringify(spec, null, 2) + '\n');
1912
+ assertNoSymlinkPath(workRoot, specPath, 'spec.json');
1913
+ if (!fs.existsSync(specPath)) failPrecondition(`--tasks-only needs an existing spec.json at ${specPath}`);
1914
+ let spec;
1915
+ try {
1916
+ spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
1917
+ } catch (error) {
1918
+ failPrecondition(`--tasks-only existing spec.json is invalid JSON (${error.message})`);
1919
+ }
1920
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
1921
+ failPrecondition('--tasks-only existing spec.json must contain a JSON object');
1922
+ }
1923
+ assertCanonicalSpecInputClosedWorld(spec);
1924
+ if (spec.schema_version === '2.0') assertLegacyMigrationClosedWorld(spec);
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
+ }
1929
+ if (args.lane && args.lane !== persistedPolicy.lane) {
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)`);
1931
+ }
1932
+ const result = scaffoldTasksOnly({
1933
+ args: { ...args, risks: riskNames },
1934
+ specDir,
1935
+ specPath,
1936
+ spec,
1937
+ persistedPolicy,
1938
+ parsed,
1939
+ artifactDeclarations,
1940
+ taskTpl,
1941
+ ts,
1942
+ });
1943
+ const createdTasks = result.createdTasks;
136
1944
  const relTo = path.relative(process.cwd(), specDir) || specDir;
137
1945
  console.log(`SCAFFOLDED (tasks-only) ${relTo}`);
138
- console.log(`- ${created.length} new task stub(s); task_files + task_registry merged.`);
1946
+ console.log(`- ${createdTasks.length} new task stub(s); task_files + task_registry merged.`);
1947
+ if (result.researchCreated) console.log('- research.md created by explicit --research request.');
139
1948
  console.log(`- NEXT: Edit-fill {{...}} in each stub; set dependencies; run validator + spec-ground.`);
140
1949
  return;
141
1950
  }
142
1951
 
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
+ };
1959
+ let selectedPolicy;
1960
+ try {
1961
+ selectedPolicy = POLICY.workflowPolicySnapshot(policyInput);
1962
+ } catch (error) {
1963
+ failPrecondition(error.message);
1964
+ }
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');
1970
+ }
143
1971
  if (fs.existsSync(specDir)) {
144
1972
  console.error(`precondition: spec dir already exists, refusing to overwrite: ${specDir}`);
145
1973
  process.exit(2);
146
1974
  }
147
1975
 
148
- fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
149
- fs.mkdirSync(path.join(specDir, 'reports'), { recursive: true });
150
-
151
1976
  // --- spec.json (from spec-state.json template) ---
152
1977
  const spec = JSON.parse(readTemplate('spec-state.json'));
153
1978
  spec.feature_name = args.feature;
154
1979
  spec.created_at = ts;
155
1980
  spec.updated_at = ts;
156
1981
  spec.language = args.lang;
157
- spec.timestamps = spec.timestamps || {};
158
- spec.timestamps.init = ts;
159
1982
  spec.scope_lock = spec.scope_lock || {};
160
1983
  spec.scope_lock.source = args.title || `{{PROJECT_DESCRIPTION}}`;
161
- spec.task_files = parsed.map((t) => t.file);
162
- spec.task_registry = {};
163
- for (const t of parsed) spec.task_registry[t.file] = registryEntry(t);
164
- fs.writeFileSync(path.join(specDir, 'spec.json'), JSON.stringify(spec, null, 2) + '\n');
1984
+ delete spec.workflow_policy;
1985
+ delete spec.override_receipt;
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
+ };
1997
+ if (parsed.length > 0) {
1998
+ spec.authoring.tasks = 'draft';
1999
+ spec.task_files = parsed.map((t) => t.file);
2000
+ spec.task_registry = {};
2001
+ for (const t of parsed) {
2002
+ spec.task_registry[t.file] = registryEntry(
2003
+ t,
2004
+ artifactDeclarations,
2005
+ dependencyDeclarations.get(t.file) || [],
2006
+ );
2007
+ }
2008
+ } else {
2009
+ delete spec.task_files;
2010
+ delete spec.task_registry;
2011
+ }
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` }];
165
2021
  created.push('spec.json');
166
2022
 
167
2023
  // --- doc templates (placeholders left for the model to fill) ---
168
- for (const [tpl, out] of [['requirements.md', 'requirements.md'], ['research.md', 'research.md'], ['design.md', 'design.md']]) {
169
- const body = readTemplate(tpl).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
170
- fs.writeFileSync(path.join(specDir, out), body);
2024
+ const docs = [
2025
+ ['requirements.md', 'requirements.md'],
2026
+ ...(args.research ? [['research.md', 'research.md']] : []),
2027
+ ['design.md', 'design.md'],
2028
+ ];
2029
+ for (const [tpl, out] of docs) {
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 });
171
2039
  created.push(out);
172
2040
  }
173
2041
 
174
2042
  // --- task stubs (fill the cheap placeholders; leave the rest) ---
175
2043
  for (const t of parsed) {
176
- 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
+ });
177
2054
  created.push(t.file);
178
2055
  }
179
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
+
180
2065
  const rel = path.relative(process.cwd(), specDir) || specDir;
181
2066
  console.log(`SCAFFOLDED ${rel}`);
182
- console.log(`- ${created.length} files created: spec.json + 3 docs + ${parsed.length} task stub(s)`);
183
- console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
184
- console.log(`- NEXT: Edit-fill the {{...}} placeholders (do NOT leave any); set dependencies in task_registry; run validator + spec-ground before ready.`);
2067
+ console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + ${parsed.length} task stub(s)`);
2068
+ if (parsed.length > 0) console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
2069
+ console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready.');
185
2070
  }
186
2071
 
187
- main();
2072
+ try {
2073
+ main();
2074
+ } catch (error) {
2075
+ console.error(`precondition: ${error.message}`);
2076
+ process.exitCode = 2;
2077
+ }