ultra-builder-pro-cli 0.6.0 → 0.7.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 (68) hide show
  1. package/.ultra-template/changes/active/.gitkeep +1 -0
  2. package/.ultra-template/changes/archive/.gitkeep +1 -0
  3. package/CHANGELOG.md +62 -11
  4. package/README.md +26 -18
  5. package/adapters/_shared/handbook.cjs +7 -7
  6. package/adapters/_shared/runtime-assets.cjs +2 -0
  7. package/adapters/_shared/tests/handbook.test.cjs +4 -1
  8. package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
  9. package/adapters/opencode.js +81 -20
  10. package/adapters/tests/codex.test.cjs +5 -3
  11. package/adapters/tests/opencode.test.cjs +16 -9
  12. package/bin/install.js +0 -19
  13. package/bin/orchestrator.js +1 -26
  14. package/commands/ultra-change.md +47 -0
  15. package/commands/ultra-deliver.md +14 -7
  16. package/commands/ultra-dev.md +6 -3
  17. package/commands/ultra-doctor.md +36 -0
  18. package/commands/ultra-init.md +4 -3
  19. package/commands/ultra-status.md +7 -5
  20. package/commands/ultra-test.md +2 -1
  21. package/docs/AGENT-CONTEXT.md +31 -23
  22. package/docs/ARCHITECTURE.md +46 -17
  23. package/docs/PLAN.zh-CN.md +35 -29
  24. package/docs/ROADMAP.md +14 -14
  25. package/docs/RUNTIME-COMPAT-MATRIX.md +11 -11
  26. package/hooks/README.md +8 -7
  27. package/hooks/active_task_context.py +22 -15
  28. package/hooks/health_check.py +136 -38
  29. package/hooks/workflow_context.py +120 -30
  30. package/mcp-server/lib/change-workflow.cjs +556 -0
  31. package/mcp-server/lib/change-workflow.test.cjs +259 -0
  32. package/mcp-server/lib/doctor.cjs +112 -0
  33. package/mcp-server/lib/doctor.test.cjs +86 -0
  34. package/mcp-server/lib/init-project.cjs +32 -6
  35. package/mcp-server/lib/runtime-state.cjs +222 -0
  36. package/mcp-server/lib/runtime-state.test.cjs +92 -0
  37. package/mcp-server/lib/state-db.cjs +31 -8
  38. package/mcp-server/lib/state-ops.cjs +17 -3
  39. package/mcp-server/server.cjs +102 -10
  40. package/mcp-server/tests/db-ops.test.cjs +5 -1
  41. package/mcp-server/tests/init-project.test.cjs +45 -0
  42. package/mcp-server/tests/server-contract.test.cjs +123 -1
  43. package/mcp-server/tests/state-db.test.cjs +44 -7
  44. package/mcp-server/tests/state-ops.test.cjs +19 -0
  45. package/orchestrator/daemon.cjs +39 -0
  46. package/orchestrator/tests/daemon.test.cjs +50 -0
  47. package/package.json +3 -3
  48. package/settings.json +1 -2
  49. package/skills/ultra-change/SKILL.md +140 -0
  50. package/skills/ultra-deliver/SKILL.md +81 -15
  51. package/skills/ultra-dev/SKILL.md +32 -12
  52. package/skills/ultra-doctor/SKILL.md +90 -0
  53. package/skills/ultra-init/SKILL.md +8 -5
  54. package/skills/ultra-review/SKILL.md +16 -0
  55. package/skills/ultra-status/SKILL.md +30 -7
  56. package/skills/ultra-test/SKILL.md +14 -2
  57. package/spec/cli-protocol.md +12 -1
  58. package/spec/mcp-tools.yaml +337 -1
  59. package/spec/schemas/mcp-tools.schema.json +1 -1
  60. package/spec/schemas/state-db.sql +117 -0
  61. package/ultra-tools/cli.cjs +3 -0
  62. package/ultra-tools/commands/system.cjs +57 -0
  63. package/ultra-tools/system-command.test.cjs +53 -0
  64. package/adapters/_shared/rtk-detect.cjs +0 -86
  65. package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
  66. package/docs/LEGACY-HERMES.md +0 -1099
  67. package/orchestrator/code-graph-watcher.cjs +0 -121
  68. package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
