ultra-builder-pro-cli 0.19.1 → 0.21.1

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 (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -4,21 +4,249 @@
4
4
  //
5
5
  // The plan itself is computed by orchestrator/planner/plan-builder.cjs
6
6
  // (Functional Core). This module handles IO: atomic write via the shared
7
- // file-ops helper, read-back, md rendering, and section projection for
8
- // plan.get.
7
+ // file-ops helper for the retired global artifact, plus journaled publication,
8
+ // read-back, md rendering, and section projection for change-scoped plans.
9
9
 
10
10
  const fs = require('node:fs');
11
+ const crypto = require('node:crypto');
11
12
  const path = require('node:path');
13
+ const { spawnSync } = require('node:child_process');
12
14
  const { writeAtomic } = require('../../adapters/_shared/file-ops.cjs');
13
15
  const { buildPlan } = require('../../orchestrator/planner/plan-builder.cjs');
14
16
 
15
17
  const DEFAULT_ARTIFACT_RELPATH = '.ultra/execution-plan.json';
18
+ const PLAN_PUBLICATION_WORKER = path.join(__dirname, 'plan-publication-worker.cjs');
16
19
 
17
- function renderPlanMd(plan) {
20
+ function planPathError(message, cause) {
21
+ const error = new Error(message);
22
+ error.code = 'PLAN_ARTIFACT_PATH_UNSAFE';
23
+ if (cause) error.cause = cause;
24
+ return error;
25
+ }
26
+
27
+ function containedPath(root, candidate) {
28
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
29
+ }
30
+
31
+ function entryExists(candidate) {
32
+ try {
33
+ fs.lstatSync(candidate);
34
+ return true;
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') return false;
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ function assertRegularTarget(target, physicalRoot) {
42
+ if (!entryExists(target)) return;
43
+ const stat = fs.lstatSync(target);
44
+ if (!stat.isFile() || stat.isSymbolicLink()) {
45
+ throw planPathError(`plan artifact target must be a regular file: ${target}`);
46
+ }
47
+ const physicalTarget = fs.realpathSync(target);
48
+ if (!containedPath(physicalRoot, physicalTarget)) {
49
+ throw planPathError(`plan artifact target escapes the physical project root: ${target}`);
50
+ }
51
+ }
52
+
53
+ function assertArtifactDirectoryCurrent(paths) {
54
+ let current = paths.root;
55
+ const relativeDirectory = path.relative(paths.root, paths.directory);
56
+ for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
57
+ current = path.join(current, segment);
58
+ let stat;
59
+ try { stat = fs.lstatSync(current); } catch (cause) {
60
+ throw planPathError(`plan artifact ancestor disappeared: ${current}`, cause);
61
+ }
62
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
63
+ throw planPathError(`plan artifact ancestor must be a real directory: ${current}`);
64
+ }
65
+ }
66
+ let physicalDirectory;
67
+ try { physicalDirectory = fs.realpathSync(paths.directory); } catch (cause) {
68
+ throw planPathError(`plan artifact directory cannot be resolved: ${paths.directory}`, cause);
69
+ }
70
+ if (physicalDirectory !== paths.physicalDirectory
71
+ || !containedPath(paths.physicalRoot, physicalDirectory)) {
72
+ throw planPathError(
73
+ `plan artifact directory changed after staging: ${paths.directory}`,
74
+ );
75
+ }
76
+ }
77
+
78
+ function verifyArtifactDirectory(projectRoot, change) {
79
+ const root = path.resolve(projectRoot);
80
+ let rootStat;
81
+ try { rootStat = fs.lstatSync(root); } catch (cause) {
82
+ throw planPathError(`project root does not exist: ${root}`, cause);
83
+ }
84
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
85
+ throw planPathError(`project root must be a real directory: ${root}`);
86
+ }
87
+ const physicalRoot = fs.realpathSync(root);
88
+ const paths = changePlanPaths(root, change);
89
+ const directory = path.dirname(paths.json);
90
+ const relativeDirectory = path.relative(root, directory);
91
+ let current = root;
92
+ for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
93
+ current = path.join(current, segment);
94
+ if (!fs.existsSync(current)) {
95
+ try { fs.mkdirSync(current); } catch (cause) {
96
+ throw planPathError(`cannot create plan artifact directory: ${current}`, cause);
97
+ }
98
+ }
99
+ const stat = fs.lstatSync(current);
100
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
101
+ throw planPathError(`plan artifact ancestor must be a real directory: ${current}`);
102
+ }
103
+ }
104
+ const physicalDirectory = fs.realpathSync(directory);
105
+ if (!containedPath(physicalRoot, physicalDirectory)) {
106
+ throw planPathError(`plan artifact directory escapes the physical project root: ${directory}`);
107
+ }
108
+ assertRegularTarget(paths.json, physicalRoot);
109
+ assertRegularTarget(paths.md, physicalRoot);
110
+ return {
111
+ ...paths,
112
+ root,
113
+ directory,
114
+ physicalRoot,
115
+ physicalDirectory,
116
+ };
117
+ }
118
+
119
+ function writeExclusive(file, content) {
120
+ const flags = fs.constants.O_WRONLY
121
+ | fs.constants.O_CREAT
122
+ | fs.constants.O_EXCL
123
+ | (fs.constants.O_NOFOLLOW || 0);
124
+ const fd = fs.openSync(file, flags, 0o600);
125
+ try {
126
+ fs.writeFileSync(fd, content);
127
+ fs.fsyncSync(fd);
128
+ } finally {
129
+ fs.closeSync(fd);
130
+ }
131
+ }
132
+
133
+ function digestContent(content) {
134
+ return crypto.createHash('sha256').update(content).digest('hex');
135
+ }
136
+
137
+ function directoryIdentity(directory) {
138
+ const stat = fs.statSync(directory, { bigint: true });
139
+ if (!stat.isDirectory()) {
140
+ throw planPathError(`plan artifact directory is not a directory: ${directory}`);
141
+ }
142
+ return { dev: String(stat.dev), ino: String(stat.ino) };
143
+ }
144
+
145
+ function workerError(payload, fallback) {
146
+ const error = new Error(payload?.message || fallback);
147
+ error.code = payload?.code || 'PLAN_WORKER_FAILED';
148
+ if (payload?.details !== undefined) error.details = payload.details;
149
+ return error;
150
+ }
151
+
152
+ function runDirectoryWorker(paths, action, payload, {
153
+ verifyCurrent = true,
154
+ } = {}) {
155
+ if (verifyCurrent) assertArtifactDirectoryCurrent(paths);
156
+ const result = spawnSync(
157
+ process.execPath,
158
+ [PLAN_PUBLICATION_WORKER, action],
159
+ {
160
+ cwd: paths.directory,
161
+ input: JSON.stringify(payload),
162
+ encoding: 'utf8',
163
+ stdio: ['pipe', 'pipe', 'pipe'],
164
+ maxBuffer: 64 * 1024 * 1024,
165
+ },
166
+ );
167
+ if (result.error) {
168
+ throw planPathError(
169
+ `cannot start plan publication worker in ${paths.directory}: ${result.error.message}`,
170
+ result.error,
171
+ );
172
+ }
173
+ let response;
174
+ try { response = JSON.parse(String(result.stdout || '').trim()); } catch (cause) {
175
+ throw workerError(
176
+ null,
177
+ `plan publication worker returned invalid output: ${String(result.stderr || '').trim()}`,
178
+ );
179
+ }
180
+ if (!response?.ok) throw workerError(response?.error, 'plan publication worker failed');
181
+ if (result.status !== 0) {
182
+ throw workerError(
183
+ response?.error,
184
+ `plan publication worker exited ${result.status}: ${String(result.stderr || '').trim()}`,
185
+ );
186
+ }
187
+ return response.result;
188
+ }
189
+
190
+ function inspectExistingArtifactDirectory(projectRoot, change) {
191
+ const root = path.resolve(projectRoot);
192
+ const paths = changePlanPaths(root, change);
193
+ const directory = path.dirname(paths.json);
194
+ let rootStat;
195
+ try { rootStat = fs.lstatSync(root); } catch (cause) {
196
+ throw planPathError(`project root does not exist: ${root}`, cause);
197
+ }
198
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
199
+ throw planPathError(`project root must be a real directory: ${root}`);
200
+ }
201
+ const physicalRoot = fs.realpathSync(root);
202
+ let current = root;
203
+ for (const segment of path.relative(root, directory).split(path.sep).filter(Boolean)) {
204
+ current = path.join(current, segment);
205
+ let stat;
206
+ try { stat = fs.lstatSync(current); } catch (cause) {
207
+ if (cause?.code === 'ENOENT') return null;
208
+ throw planPathError(`plan artifact ancestor cannot be inspected: ${current}`, cause);
209
+ }
210
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
211
+ throw planPathError(`plan artifact ancestor must be a real directory: ${current}`);
212
+ }
213
+ }
214
+ const physicalDirectory = fs.realpathSync(directory);
215
+ if (!containedPath(physicalRoot, physicalDirectory)) {
216
+ throw planPathError(`plan artifact directory escapes the physical project root: ${directory}`);
217
+ }
218
+ return {
219
+ ...paths,
220
+ root,
221
+ directory,
222
+ physicalRoot,
223
+ physicalDirectory,
224
+ };
225
+ }
226
+
227
+ function readChangePlanBytes(projectRoot, change) {
228
+ const paths = inspectExistingArtifactDirectory(projectRoot, change);
229
+ if (!paths) return null;
230
+ const identity = directoryIdentity(paths.directory);
231
+ const result = runDirectoryWorker(paths, 'read', {
232
+ identity,
233
+ target: 'plan.json',
234
+ });
235
+ if (!result.exists) return null;
236
+ const bytes = Buffer.from(result.content, 'base64');
237
+ if (digestContent(bytes) !== result.digest) {
238
+ throw planPathError('plan publication reader returned inconsistent bytes');
239
+ }
240
+ return bytes;
241
+ }
242
+
243
+ function renderPlanMd(plan, { tasks = [] } = {}) {
18
244
  const lines = [];
19
245
  lines.push('# Execution Plan');
20
246
  lines.push('');
21
247
  if (plan.change_id) lines.push(`- Change: ${plan.change_id}`);
248
+ if (plan.context?.snapshot_id) lines.push(`- Context snapshot: ${plan.context.snapshot_id}`);
249
+ if (plan.context?.manifest_digest) lines.push(`- Context digest: ${plan.context.manifest_digest}`);
22
250
  lines.push(`- Waves: ${plan.waves.length}`);
23
251
  lines.push(plan.estimated_cost_usd === null
24
252
  ? '- Estimated cost: unavailable (exact runtime model was not recorded)'
@@ -46,17 +274,57 @@ function renderPlanMd(plan) {
46
274
  lines.push(`- **files**: ${c.files.join(', ')}; **tasks**: ${c.tasks.join(', ')}; **recommend**: ${c.recommend}`);
47
275
  }
48
276
  }
277
+ if (tasks.length > 0) {
278
+ lines.push('');
279
+ lines.push('## Task Contracts');
280
+ lines.push('');
281
+ for (const task of [...tasks].sort((left, right) => String(left.id).localeCompare(String(right.id)))) {
282
+ lines.push(`## Task ${task.id}`);
283
+ lines.push('');
284
+ lines.push(`- Purpose: ${task.outcome || 'unresolved'}`);
285
+ lines.push(`- Why: ${task.trace_to || 'unresolved'}`);
286
+ lines.push(`- Slice: ${task.slice_kind || 'unresolved'}`);
287
+ lines.push(`- Public seam: ${task.public_seam || 'unresolved'}`);
288
+ lines.push(`- Target files: ${(task.files_modified || []).join(', ') || 'resolved at implementation time'}`);
289
+ lines.push(`- Verification: ${task.verification_command || 'unresolved'}`);
290
+ lines.push(`- Documentation: ${task.docs_impact?.status || 'unknown'} — ${task.docs_impact?.rationale || 'unresolved'}`);
291
+ lines.push('');
292
+ lines.push('### Acceptance');
293
+ lines.push('');
294
+ for (const item of task.acceptance || []) {
295
+ lines.push(`- ${item.id}: ${item.criterion} — \`${item.verification}\``);
296
+ }
297
+ if ((task.acceptance || []).length === 0) lines.push('- unresolved');
298
+ lines.push('');
299
+ lines.push('### Pattern and context references');
300
+ lines.push('');
301
+ for (const item of task.context_refs || []) {
302
+ const locator = [item.ref, item.anchor].filter(Boolean).join('#');
303
+ const scope = item.scope ? `; scope: ${item.scope}` : '';
304
+ lines.push(`- \`${locator}\` — ${item.reason}${scope}`);
305
+ }
306
+ if ((task.context_refs || []).length === 0) lines.push('- unresolved');
307
+ lines.push('');
308
+ lines.push('### Recovery and drift');
309
+ lines.push('');
310
+ lines.push('- Recovery: follow the owning Change Contract recovery strategy and verification.');
311
+ lines.push('- Drift: recompile when the Change contract, task contract, or a digest-bound context reference changes.');
312
+ lines.push('');
313
+ }
314
+ }
49
315
  return lines.join('\n') + '\n';
50
316
  }
