ultra-builder-pro-cli 0.20.0 → 0.22.0

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 (109) 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 +10 -3
  4. package/.ultra-template/templates/task-context.md +19 -0
  5. package/CHANGELOG.md +106 -0
  6. package/README.md +215 -41
  7. package/adapters/_shared/codex-assets.cjs +69 -11
  8. package/adapters/_shared/interaction-contract.cjs +36 -3
  9. package/adapters/_shared/runtime-assets.cjs +41 -1
  10. package/adapters/claude.js +7 -5
  11. package/adapters/codex.js +1 -1
  12. package/adapters/kimi.js +2 -8
  13. package/adapters/opencode.js +43 -30
  14. package/bin/orchestrator.js +64 -24
  15. package/docs/AGENT-CONTEXT.md +89 -49
  16. package/docs/ARCHITECTURE.md +112 -45
  17. package/docs/ARTIFACT-AUTHORITY.md +274 -0
  18. package/docs/COMMIT-HASH-BACKFILL.md +33 -90
  19. package/docs/DECISIONS.md +14 -6
  20. package/docs/LEGACY-CLI-CROSSWALK.md +16 -6
  21. package/docs/PLUGIN-ISOLATION-CONTRACT.md +6 -4
  22. package/docs/ROADMAP.md +20 -6
  23. package/docs/RUNTIME-COMPAT-MATRIX.md +29 -16
  24. package/docs/STATE-DB-ACCESS-POLICY.md +43 -13
  25. package/docs/WORKFLOW-LIFECYCLE.md +105 -50
  26. package/hooks/README.md +10 -8
  27. package/hooks/active_task_context.py +105 -18
  28. package/hooks/context_spine.py +16 -4
  29. package/hooks/health_check.py +5 -4
  30. package/hooks/pre_stop_check.py +5 -2
  31. package/hooks/runtime_paths.py +396 -0
  32. package/hooks/subagent_tracker.py +5 -2
  33. package/hooks/workflow_checkpoint.py +16 -3
  34. package/hooks/workflow_context.py +11 -3
  35. package/hooks/workflow_resume.py +7 -4
  36. package/mcp-server/breadcrumb.cjs +20 -2
  37. package/mcp-server/lib/archive-journal.cjs +1026 -111
  38. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  39. package/mcp-server/lib/artifact-registry.cjs +1127 -0
  40. package/mcp-server/lib/baseline-workflow.cjs +69 -28
  41. package/mcp-server/lib/change-packet.cjs +818 -0
  42. package/mcp-server/lib/change-workflow.cjs +932 -176
  43. package/mcp-server/lib/context-paths.cjs +142 -0
  44. package/mcp-server/lib/context-spine.cjs +511 -45
  45. package/mcp-server/lib/decision-dialogue.cjs +345 -3
  46. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  47. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  48. package/mcp-server/lib/doctor.cjs +401 -11
  49. package/mcp-server/lib/git-bootstrap.cjs +458 -47
  50. package/mcp-server/lib/init-project.cjs +240 -95
  51. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  52. package/mcp-server/lib/plan-store.cjs +700 -5
  53. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  54. package/mcp-server/lib/projector.cjs +211 -42
  55. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  56. package/mcp-server/lib/safe-project-file.cjs +385 -0
  57. package/mcp-server/lib/spec-learning.cjs +28 -12
  58. package/mcp-server/lib/state-authority.cjs +19 -3
  59. package/mcp-server/lib/state-db.cjs +263 -9
  60. package/mcp-server/lib/state-ops.cjs +254 -50
  61. package/mcp-server/lib/task-ledger.cjs +1342 -0
  62. package/mcp-server/lib/telemetry.cjs +12 -3
  63. package/mcp-server/lib/workflow-state.cjs +610 -58
  64. package/mcp-server/server.cjs +423 -42
  65. package/orchestrator/daemon.cjs +2 -10
  66. package/orchestrator/parallel-orchestrator.cjs +314 -50
  67. package/orchestrator/planner/plan-builder.cjs +1 -1
  68. package/orchestrator/recovery.cjs +125 -7
  69. package/orchestrator/session-close-journal-worker.cjs +5 -0
  70. package/orchestrator/session-close-journal.cjs +1014 -0
  71. package/orchestrator/session-runner.cjs +679 -69
  72. package/orchestrator/worktree-manager.cjs +186 -35
  73. package/package.json +2 -3
  74. package/skills/cc-collab/SKILL.md +4 -1
  75. package/skills/codex-collab/SKILL.md +5 -2
  76. package/skills/ultra-change/SKILL.md +34 -8
  77. package/skills/ultra-deliver/SKILL.md +26 -11
  78. package/skills/ultra-dev/SKILL.md +52 -10
  79. package/skills/ultra-doctor/SKILL.md +16 -3
  80. package/skills/ultra-init/SKILL.md +22 -11
  81. package/skills/ultra-plan/SKILL.md +58 -18
  82. package/skills/ultra-research/SKILL.md +37 -35
  83. package/skills/ultra-review/SKILL.md +14 -8
  84. package/skills/ultra-status/SKILL.md +18 -9
  85. package/skills/ultra-test/SKILL.md +11 -5
  86. package/skills/ultra-think/SKILL.md +6 -2
  87. package/skills/ultra-think/references/decision-dialogue.md +17 -6
  88. package/skills/ultra-verify/SKILL.md +4 -1
  89. package/spec/cli-protocol.md +21 -7
  90. package/spec/mcp-tools.yaml +629 -44
  91. package/spec/schemas/change-delta.v1.schema.json +153 -0
  92. package/spec/schemas/context-file.v4.5.schema.json +1 -1
  93. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  94. package/spec/schemas/mcp-tools.schema.json +1 -1
  95. package/spec/schemas/state-db.sql +51 -14
  96. package/spec/schemas/task-ledger.v1.schema.json +217 -0
  97. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  98. package/ultra-tools/cli.cjs +3 -2
  99. package/ultra-tools/commands/db.cjs +76 -25
  100. package/ultra-tools/commands/migrate.cjs +1000 -82
  101. package/ultra-tools/commands/session.cjs +34 -8
  102. package/ultra-tools/commands/status.cjs +32 -6
  103. package/ultra-tools/commands/system.cjs +129 -22
  104. package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
  105. package/commands/learn.md +0 -10
  106. package/orchestrator/skill-miner.cjs +0 -133
  107. package/output-styles/coding-vibes.md +0 -67
  108. package/output-styles/structural-thinking.md +0 -61
  109. package/skills/learn/SKILL.md +0 -58