@@ -0,0 +1,556 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const ops = require('./state-ops.cjs');
9
+
10
+ const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
11
+ const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
12
+ const PROVIDER_FIELDS = new Set([
13
+ 'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
14
+ ]);
15
+ const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
16
+ const CHANGE_TRANSITIONS = Object.freeze({
17
+ active: new Set(['active', 'blocked', 'cancelled']),
18
+ blocked: new Set(['active', 'blocked', 'cancelled']),
19
+ ready: new Set(['ready']),
20
+ archived: new Set(),
21
+ cancelled: new Set(),
22
+ });
23
+ const REQUIRED_EVIDENCE = Object.freeze({
24
+ quick: ['diff', 'tests', 'spec'],
25
+ standard: ['diff', 'tests', 'spec', 'docs', 'review'],
26
+ major: ['diff', 'tests', 'spec', 'docs', 'review'],
27
+ incident: ['diagnosis', 'diff', 'tests'],
28
+ });
29
+
30
+ class ChangeWorkflowError extends Error {
31
+ constructor(code, message, details) {
32
+ super(message);
33
+ this.name = 'ChangeWorkflowError';
34
+ this.code = code;
35
+ if (details !== undefined) this.details = details;
36
+ }
37
+ }
38
+
39
+ function nowIso() {
40
+ return new Date().toISOString();
41
+ }
42
+
43
+ function parseJson(value, field) {
44
+ try { return JSON.parse(value); }
45
+ catch (error) {
46
+ throw new ChangeWorkflowError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
47
+ }
48
+ }
49
+
50
+ function rowToChange(row) {
51
+ if (!row) return null;
52
+ const change = {
53
+ ...row,
54
+ docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
55
+ provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
56
+ };
57
+ delete change.docs_impact_json;
58
+ delete change.provider_refs_json;
59
+ return change;
60
+ }
61
+
62
+ function normalizeDocsImpact(value) {
63
+ const input = value || {};
64
+ const status = input.status || 'unknown';
65
+ if (!['unknown', 'required', 'none'].includes(status)) {
66
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid docs impact status: ${status}`);
67
+ }
68
+ const files = input.files === undefined ? [] : input.files;
69
+ if (!Array.isArray(files) || files.some((file) => typeof file !== 'string' || !file.trim())) {
70
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact files must be non-empty strings');
71
+ }
72
+ const rationale = input.rationale == null ? null : String(input.rationale).trim();
73
+ return { status, files: [...new Set(files)], rationale };
74
+ }
75
+
76
+ function normalizeProviderRefs(value) {
77
+ if (value == null) return {};
78
+ if (typeof value !== 'object' || Array.isArray(value)) {
79
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'provider_refs must be an object');
80
+ }
81
+ const out = {};
82
+ for (const [kind, ref] of Object.entries(value)) {
83
+ if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
84
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
85
+ }
86
+ for (const key of Object.keys(ref)) {
87
+ if (!PROVIDER_FIELDS.has(key)) {
88
+ throw new ChangeWorkflowError(
89
+ 'PROVIDER_CONTENT_FORBIDDEN',
90
+ `provider_refs.${kind}.${key} is not metadata; provider content remains external`,
91
+ );
92
+ }
93
+ }
94
+ if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
95
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
96
+ }
97
+ out[kind] = { ...ref };
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function gitHead(rootDir) {
103
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], {
104
+ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
105
+ });
106
+ return result.status === 0 ? result.stdout.trim() : null;
107
+ }
108
+
109
+ function safeRelativePath(rootDir, candidate) {
110
+ if (typeof candidate !== 'string' || !candidate.trim() || path.isAbsolute(candidate)) {
111
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `path must be project-relative: ${candidate}`);
112
+ }
113
+ const root = path.resolve(rootDir);
114
+ const target = path.resolve(root, candidate);
115
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
116
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `path escapes project root: ${candidate}`);
117
+ }
118
+ return target;
119
+ }
120
+
121
+ function readChange(db, id) {
122
+ return rowToChange(db.prepare('SELECT * FROM changes WHERE id = ?').get(id));
123
+ }
124
+
125
+ const LIST_CHANGES_SQL = "SELECT * FROM changes WHERE (@status IS NULL OR status = @status) AND (@kind IS NULL OR kind = @kind) ORDER BY created_at DESC LIMIT @maxn";
126
+
127
+ function listChanges(db, { status = null, kind = null, limit = 100 } = {}) {
128
+ return db.prepare(LIST_CHANGES_SQL).all({
129
+ status, kind, maxn: Math.min(Math.max(limit, 1), 500),
130
+ }).map(rowToChange);
131
+ }
132
+
133
+ function writeIntent(file, change) {
134
+ const impact = change.docs_impact;
135
+ const lines = [
136
+ `# ${change.title}`,
137
+ '',
138
+ `- Change: \`${change.id}\``,
139
+ `- Kind: \`${change.kind}\``,
140
+ `- Base commit: \`${change.base_commit || 'unavailable'}\``,
141
+ `- Documentation impact: \`${impact.status}\``,
142
+ '',
143
+ '## Intent',
144
+ '',
145
+ change.intent,
146
+ '',
147
+ ];
148
+ if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
149
+ fs.writeFileSync(file, `${lines.join('\n')}\n`);
150
+ }
151
+
152
+ function upsertArtifact(db, { change_id, task_id = null, kind, artifactPath, contentHash, metadata }) {
153
+ const id = `art-${crypto.randomUUID().slice(0, 12)}`;
154
+ db.prepare(
155
+ `INSERT INTO artifacts (id, change_id, task_id, kind, path, content_hash, metadata_json)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?)
157
+ ON CONFLICT(change_id, kind, path) DO UPDATE SET
158
+ task_id = excluded.task_id, content_hash = excluded.content_hash,
159
+ metadata_json = excluded.metadata_json, updated_at = excluded.updated_at`,
160
+ ).run(
161
+ id, change_id, task_id, kind, artifactPath, contentHash || null,
162
+ metadata === undefined ? null : JSON.stringify(metadata),
163
+ );
164
+ }
165
+
166
+ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
167
+ const change = readChange(db, input.id);
168
+ if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
169
+ if (['archived', 'cancelled'].includes(change.status)) {
170
+ throw new ChangeWorkflowError('CHANGE_TERMINAL', `change ${input.id} is ${change.status}`);
171
+ }
172
+ const providers = input.provider_refs === undefined
173
+ ? change.provider_refs
174
+ : normalizeProviderRefs(input.provider_refs);
175
+ const tasks = db.prepare(
176
+ 'SELECT id, title, status, priority, stale, trace_to, context_file FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
177
+ ).all(change.id).map((task) => ({ ...task, stale: Boolean(task.stale) }));
178
+ const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
179
+ const allowedPaths = Array.isArray(input.allowed_paths) ? input.allowed_paths : [];
180
+ const head = gitHead(rootDir);
181
+ const manifest = {
182
+ schema_version: '1.0',
183
+ generated_at: nowIso(),
184
+ change: {
185
+ id: change.id, title: change.title, kind: change.kind, status: change.status,
186
+ intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
187
+ },
188
+ git: { head },
189
+ tasks,
190
+ specs,
191
+ providers,
192
+ provider_boundary: 'metadata references only; memory and code graph content remain external',
193
+ tool_policy: { allowed_paths: allowedPaths },
194
+ };
195
+ const artifactDir = path.resolve(rootDir, change.artifact_root);
196
+ fs.mkdirSync(artifactDir, { recursive: true });
197
+ const manifestPath = path.join(artifactDir, 'context-manifest.json');
198
+ const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
199
+ fs.writeFileSync(manifestPath, serialized);
200
+ const hash = crypto.createHash('sha256').update(serialized).digest('hex');
201
+ const relative = path.relative(rootDir, manifestPath);
202
+
203
+ ops.tx(db, () => {
204
+ db.prepare(
205
+ `INSERT INTO context_snapshots
206
+ (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
208
+ ).run(
209
+ `ctx-${crypto.randomUUID().slice(0, 12)}`, change.id, input.task_id || null,
210
+ head, JSON.stringify(providers), relative, hash,
211
+ );
212
+ upsertArtifact(db, {
213
+ change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
214
+ artifactPath: relative, contentHash: hash, metadata: { git_head: head },
215
+ });
216
+ for (const task of tasks) {
217
+ if (!task.trace_to) continue;
218
+ db.prepare(
219
+ `INSERT OR IGNORE INTO trace_links
220
+ (change_id, task_id, source_ref, target_ref, relation) VALUES (?, ?, ?, ?, ?)`,
221
+ ).run(change.id, task.id, task.trace_to, `task:${task.id}`, 'specified_by');
222
+ }
223
+ db.prepare('UPDATE changes SET provider_refs_json = ?, updated_at = ? WHERE id = ?')
224
+ .run(JSON.stringify(providers), nowIso(), change.id);
225
+ ops.appendEventInTx(db, {
226
+ type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
227
+ payload: { manifest_path: relative, manifest_hash: hash, git_head: head },
228
+ });
229
+ });
230
+ return { manifest, context_manifest_path: manifestPath, manifest_hash: hash };
231
+ }
232
+
233
+ function createChange(db, input, { rootDir = process.cwd() } = {}) {
234
+ const title = typeof input?.title === 'string' ? input.title.trim() : '';
235
+ const intent = typeof input?.intent === 'string' ? input.intent.trim() : '';
236
+ if (!input || !CHANGE_ID.test(input.id || '') || title.length < 3 || intent.length < 3) {
237
+ throw new ChangeWorkflowError(
238
+ 'VALIDATION_ERROR',
239
+ 'id plus non-blank title and intent of at least three characters are required',
240
+ );
241
+ }
242
+ if (!REQUIRED_EVIDENCE[input.kind]) {
243
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
244
+ }
245
+ if (readChange(db, input.id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${input.id} exists`);
246
+ const docsImpact = normalizeDocsImpact(input.docs_impact);
247
+ const providers = normalizeProviderRefs(input.provider_refs);
248
+ const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
249
+ const artifactDir = path.resolve(rootDir, artifactRoot);
250
+ if (fs.existsSync(artifactDir)) {
251
+ throw new ChangeWorkflowError('CHANGE_ARTIFACT_EXISTS', `change artifact already exists: ${artifactDir}`);
252
+ }
253
+ const ts = nowIso();
254
+ const row = {
255
+ id: input.id, title, kind: input.kind, status: 'active',
256
+ intent, docs_impact: docsImpact, provider_refs: providers,
257
+ base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
258
+ created_at: ts, updated_at: ts,
259
+ };
260
+ try {
261
+ return ops.tx(db, () => {
262
+ db.prepare(
263
+ `INSERT INTO changes
264
+ (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
265
+ base_commit, artifact_root, created_at, updated_at)
266
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
267
+ ).run(
268
+ row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
269
+ JSON.stringify(row.provider_refs), row.base_commit, row.artifact_root, ts, ts,
270
+ );
271
+ fs.mkdirSync(artifactDir, { recursive: true });
272
+ const intentPath = path.join(artifactDir, 'intent.md');
273
+ writeIntent(intentPath, row);
274
+ upsertArtifact(db, {
275
+ change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
276
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
277
+ });
278
+ ops.appendEventInTx(db, {
279
+ type: 'change_created', change_id: row.id,
280
+ payload: { kind: row.kind, artifact_root: row.artifact_root },
281
+ });
282
+ const context = compileContext(db, { id: row.id }, { rootDir });
283
+ return {
284
+ change: readChange(db, row.id), intent_path: intentPath,
285
+ context_manifest_path: context.context_manifest_path,
286
+ };
287
+ });
288
+ } catch (error) {
289
+ fs.rmSync(artifactDir, { recursive: true, force: true });
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
295
+ const current = readChange(db, id);
296
+ if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
297
+ if (['ready', 'archived', 'cancelled'].includes(current.status)) {
298
+ throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${id} is ${current.status}`);
299
+ }
300
+ for (const field of Object.keys(patch)) {
301
+ if (!CHANGE_PATCH_FIELDS.has(field)) {
302
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `change field ${field} is not patchable`);
303
+ }
304
+ }
305
+ const syncIntent = ['title', 'intent', 'docs_impact'].some((field) => patch[field] !== undefined);
306
+ const intentPath = path.resolve(rootDir, current.artifact_root, 'intent.md');
307
+ const previousIntent = syncIntent && fs.existsSync(intentPath)
308
+ ? fs.readFileSync(intentPath)
309
+ : null;
310
+ try {
311
+ return ops.tx(db, () => {
312
+ const sets = [];
313
+ const values = [];
314
+ if (patch.title !== undefined) {
315
+ const title = String(patch.title).trim();
316
+ if (!title) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change title cannot be empty');
317
+ sets.push('title = ?'); values.push(title);
318
+ }
319
+ if (patch.intent !== undefined) {
320
+ const intent = String(patch.intent).trim();
321
+ if (!intent) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change intent cannot be empty');
322
+ sets.push('intent = ?'); values.push(intent);
323
+ }
324
+ if (patch.docs_impact !== undefined) {
325
+ sets.push('docs_impact_json = ?'); values.push(JSON.stringify(normalizeDocsImpact(patch.docs_impact)));
326
+ }
327
+ if (patch.provider_refs !== undefined) {
328
+ sets.push('provider_refs_json = ?'); values.push(JSON.stringify(normalizeProviderRefs(patch.provider_refs)));
329
+ }
330
+ if (patch.status !== undefined) {
331
+ const allowed = CHANGE_TRANSITIONS[current.status] || new Set();
332
+ if (!allowed.has(patch.status)) {
333
+ throw new ChangeWorkflowError(
334
+ 'ILLEGAL_CHANGE_TRANSITION', `cannot transition change ${id} from ${current.status} to ${patch.status}`,
335
+ );
336
+ }
337
+ sets.push('status = ?'); values.push(patch.status);
338
+ }
339
+ if (sets.length === 0) return current;
340
+ sets.push('updated_at = ?'); values.push(nowIso(), id);
341
+ db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
342
+ const updated = readChange(db, id);
343
+ if (syncIntent) {
344
+ fs.mkdirSync(path.dirname(intentPath), { recursive: true });
345
+ writeIntent(intentPath, updated);
346
+ upsertArtifact(db, {
347
+ change_id: id,
348
+ kind: 'intent',
349
+ artifactPath: path.relative(rootDir, intentPath),
350
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
351
+ });
352
+ }
353
+ ops.appendEventInTx(db, { type: 'change_updated', change_id: id, payload: { fields: Object.keys(patch) } });
354
+ return updated;
355
+ });
356
+ } catch (error) {
357
+ if (syncIntent) {
358
+ if (previousIntent === null) fs.rmSync(intentPath, { force: true });
359
+ else fs.writeFileSync(intentPath, previousIntent);
360
+ }
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ function filesUnder(dir) {
366
+ if (!fs.existsSync(dir)) return [];
367
+ const out = [];
368
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
369
+ const full = path.join(dir, entry.name);
370
+ if (entry.isDirectory()) out.push(...filesUnder(full));
371
+ else if (entry.isFile()) out.push(full);
372
+ }
373
+ return out;
374
+ }
375
+
376
+ function evidenceBlockers(change, evidence) {
377
+ const blockers = [];
378
+ const rows = Array.isArray(evidence) ? evidence : [];
379
+ const byCategory = new Map(rows.map((row) => [row.category, row]));
380
+ for (const category of REQUIRED_EVIDENCE[change.kind]) {
381
+ const row = byCategory.get(category);
382
+ if (!row) blockers.push(`EVIDENCE_MISSING:${category}`);
383
+ else if (!['pass', 'not_applicable'].includes(row.status)) blockers.push(`EVIDENCE_FAILED:${category}`);
384
+ else if (!row.evidence || String(row.evidence).trim().length < 3) blockers.push(`EVIDENCE_EMPTY:${category}`);
385
+ }
386
+ return blockers;
387
+ }
388
+
389
+ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
390
+ const change = readChange(db, input.id);
391
+ if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
392
+ if (!['active', 'blocked'].includes(change.status)) {
393
+ throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
394
+ }
395
+ const blockers = new Set(evidenceBlockers(change, input.evidence));
396
+ const tasks = db.prepare('SELECT id, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
397
+ if (tasks.length === 0) blockers.add('NO_TASKS');
398
+ if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
399
+ if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
400
+
401
+ if (change.docs_impact.status === 'unknown') blockers.add('DOCS_IMPACT_UNKNOWN');
402
+ if (change.docs_impact.status === 'none' && (!change.docs_impact.rationale || change.docs_impact.rationale.length < 3)) {
403
+ blockers.add('DOCS_IMPACT_RATIONALE_MISSING');
404
+ }
405
+ if (change.docs_impact.status === 'required') {
406
+ if (change.docs_impact.files.length === 0) blockers.add('DOCS_FILES_UNDECLARED');
407
+ for (const file of change.docs_impact.files) {
408
+ if (!fs.existsSync(safeRelativePath(rootDir, file))) blockers.add(`DOCS_FILE_MISSING:${file}`);
409
+ }
410
+ }
411
+
412
+ const artifactDir = path.resolve(rootDir, change.artifact_root);
413
+ if (['standard', 'major'].includes(change.kind)) {
414
+ if (filesUnder(path.join(artifactDir, 'delta')).length === 0) blockers.add('SPEC_DELTA_MISSING');
415
+ if (!fs.existsSync(path.join(artifactDir, 'plan.md'))) blockers.add('CHANGE_PLAN_MISSING');
416
+ }
417
+ const manifestPath = path.join(artifactDir, 'context-manifest.json');
418
+ if (!fs.existsSync(manifestPath)) blockers.add('CONTEXT_MANIFEST_MISSING');
419
+ else {
420
+ const manifest = parseJson(fs.readFileSync(manifestPath, 'utf8'), 'context-manifest.json');
421
+ if (manifest.git && manifest.git.head !== gitHead(rootDir)) blockers.add('CONTEXT_HEAD_STALE');
422
+ const manifestTasks = (manifest.tasks || []).map((task) => task.id).sort();
423
+ const currentTasks = tasks.map((task) => task.id).sort();
424
+ if (JSON.stringify(manifestTasks) !== JSON.stringify(currentTasks)) blockers.add('CONTEXT_TASK_SET_STALE');
425
+ }
426
+ const openIncidents = db.prepare(
427
+ `SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'
428
+ AND (change_id = ? OR task_id IN (SELECT id FROM tasks WHERE change_id = ?))`,
429
+ ).get(change.id, change.id).count;
430
+ if (openIncidents > 0) blockers.add('OPEN_INCIDENTS');
431
+
432
+ const blockerList = [...blockers].sort();
433
+ if (blockerList.length > 0) {
434
+ ops.tx(db, () => {
435
+ db.prepare("UPDATE changes SET status = 'blocked', updated_at = ? WHERE id = ?")
436
+ .run(nowIso(), change.id);
437
+ ops.appendEventInTx(db, {
438
+ type: 'change_blocked', change_id: change.id, payload: { blockers: blockerList },
439
+ });
440
+ });
441
+ return { ready: false, status: 'blocked', blockers: blockerList };
442
+ }
443
+
444
+ const verificationPath = path.join(artifactDir, 'verification.md');
445
+ const lines = [
446
+ `# Verification: ${change.title}`, '',
447
+ ...input.evidence.flatMap((row) => [
448
+ `## ${row.category}`, '', `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
449
+ ]),
450
+ ];
451
+ fs.writeFileSync(verificationPath, `${lines.join('\n')}\n`);
452
+ ops.tx(db, () => {
453
+ db.prepare("UPDATE changes SET status = 'ready', updated_at = ? WHERE id = ?")
454
+ .run(nowIso(), change.id);
455
+ upsertArtifact(db, {
456
+ change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
457
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
458
+ metadata: { evidence: input.evidence },
459
+ });
460
+ ops.appendEventInTx(db, {
461
+ type: 'change_converged', change_id: change.id,
462
+ payload: { evidence_categories: input.evidence.map((row) => row.category) },
463
+ });
464
+ });
465
+ return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
466
+ }
467
+
468
+ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
469
+ const change = readChange(db, input.id);
470
+ if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
471
+ if (change.status !== 'ready') {
472
+ throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
473
+ }
474
+ if (!input.summary || String(input.summary).trim().length < 3) {
475
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
476
+ }
477
+ const updates = Array.isArray(input.baseline_updates) ? input.baseline_updates : [];
478
+ const noChangeReason = input.no_baseline_change_reason && String(input.no_baseline_change_reason).trim();
479
+ if (updates.length === 0 && !noChangeReason) {
480
+ throw new ChangeWorkflowError('BASELINE_RECONCILIATION_REQUIRED', 'baseline updates or no-change reason required');
481
+ }
482
+ for (const file of updates) {
483
+ if (!fs.existsSync(safeRelativePath(rootDir, file))) {
484
+ throw new ChangeWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${file}`);
485
+ }
486
+ }
487
+ const source = path.resolve(rootDir, change.artifact_root);
488
+ const date = nowIso().slice(0, 10);
489
+ const destination = path.join(rootDir, '.ultra', 'changes', 'archive', `${date}-${change.id}`);
490
+ if (fs.existsSync(destination)) {
491
+ throw new ChangeWorkflowError('ARCHIVE_EXISTS', `archive already exists: ${destination}`);
492
+ }
493
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
494
+ const summaryPath = path.join(source, 'archive-summary.md');
495
+ const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath) : null;
496
+ fs.writeFileSync(summaryPath, [
497
+ `# Archived change: ${change.title}`, '', String(input.summary).trim(), '',
498
+ '## Baseline reconciliation', '',
499
+ ...(updates.length > 0 ? updates.map((file) => `- ${file}`) : [noChangeReason]), '',
500
+ ].join('\n'));
501
+ fs.renameSync(source, destination);
502
+ try {
503
+ ops.tx(db, () => {
504
+ const relative = path.relative(rootDir, destination);
505
+ db.prepare(
506
+ "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
507
+ ).run(relative, nowIso(), nowIso(), change.id);
508
+ const prefix = `${change.artifact_root}${path.sep}`;
509
+ const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?')
510
+ .all(change.id);
511
+ for (const artifact of artifactRows) {
512
+ if (artifact.path !== change.artifact_root && !artifact.path.startsWith(prefix)) continue;
513
+ const suffix = path.relative(change.artifact_root, artifact.path);
514
+ const archivedPath = suffix ? path.join(relative, suffix) : relative;
515
+ db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
516
+ .run(archivedPath, nowIso(), artifact.id);
517
+ }
518
+ const archivedSummary = path.join(destination, 'archive-summary.md');
519
+ upsertArtifact(db, {
520
+ change_id: change.id,
521
+ kind: 'archive_summary',
522
+ artifactPath: path.relative(rootDir, archivedSummary),
523
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
524
+ metadata: {
525
+ baseline_updates: updates,
526
+ no_baseline_change_reason: noChangeReason || null,
527
+ },
528
+ });
529
+ db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
530
+ .run(nowIso(), change.id);
531
+ ops.appendEventInTx(db, {
532
+ type: 'change_archived', change_id: change.id,
533
+ payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
534
+ });
535
+ });
536
+ } catch (error) {
537
+ fs.renameSync(destination, source);
538
+ if (previousSummary === null) fs.rmSync(summaryPath, { force: true });
539
+ else fs.writeFileSync(summaryPath, previousSummary);
540
+ throw error;
541
+ }
542
+ return { change: readChange(db, change.id), archive_path: destination };
543
+ }
544
+
545
+ module.exports = {
546
+ ChangeWorkflowError,
547
+ createChange,
548
+ readChange,
549
+ listChanges,
550
+ updateChange,
551
+ compileContext,
552
+ convergeChange,
553
+ archiveChange,
554
+ normalizeDocsImpact,
555
+ normalizeProviderRefs,
556
+ };