51
317
 
52
- function savePlanArtifact(plan, outPath, format = 'json') {
318
+ function savePlanArtifact(plan, outPath, format = 'json', renderOptions = {}) {
53
319
  if (!outPath) {
54
320
  const err = new Error('out_path required');
55
321
  err.code = 'WRITE_FAILED';
56
322
  throw err;
57
323
  }
58
324
  const abs = path.resolve(outPath);
59
- const content = format === 'md' ? renderPlanMd(plan) : JSON.stringify(plan, null, 2);
325
+ const content = format === 'md'
326
+ ? renderPlanMd(plan, renderOptions)
327
+ : `${JSON.stringify(plan, null, 2)}\n`;
60
328
  try {
61
329
  fs.mkdirSync(path.dirname(abs), { recursive: true });
62
330
  writeAtomic(abs, content);
@@ -79,6 +347,426 @@ function loadPlanArtifact(projectRoot) {
79
347
  }
80
348
  }
81
349
 
350
+ function changePlanPaths(projectRoot, change) {
351
+ const artifactRoot = String(change?.artifact_root || '').trim();
352
+ if (!artifactRoot || path.isAbsolute(artifactRoot)) {
353
+ const error = new Error('change artifact_root must be project-relative');
354
+ error.code = 'WRITE_FAILED';
355
+ throw error;
356
+ }
357
+ const root = path.resolve(projectRoot);
358
+ const directory = path.resolve(root, artifactRoot);
359
+ if (directory !== root && !directory.startsWith(`${root}${path.sep}`)) {
360
+ const error = new Error('change artifact_root escapes project root');
361
+ error.code = 'WRITE_FAILED';
362
+ throw error;
363
+ }
364
+ return {
365
+ json: path.join(directory, 'plan.json'),
366
+ md: path.join(directory, 'plan.md'),
367
+ };
368
+ }
369
+
370
+ function saveChangePlanArtifacts(plan, {
371
+ rootDir,
372
+ change,
373
+ tasks = [],
374
+ context,
375
+ } = {}) {
376
+ const publication = prepareChangePlanPublication(plan, {
377
+ rootDir,
378
+ change,
379
+ tasks,
380
+ context,
381
+ });
382
+ try {
383
+ publication.publish();
384
+ } catch (error) {
385
+ const rollback = publication.rollback();
386
+ if (rollback?.rolled_back === false) {
387
+ throw planRecoveryRequiredError(error, rollback, publication.transaction_id);
388
+ }
389
+ throw error;
390
+ }
391
+ publication.commit();
392
+ return {
393
+ plan_path: publication.plan_path,
394
+ plan_md_path: publication.plan_md_path,
395
+ plan: publication.plan,
396
+ };
397
+ }
398
+
399
+ function errorEvidence(error) {
400
+ return {
401
+ code: error?.code || 'PLAN_PUBLISH_FAILED',
402
+ message: error?.message || 'plan publication failed',
403
+ ...(error?.details !== undefined ? { details: error.details } : {}),
404
+ };
405
+ }
406
+
407
+ function planRecoveryRequiredError(originalError, rollback, transactionId) {
408
+ const original = originalError?.details?.original_error || errorEvidence(originalError);
409
+ const rollbackIssue = rollback?.issue
410
+ || originalError?.details?.rollback_issue
411
+ || {
412
+ code: 'PLAN_RECOVERY_FAILED',
413
+ message: 'plan publication rollback did not complete',
414
+ };
415
+ const error = new Error(
416
+ `plan publication failed and rollback recovery is required: ${original.message}; `
417
+ + `${rollbackIssue.message}`,
418
+ );
419
+ error.code = 'PLAN_RECOVERY_REQUIRED';
420
+ error.cause = originalError;
421
+ error.details = {
422
+ transaction_id: transactionId,
423
+ original_error: original,
424
+ rollback_issue: rollbackIssue,
425
+ issue: rollbackIssue,
426
+ };
427
+ return error;
428
+ }
429
+
430
+ function prepareChangePlanPublication(plan, {
431
+ rootDir,
432
+ change,
433
+ tasks = [],
434
+ context,
435
+ } = {}) {
436
+ if (!change || plan?.change_id !== change.id) {
437
+ const error = new Error('plan must be bound to its owning change');
438
+ error.code = 'PLAN_CHANGE_MISMATCH';
439
+ throw error;
440
+ }
441
+ if (!context?.snapshot_id || !/^[0-9a-f]{64}$/.test(context.manifest_digest || '')) {
442
+ const error = new Error('plan export requires a digest-bound planning context');
443
+ error.code = 'PLAN_CONTEXT_REQUIRED';
444
+ throw error;
445
+ }
446
+ const boundPlan = {
447
+ ...plan,
448
+ context: {
449
+ snapshot_id: context.snapshot_id,
450
+ manifest_path: context.manifest_path,
451
+ manifest_digest: context.manifest_digest,
452
+ },
453
+ };
454
+ const paths = verifyArtifactDirectory(rootDir, change);
455
+ const jsonContent = `${JSON.stringify(boundPlan, null, 2)}\n`;
456
+ const mdContent = renderPlanMd(boundPlan, { tasks });
457
+ const canonicalPath = (file) => path.relative(path.resolve(rootDir), file)
458
+ .split(path.sep).join('/');
459
+ return beginPlanPublication({
460
+ paths,
461
+ boundPlan,
462
+ jsonContent,
463
+ mdContent,
464
+ authority: {
465
+ owner_type: 'change',
466
+ owner_id: change.id,
467
+ paths: {
468
+ execution_plan: canonicalPath(paths.json),
469
+ execution_plan_markdown: canonicalPath(paths.md),
470
+ },
471
+ },
472
+ });
473
+ }
474
+
475
+ function beginPlanPublication({
476
+ paths,
477
+ boundPlan,
478
+ jsonContent,
479
+ mdContent,
480
+ authority,
481
+ }) {
482
+ const transactionId = crypto.randomUUID();
483
+ const identity = directoryIdentity(paths.directory);
484
+ let prepared;
485
+ try {
486
+ prepared = runDirectoryWorker(paths, 'prepare', {
487
+ identity,
488
+ transaction_id: transactionId,
489
+ contents: {
490
+ execution_plan: jsonContent,
491
+ execution_plan_markdown: mdContent,
492
+ },
493
+ authority,
494
+ });
495
+ } catch (cause) {
496
+ if (cause?.code === 'PLAN_ARTIFACT_PATH_UNSAFE') throw cause;
497
+ const error = new Error(`cannot stage plan publication: ${cause.message}`);
498
+ error.code = cause?.code || 'PLAN_PUBLISH_FAILED';
499
+ error.cause = cause;
500
+ throw error;
501
+ }
502
+ let published = false;
503
+ let finalized = false;
504
+
505
+ const rollback = () => {
506
+ if (finalized) return;
507
+ try {
508
+ runDirectoryWorker(paths, 'rollback', {
509
+ identity,
510
+ transaction_id: transactionId,
511
+ });
512
+ finalized = true;
513
+ } catch (error) {
514
+ return {
515
+ rolled_back: false,
516
+ issue: {
517
+ code: error.code || 'PLAN_RECOVERY_FAILED',
518
+ message: error.message,
519
+ ...(error.details !== undefined ? { details: error.details } : {}),
520
+ },
521
+ };
522
+ }
523
+ return { rolled_back: true };
524
+ };
525
+
526
+ const publish = () => {
527
+ if (published) return;
528
+ try {
529
+ runDirectoryWorker(paths, 'publish', {
530
+ identity,
531
+ transaction_id: transactionId,
532
+ });
533
+ published = true;
534
+ } catch (cause) {
535
+ const rollbackResult = rollback();
536
+ if (rollbackResult?.rolled_back === false) {
537
+ throw planRecoveryRequiredError(cause, rollbackResult, transactionId);
538
+ }
539
+ if (['PLAN_ARTIFACT_PATH_UNSAFE', 'PLAN_PUBLISH_CONFLICT'].includes(cause?.code)) {
540
+ throw cause;
541
+ }
542
+ const error = new Error(`cannot publish plan artifacts: ${cause.message}`);
543
+ error.code = cause?.code || 'PLAN_PUBLISH_FAILED';
544
+ error.cause = cause;
545
+ throw error;
546
+ }
547
+ };
548
+
549
+ const commit = () => {
550
+ if (finalized) return;
551
+ if (!published) {
552
+ const error = new Error('plan publication was not published');
553
+ error.code = 'PLAN_PUBLISH_FAILED';
554
+ throw error;
555
+ }
556
+ try {
557
+ runDirectoryWorker(paths, 'commit', {
558
+ identity,
559
+ transaction_id: transactionId,
560
+ });
561
+ finalized = true;
562
+ } catch (cause) {
563
+ const error = new Error(
564
+ `plan publication cleanup is incomplete; recovery is required: ${cause.message}`,
565
+ );
566
+ error.code = 'PLAN_RECOVERY_REQUIRED';
567
+ error.cause = cause;
568
+ error.details = {
569
+ transaction_id: transactionId,
570
+ issue: {
571
+ code: cause.code || 'PLAN_RECOVERY_FAILED',
572
+ message: cause.message,
573
+ details: cause.details,
574
+ },
575
+ };
576
+ throw error;
577
+ }
578
+ };
579
+
580
+ return {
581
+ transaction_id: transactionId,
582
+ plan_path: paths.json,
583
+ plan_md_path: paths.md,
584
+ plan: boundPlan,
585
+ artifacts: prepared.entries.map((entry) => ({
586
+ kind: entry.kind,
587
+ path: path.join(paths.directory, entry.target),
588
+ digest: entry.digest,
589
+ })),
590
+ publish,
591
+ rollback,
592
+ commit,
593
+ };
594
+ }
595
+
596
+ function canonicalChangeRows(db) {
597
+ return db.prepare(
598
+ `SELECT id, artifact_root
599
+ FROM changes
600
+ WHERE artifact_root IS NOT NULL AND TRIM(artifact_root) <> ''
601
+ ORDER BY id`,
602
+ ).all();
603
+ }
604
+
605
+ function registryAuthorityForPath(db, relativePath) {
606
+ const rows = db.prepare(
607
+ `SELECT owner_type, owner_id, kind, path, digest, content_hash,
608
+ status, managed, provenance_json
609
+ FROM artifacts
610
+ WHERE path = ? AND status <> 'archived'
611
+ ORDER BY updated_at DESC, rowid DESC`,
612
+ ).all(relativePath);
613
+ if (rows.length !== 1) return null;
614
+ let provenance = {};
615
+ try { provenance = JSON.parse(rows[0].provenance_json || '{}'); } catch { provenance = {}; }
616
+ return {
617
+ owner_type: rows[0].owner_type,
618
+ owner_id: rows[0].owner_id,
619
+ kind: rows[0].kind,
620
+ path: rows[0].path,
621
+ digest: rows[0].digest || rows[0].content_hash || null,
622
+ status: rows[0].status,
623
+ managed: rows[0].managed,
624
+ provenance,
625
+ };
626
+ }
627
+
628
+ function inspectPlanPublications(db, {
629
+ rootDir = process.cwd(),
630
+ } = {}) {
631
+ let pending = 0;
632
+ const transactions = [];
633
+ const issues = [];
634
+ for (const change of canonicalChangeRows(db)) {
635
+ let paths;
636
+ try {
637
+ paths = inspectExistingArtifactDirectory(rootDir, change);
638
+ if (!paths) continue;
639
+ const identity = directoryIdentity(paths.directory);
640
+ const inspected = runDirectoryWorker(paths, 'inspect', { identity });
641
+ pending += inspected.pending;
642
+ transactions.push(...inspected.transactions.map((item) => ({
643
+ ...item,
644
+ change_id: change.id,
645
+ artifact_root: change.artifact_root,
646
+ })));
647
+ issues.push(...inspected.issues.map((item) => ({
648
+ ...item,
649
+ change_id: change.id,
650
+ artifact_root: change.artifact_root,
651
+ })));
652
+ } catch (error) {
653
+ pending += 1;
654
+ issues.push({
655
+ code: error.code || 'PLAN_RECOVERY_FAILED',
656
+ change_id: change.id,
657
+ artifact_root: change.artifact_root,
658
+ message: error.message,
659
+ });
660
+ }
661
+ }
662
+ return {
663
+ status: pending === 0 && issues.length === 0 ? 'pass' : 'fail',
664
+ pending,
665
+ transactions,
666
+ issues,
667
+ };
668
+ }
669
+
670
+ function recoverPlanPublications(db, {
671
+ rootDir = process.cwd(),
672
+ } = {}) {
673
+ let recovered = 0;
674
+ let finalized = 0;
675
+ const issues = [];
676
+ for (const change of canonicalChangeRows(db)) {
677
+ let paths;
678
+ try {
679
+ paths = inspectExistingArtifactDirectory(rootDir, change);
680
+ if (!paths) continue;
681
+ const identity = directoryIdentity(paths.directory);
682
+ const relative = (file) => path.relative(path.resolve(rootDir), file)
683
+ .split(path.sep).join('/');
684
+ const result = runDirectoryWorker(paths, 'recover', {
685
+ identity,
686
+ registry: {
687
+ 'plan.json': registryAuthorityForPath(db, relative(paths.json)),
688
+ 'plan.md': registryAuthorityForPath(db, relative(paths.md)),
689
+ },
690
+ });
691
+ recovered += result.recovered;
692
+ finalized += result.finalized;
693
+ issues.push(...result.issues.map((item) => ({
694
+ ...item,
695
+ change_id: change.id,
696
+ artifact_root: change.artifact_root,
697
+ })));
698
+ } catch (error) {
699
+ issues.push({
700
+ code: error.code || 'PLAN_RECOVERY_FAILED',
701
+ change_id: change.id,
702
+ artifact_root: change.artifact_root,
703
+ message: error.message,
704
+ });
705
+ }
706
+ }
707
+ const after = inspectPlanPublications(db, { rootDir });
708
+ return {
709
+ recovered,
710
+ finalized,
711
+ pending: after.pending,
712
+ issues: [...issues, ...after.issues],
713
+ };
714
+ }
715
+
716
+ function loadChangePlanArtifact(projectRoot, change, {
717
+ db = null,
718
+ strict = false,
719
+ } = {}) {
720
+ try {
721
+ const bytes = readChangePlanBytes(projectRoot, change);
722
+ if (!bytes) return null;
723
+ const relative = path.relative(
724
+ path.resolve(projectRoot),
725
+ changePlanPaths(projectRoot, change).json,
726
+ ).split(path.sep).join('/');
727
+ if (db) {
728
+ const rows = db.prepare(
729
+ `SELECT owner_type, owner_id, kind, digest, content_hash, status
730
+ FROM artifacts
731
+ WHERE path = ? AND status <> 'archived'
732
+ ORDER BY updated_at DESC, rowid DESC`,
733
+ ).all(relative);
734
+ if (rows.length !== 1
735
+ || rows[0].owner_type !== 'change'
736
+ || rows[0].owner_id !== change.id
737
+ || rows[0].kind !== 'execution_plan'
738
+ || rows[0].status !== 'current') {
739
+ const error = new Error(`canonical Change plan registry authority is invalid: ${relative}`);
740
+ error.code = 'PLAN_ARTIFACT_INVALID';
741
+ throw error;
742
+ }
743
+ const expected = rows[0].digest || rows[0].content_hash;
744
+ const actual = digestContent(bytes);
745
+ if (!expected || expected !== actual) {
746
+ const error = new Error(`canonical Change plan bytes do not match registry authority: ${relative}`);
747
+ error.code = 'PLAN_ARTIFACT_INVALID';
748
+ throw error;
749
+ }
750
+ }
751
+ const value = JSON.parse(bytes.toString('utf8'));
752
+ if (value?.change_id !== change.id) {
753
+ const error = new Error(`canonical Change plan is not bound to ${change.id}`);
754
+ error.code = 'PLAN_ARTIFACT_INVALID';
755
+ throw error;
756
+ }
757
+ return value;
758
+ } catch (error) {
759
+ if (strict) {
760
+ if (error?.code === 'PLAN_ARTIFACT_INVALID') throw error;
761
+ const wrapped = new Error(`canonical Change plan is unsafe or invalid: ${error.message}`);
762
+ wrapped.code = 'PLAN_ARTIFACT_INVALID';
763
+ wrapped.cause = error;
764
+ throw wrapped;
765
+ }
766
+ return null;
767
+ }
768
+ }
769
+
82
770
  function selectSection(plan, section) {
83
771
  switch (section || 'all') {
84
772
  case 'tasks': return { ownership_forecast: plan.ownership_forecast };
@@ -92,8 +780,15 @@ function selectSection(plan, section) {
92
780
  module.exports = {
93
781
  savePlanArtifact,
94
782
  loadPlanArtifact,
783
+ saveChangePlanArtifacts,
784
+ prepareChangePlanPublication,
785
+ inspectPlanPublications,
786
+ recoverPlanPublications,
787
+ loadChangePlanArtifact,
788
+ changePlanPaths,
95
789
  selectSection,
96
790
  renderPlanMd,
97
791
  buildPlan,
792
+ planRecoveryRequiredError,
98
793
  DEFAULT_ARTIFACT_RELPATH,
99
794
  };