@@ -7,6 +7,7 @@ const Database = require('better-sqlite3');
7
7
 
8
8
  const { EXPECTED_VERSION, REQUIRED_TABLES } = require('./state-db.cjs');
9
9
  const { readBreadcrumb } = require('./context-spine.cjs');
10
+ const runtimePaths = require('./runtime-paths.cjs');
10
11
 
11
12
  function blockedBreadcrumb(code, {
12
13
  allowedTransitions = [],
@@ -26,6 +27,7 @@ function blockedBreadcrumb(code, {
26
27
  context_manifest_hash: null,
27
28
  git_head: null,
28
29
  baseline: null,
30
+ accepted_intent: [],
29
31
  };
30
32
  }
31
33
 
@@ -43,6 +45,12 @@ function doctorBreadcrumb(code) {
43
45
  });
44
46
  }
45
47
 
48
+ function compactText(value, maximum = 240) {
49
+ const normalized = String(value || '').replace(/\s+/g, ' ').trim();
50
+ if (normalized.length <= maximum) return normalized;
51
+ return `${normalized.slice(0, maximum - 3).trimEnd()}...`;
52
+ }
53
+
46
54
  function latestSchemaVersion(db) {
47
55
  return db.prepare(
48
56
  'SELECT version FROM schema_version ORDER BY applied_at DESC, rowid DESC LIMIT 1',
@@ -59,7 +67,7 @@ function readProjectBreadcrumb(rootDir) {
59
67
  const root = path.resolve(rootDir || process.cwd());
60
68
  const ultraDir = path.join(root, '.ultra');
61
69
  if (!fs.existsSync(ultraDir)) return null;
62
- const dbPath = path.join(ultraDir, 'state.db');
70
+ const dbPath = runtimePaths.locateStateDb(root);
63
71
  if (!fs.existsSync(dbPath)) {
64
72
  return blockedBreadcrumb('STATE_DB_MISSING', {
65
73
  allowedTransitions: ['ultra-init', 'ultra-status'],
@@ -131,8 +139,17 @@ function renderProjectBreadcrumb(rootDir, breadcrumb) {
131
139
  lines.push(
132
140
  `Decision thread: ${breadcrumb.decision.thread_id} (${breadcrumb.decision.status}/${breadcrumb.decision.mode})`,
133
141
  current
134
- ? `Decision: ${current.id} — ${current.question}`
135
- : 'Decision: checkpoint confirmation required',
142
+ ? `Decision: ${current.id} — ${compactText(current.question)}`
143
+ : (breadcrumb.decision.status === 'checkpoint_ready'
144
+ ? 'Decision: checkpoint confirmation required'
145
+ : 'Decision: normalized intent recorded; application and read-back remain'),
146
+ );
147
+ }
148
+ for (const accepted of (breadcrumb.accepted_intent || []).slice(0, 3)) {
149
+ const value = accepted.decision
150
+ || (accepted.consequences ? `Deferred: ${accepted.consequences}` : 'Normalized state recorded');
151
+ lines.push(
152
+ `Accepted intent: ${accepted.decision_id} [${accepted.authority || accepted.status}] ${compactText(value)}`,
136
153
  );
137
154
  }
138
155
  lines.push(`Allowed transitions: ${(breadcrumb.allowed_transitions || []).join(', ') || 'none'}`);
@@ -144,7 +161,9 @@ function renderProjectBreadcrumb(rootDir, breadcrumb) {
144
161
  `Context: ${breadcrumb.context_manifest_path} sha256=${breadcrumb.context_manifest_hash || 'unknown'}`,
145
162
  );
146
163
  }
147
- lines.push('Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.');
164
+ lines.push(
165
+ 'Lifecycle authority: .ultra/.runtime/state.db; digest-bound files carry semantic and evidence content; projections remain non-authoritative.',
166
+ );
148
167
  return lines.join('\n');
149
168
  }
150
169
 
@@ -1,9 +1,10 @@
1
1
  'use strict';
2
2
 
3
- // Projector: regenerates the read-only file views from .ultra/state.db.
3
+ // Projector: regenerates the read-only file views from .ultra/.runtime/state.db.
4
4
  //
5
- // state.db is the only authority (D32). tasks.json and contexts/task-*.md
6
- // are projections — humans may read them, never write them. The projector
5
+ // state.db owns lifecycle and artifact bindings (D32); registered digest-bound
6
+ // files own semantic/evidence bodies. Live tasks.json and contexts/task-*.md are
7
+ // checkout-local projections — humans may read them, never write them. The projector
7
8
  // is trigger-based: state-ops calls projectAll() after each successful
8
9
  // write transaction. Projection output passes the v4.5 schemas under
9
10
  // spec/schemas/ — see mcp-server/tests/projector.test.cjs for the
@@ -11,9 +12,14 @@
11
12
 
12
13
  const fs = require('node:fs');
13
14
  const path = require('node:path');
15
+ const crypto = require('node:crypto');
16
+
17
+ const ops = require('./state-ops.cjs');
18
+ const artifacts = require('./artifact-registry.cjs');
19
+ const contextPaths = require('./context-paths.cjs');
14
20
 
15
21
  const SCHEMA_VERSION = '4.5';
16
- const SOURCE_TAG = '.ultra/state.db';
22
+ const SOURCE_TAG = '.ultra/.runtime/state.db';
17
23
 
18
24
  // Frozen SELECTs — values bind through @placeholders.
19
25
  const TASK_PROJECTION_COLUMNS = 'id, title, type, priority, complexity, estimated_days, status, deps, files_modified, session_id, stale, tag, trace_to, outcome, slice_kind, public_seam, verification_command, acceptance_json, context_refs_json, docs_impact_json, ownership_json, context_file, completion_commit, created_at, updated_at';
@@ -51,8 +57,8 @@ function rowToProjection(row) {
51
57
 
52
58
  function defaultPaths(rootDir) {
53
59
  return {
54
- tasksJson: path.join(rootDir, '.ultra', 'tasks', 'tasks.json'),
55
- contextsDir: path.join(rootDir, '.ultra', 'tasks', 'contexts'),
60
+ tasksJson: path.join(rootDir, '.ultra', '.runtime', 'projections', 'tasks.json'),
61
+ contextsDir: path.join(rootDir, '.ultra', '.runtime', 'projections', 'contexts'),
56
62
  };
57
63
  }
58
64
 
@@ -84,7 +90,7 @@ function projectTasks(db, { tasksJson } = {}, opts = {}) {
84
90
  return { path: target, count: rows.length };
85
91
  }
86
92
 
87
- function buildContextDoc(taskRow, existingBody) {
93
+ function buildContextDoc(taskRow) {
88
94
  const headerLines = [
89
95
  '---',
90
96
  `task_id: ${taskRow.id}`,
@@ -97,31 +103,20 @@ function buildContextDoc(taskRow, existingBody) {
97
103
  if (taskRow.completion_commit) headerLines.push(`completion_commit: ${taskRow.completion_commit}`);
98
104
  if (taskRow.stale) headerLines.push('stale: true');
99
105
  headerLines.push(`schema_version: ${SCHEMA_VERSION}`);
106
+ headerLines.push(`generated_by: ${contextPaths.GENERATED_BY}`);
100
107
  headerLines.push(`generated_at: ${taskRow.updated_at || new Date().toISOString()}`);
101
108
  headerLines.push('---', '');
102
109
 
103
- let body = existingBody || '';
104
- // v4.4 contexts carried a second generated status banner in the Markdown
105
- // body. Once frontmatter is authoritative, retaining that line creates two
106
- // state surfaces that can disagree. Remove only the legacy generated line;
107
- // all task-authored body content remains intact.
108
- body = body.replace(/^>\s*\*\*Status\*\*:.*(?:\r?\n|$)(?:\r?\n)?/mi, '');
109
- // Strip any previous STALE banner so re-projection after reset is clean.
110
- body = body.replace(/^> ⚠️\s*STALE[^\n]*\n(?:>[^\n]*\n)*\n?/m, '');
111
- body = body.replace(
112
- new RegExp(`${CONTRACT_START}[\\s\\S]*?${CONTRACT_END}\\n*`, 'm'),
113
- '',
114
- );
110
+ const body = [];
115
111
  if (taskRow.stale) {
116
- const banner = [
112
+ body.push([
117
113
  `> ⚠️ STALE since ${taskRow.updated_at || new Date().toISOString()}`,
118
114
  '> Specification evidence may have changed. Recompile the task context before continuing.',
119
115
  '',
120
- ].join('\n');
121
- body = banner + body;
116
+ ].join('\n'));
122
117
  }
123
- body = buildTaskContract(taskRow) + body;
124
- return headerLines.join('\n') + body;
118
+ body.push(buildTaskContract(taskRow));
119
+ return headerLines.join('\n') + body.join('');
125
120
  }
126
121
 
127
122
  function buildTaskContract(task) {
@@ -167,43 +162,215 @@ function escapeYaml(str) {
167
162
  return str;
168
163
  }
169
164
 
170
- function extractBodyFromExistingFile(file) {
171
- if (!fs.existsSync(file)) return '';
172
- const text = fs.readFileSync(file, 'utf8');
173
- if (!text.startsWith('---')) return text;
174
- const end = text.indexOf('\n---', 3);
175
- if (end === -1) return text;
176
- return text.slice(end + 4).replace(/^\n/, '');
165
+ function legacyContextParseError(message) {
166
+ const error = new Error(message);
167
+ error.code = 'LEGACY_CONTEXT_PARSE_ERROR';
168
+ return error;
169
+ }
170
+
171
+ function extractAuthoredContextBytes(contents) {
172
+ const original = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
173
+ let body = original.toString('utf8');
174
+ const frontmatter = /^(?:\uFEFF)?---(?:\r\n|\n)[\s\S]*?(?:\r\n|\n)---(?:(?:\r\n|\n)|$)/.exec(body);
175
+ if (frontmatter) body = body.slice(frontmatter[0].length);
176
+ const staleBanner = /^> ⚠️ STALE[^\r\n]*(?:(?:\r\n|\n)>[^\r\n]*)*(?:(?:\r\n|\n){1,2}|$)/.exec(body);
177
+ if (staleBanner) body = body.slice(staleBanner[0].length);
178
+
179
+ let cursor = 0;
180
+ while (true) {
181
+ const start = body.indexOf(CONTRACT_START, cursor);
182
+ if (start === -1) break;
183
+ const markerEnd = body.indexOf(CONTRACT_END, start + CONTRACT_START.length);
184
+ if (markerEnd === -1) {
185
+ throw legacyContextParseError(
186
+ 'legacy task context has an unterminated generated execution contract',
187
+ );
188
+ }
189
+ let end = markerEnd + CONTRACT_END.length;
190
+ if (body.startsWith('\r\n', end)) end += 2;
191
+ else if (body.startsWith('\n', end)) end += 1;
192
+ const removeFrom = body.slice(0, start).trim() === '' ? 0 : start;
193
+ body = body.slice(0, removeFrom) + body.slice(end);
194
+ cursor = removeFrom;
195
+ }
196
+ return body.trim() === '' ? null : Buffer.from(body, 'utf8');
197
+ }
198
+
199
+ function promoteAuthoredContext(db, taskId, target, rootDir) {
200
+ if (!fs.existsSync(target.file)) return null;
201
+ const stat = fs.lstatSync(target.file);
202
+ if (!stat.isFile() || stat.isSymbolicLink()) {
203
+ throw new contextPaths.ContextPathError(
204
+ `task context must be a regular file: ${target.relative}`,
205
+ );
206
+ }
207
+ const sourceBytes = fs.readFileSync(target.file);
208
+ const bytes = extractAuthoredContextBytes(sourceBytes);
209
+ if (!bytes) return null;
210
+ const digest = crypto.createHash('sha256').update(bytes).digest('hex');
211
+ const sourceDigest = crypto.createHash('sha256').update(sourceBytes).digest('hex');
212
+ const safeTaskId = String(taskId).replace(/[^a-zA-Z0-9_-]+/g, '-');
213
+ const relative = `.ultra/docs/migration/legacy-context/${safeTaskId}-${digest.slice(0, 12)}.md`;
214
+ const file = path.resolve(rootDir, ...relative.split('/'));
215
+ const existed = fs.existsSync(file);
216
+ if (existed) {
217
+ const existingDigest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
218
+ if (existingDigest !== digest) {
219
+ throw new Error(`legacy context promotion digest conflict: ${relative}`);
220
+ }
221
+ } else {
222
+ writeAtomic(file, bytes);
223
+ }
224
+ try {
225
+ const recorded = artifacts.recordArtifact(db, {
226
+ owner_type: 'task',
227
+ owner_id: taskId,
228
+ kind: 'legacy_context_findings',
229
+ path: relative,
230
+ content_digest: digest,
231
+ source_refs: [{
232
+ type: 'external',
233
+ id: `legacy-context:${target.relative}`,
234
+ relation: 'imported_from',
235
+ }],
236
+ consumer_refs: [{ type: 'task', id: taskId, relation: 'consumed_by' }],
237
+ provenance: {
238
+ writer: 'ultra-projector',
239
+ operation: 'legacy-context-promotion',
240
+ },
241
+ metadata: {
242
+ original_path: target.relative,
243
+ original_digest: sourceDigest,
244
+ authored_body_digest: digest,
245
+ },
246
+ }, { rootDir });
247
+ return { path: relative, digest, artifact_id: recorded.artifact.id };
248
+ } catch (error) {
249
+ if (!existed) fs.rmSync(file, { force: true });
250
+ throw error;
251
+ }
177
252
  }
178
253
 
179
254
  function projectContext(db, taskId, { contextsDir } = {}, opts = {}) {
180
255
  const row = db.prepare(READ_TASK_FOR_PROJECTION_SQL).get({ id: taskId });
181
256
  if (!row) return null;
182
257
  const projection = rowToProjection(row);
183
- const dir = contextsDir || defaultPaths(opts.rootDir || '.').contextsDir;
184
- const target = projection.context_file
185
- ? path.resolve(opts.rootDir || '.', projection.context_file)
186
- : path.join(dir, `task-${taskId}.md`);
187
- const body = extractBodyFromExistingFile(target);
188
- const next = buildContextDoc(projection, body);
189
- writeAtomic(target, next);
190
- return { path: target, task_id: taskId };
258
+ const rootDir = path.resolve(opts.rootDir || '.');
259
+ if (contextsDir) {
260
+ const supplied = path.resolve(contextsDir);
261
+ const canonical = defaultPaths(rootDir).contextsDir;
262
+ if (supplied !== canonical) {
263
+ throw new contextPaths.ContextPathError(
264
+ `custom context projection roots are not allowed: ${contextsDir}`,
265
+ );
266
+ }
267
+ }
268
+ const target = contextPaths.resolveContextPath(rootDir, projection.context_file, { taskId });
269
+ const legacyValue = String(projection.context_file || '')
270
+ .replaceAll('\\', '/')
271
+ .replace(/^\.\//, '');
272
+ const legacyDefault = `${contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE}/${
273
+ path.posix.basename(target.relative)
274
+ }`;
275
+ const legacyTarget = contextPaths.resolveLegacyContextPath(
276
+ rootDir,
277
+ legacyValue.startsWith(`${contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE}/`)
278
+ ? legacyValue
279
+ : legacyDefault,
280
+ { taskId },
281
+ );
282
+ const legacyPromoted = promoteAuthoredContext(db, taskId, legacyTarget, rootDir);
283
+ if (fs.existsSync(legacyTarget.file)
284
+ && contextPaths.isGeneratedContextContents(fs.readFileSync(legacyTarget.file, 'utf8'))) {
285
+ fs.rmSync(legacyTarget.file, { force: true });
286
+ } else if (legacyPromoted) {
287
+ fs.rmSync(legacyTarget.file, { force: true });
288
+ }
289
+ const promoted = promoteAuthoredContext(db, taskId, target, rootDir) || legacyPromoted;
290
+ const next = buildContextDoc(projection);
291
+ writeAtomic(target.file, next);
292
+ return { path: target.file, task_id: taskId, promoted };
293
+ }
294
+
295
+ function listContextFiles(directory) {
296
+ if (!fs.existsSync(directory)) return [];
297
+ const output = [];
298
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
299
+ const file = path.join(directory, entry.name);
300
+ if (entry.isSymbolicLink()) continue;
301
+ if (entry.isDirectory()) output.push(...listContextFiles(file));
302
+ else if (entry.isFile()) output.push(file);
303
+ }
304
+ return output;
305
+ }
306
+
307
+ function pruneGeneratedGhostsIn(db, rootDir, contextsDir, expected) {
308
+ const pruned = [];
309
+ for (const file of listContextFiles(contextsDir)) {
310
+ const relative = path.relative(rootDir, file).split(path.sep).join('/');
311
+ if (expected.has(relative) || path.basename(file) === 'TEMPLATE.md') continue;
312
+ const contents = fs.readFileSync(file);
313
+ if (!contextPaths.isGeneratedContextContents(contents)) continue;
314
+ const staged = `${file}.prune-${process.pid}-${Date.now()}`;
315
+ fs.renameSync(file, staged);
316
+ try {
317
+ ops.appendEvent(db, {
318
+ type: 'projection_pruned',
319
+ payload: { path: relative, reason: 'task_missing' },
320
+ });
321
+ fs.rmSync(staged, { force: true });
322
+ pruned.push(relative);
323
+ } catch (error) {
324
+ fs.renameSync(staged, file);
325
+ throw error;
326
+ }
327
+ }
328
+ return pruned;
329
+ }
330
+
331
+ function pruneGeneratedGhosts(db, rootDir, expected) {
332
+ const runtime = pruneGeneratedGhostsIn(
333
+ db,
334
+ rootDir,
335
+ defaultPaths(rootDir).contextsDir,
336
+ expected,
337
+ );
338
+ const legacy = pruneGeneratedGhostsIn(
339
+ db,
340
+ rootDir,
341
+ path.join(rootDir, ...contextPaths.LEGACY_CONTEXT_ROOT_RELATIVE.split('/')),
342
+ new Set(),
343
+ );
344
+ return [...runtime, ...legacy].sort();
191
345
  }
192
346
 
193
347
  function projectAll(db, { rootDir = '.', tasksJson, contextsDir } = {}) {
194
- const paths = defaultPaths(rootDir);
348
+ const resolvedRoot = path.resolve(rootDir);
349
+ const paths = defaultPaths(resolvedRoot);
195
350
  const tasksTarget = tasksJson || paths.tasksJson;
196
351
  const contextsTarget = contextsDir || paths.contextsDir;
352
+ if (path.resolve(contextsTarget) !== paths.contextsDir) {
353
+ throw new contextPaths.ContextPathError(
354
+ `custom context projection roots are not allowed: ${contextsTarget}`,
355
+ );
356
+ }
197
357
 
358
+ const rows = db.prepare("SELECT id, context_file FROM tasks").all();
359
+ const expected = new Set(rows.map((row) => (
360
+ contextPaths.resolveContextPath(resolvedRoot, row.context_file, { taskId: row.id }).relative
361
+ )));
198
362
  const tasksResult = projectTasks(db, { tasksJson: tasksTarget });
199
363
  const contextResults = [];
200
- const rows = db.prepare("SELECT id FROM tasks").all();
201
364
  for (const r of rows) {
202
- contextResults.push(projectContext(db, r.id, { contextsDir: contextsTarget }, { rootDir }));
365
+ contextResults.push(projectContext(
366
+ db, r.id, { contextsDir: contextsTarget }, { rootDir: resolvedRoot },
367
+ ));
203
368
  }
369
+ const pruned = pruneGeneratedGhosts(db, resolvedRoot, expected);
204
370
  return {
205
371
  tasks_json: tasksResult,
206
372
  contexts: contextResults.filter(Boolean),
373
+ pruned,
207
374
  };
208
375
  }
209
376
 
@@ -214,4 +381,6 @@ module.exports = {
214
381
  projectContext,
215
382
  projectAll,
216
383
  rowToProjection,
384
+ extractAuthoredContextBytes,
385
+ promoteAuthoredContext,
217
386
  };