ultra-builder-pro-cli 0.6.0 → 0.8.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.
- package/.ultra-template/changes/active/.gitkeep +1 -0
- package/.ultra-template/changes/archive/.gitkeep +1 -0
- package/CHANGELOG.md +90 -11
- package/README.md +39 -19
- package/adapters/_shared/handbook.cjs +8 -7
- package/adapters/_shared/provenance.cjs +286 -0
- package/adapters/_shared/runtime-assets.cjs +2 -0
- package/adapters/_shared/tests/handbook.test.cjs +4 -1
- package/adapters/_shared/tests/provenance.test.cjs +133 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
- package/adapters/claude.js +33 -0
- package/adapters/codex.js +87 -0
- package/adapters/opencode.js +147 -20
- package/adapters/tests/codex.test.cjs +42 -3
- package/adapters/tests/opencode.test.cjs +16 -9
- package/agents/debugger.md +12 -0
- package/bin/install.js +63 -27
- package/bin/orchestrator.js +1 -26
- package/commands/ultra-change.md +48 -0
- package/commands/ultra-deliver.md +14 -7
- package/commands/ultra-dev.md +6 -3
- package/commands/ultra-doctor.md +36 -0
- package/commands/ultra-init.md +4 -3
- package/commands/ultra-status.md +7 -5
- package/commands/ultra-test.md +2 -1
- package/docs/AGENT-CONTEXT.md +31 -23
- package/docs/ARCHITECTURE.md +80 -17
- package/docs/PLAN.zh-CN.md +37 -29
- package/docs/ROADMAP.md +14 -14
- package/docs/RUNTIME-COMPAT-MATRIX.md +19 -12
- package/hooks/README.md +14 -9
- package/hooks/active_task_context.py +22 -15
- package/hooks/health_check.py +136 -38
- package/hooks/workflow_checkpoint.py +15 -1
- package/hooks/workflow_context.py +120 -30
- package/hooks/workflow_resume.py +96 -7
- package/mcp-server/lib/change-workflow.cjs +625 -0
- package/mcp-server/lib/change-workflow.test.cjs +346 -0
- package/mcp-server/lib/doctor.cjs +112 -0
- package/mcp-server/lib/doctor.test.cjs +86 -0
- package/mcp-server/lib/init-project.cjs +32 -6
- package/mcp-server/lib/runtime-state.cjs +222 -0
- package/mcp-server/lib/runtime-state.test.cjs +92 -0
- package/mcp-server/lib/state-db.cjs +31 -8
- package/mcp-server/lib/state-ops.cjs +17 -3
- package/mcp-server/server.cjs +102 -10
- package/mcp-server/tests/db-ops.test.cjs +5 -1
- package/mcp-server/tests/init-project.test.cjs +45 -0
- package/mcp-server/tests/server-contract.test.cjs +123 -1
- package/mcp-server/tests/state-db.test.cjs +44 -7
- package/mcp-server/tests/state-ops.test.cjs +19 -0
- package/orchestrator/daemon.cjs +39 -0
- package/orchestrator/tests/daemon.test.cjs +50 -0
- package/package.json +3 -3
- package/settings.json +1 -2
- package/skills/ultra-change/SKILL.md +149 -0
- package/skills/ultra-deliver/SKILL.md +83 -15
- package/skills/ultra-dev/SKILL.md +32 -12
- package/skills/ultra-doctor/SKILL.md +97 -0
- package/skills/ultra-init/SKILL.md +8 -5
- package/skills/ultra-review/SKILL.md +16 -0
- package/skills/ultra-status/SKILL.md +30 -7
- package/skills/ultra-test/SKILL.md +14 -2
- package/spec/cli-protocol.md +12 -1
- package/spec/mcp-tools.yaml +337 -1
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +117 -0
- package/ultra-tools/cli.cjs +3 -0
- package/ultra-tools/commands/system.cjs +57 -0
- package/ultra-tools/system-command.test.cjs +53 -0
- package/adapters/_shared/rtk-detect.cjs +0 -86
- package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
- package/docs/LEGACY-HERMES.md +0 -1099
- package/orchestrator/code-graph-watcher.cjs +0 -121
- package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
11
|
+
const ops = require('./state-ops.cjs');
|
|
12
|
+
const changes = require('./change-workflow.cjs');
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-change-'));
|
|
16
|
+
execFileSync('git', ['init', '-q'], { cwd: rootDir });
|
|
17
|
+
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: rootDir });
|
|
18
|
+
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: rootDir });
|
|
19
|
+
fs.writeFileSync(path.join(rootDir, 'README.md'), '# Fixture\n');
|
|
20
|
+
execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
|
|
21
|
+
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
|
|
22
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
23
|
+
return { rootDir, db };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanup({ rootDir, db }) {
|
|
27
|
+
closeStateDb(db);
|
|
28
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function standardEvidence() {
|
|
32
|
+
return [
|
|
33
|
+
{ category: 'diff', status: 'pass', evidence: 'git diff reviewed' },
|
|
34
|
+
{ category: 'tests', status: 'pass', evidence: 'node --test passed' },
|
|
35
|
+
{ category: 'spec', status: 'pass', evidence: 'delta reconciled' },
|
|
36
|
+
{ category: 'docs', status: 'pass', evidence: 'docs/feature.md updated' },
|
|
37
|
+
{ category: 'review', status: 'pass', evidence: 'review findings resolved' },
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function incidentEvidence() {
|
|
42
|
+
return [
|
|
43
|
+
{ category: 'diagnosis', status: 'pass', evidence: 'Structured diagnosis artifact reviewed.' },
|
|
44
|
+
{ category: 'diff', status: 'pass', evidence: 'Root-cause fix diff reviewed.' },
|
|
45
|
+
{ category: 'tests', status: 'pass', evidence: 'Regression test reproduces then verifies the fix.' },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('createChange persists a change and an inspectable external-provider context manifest', () => {
|
|
50
|
+
const fx = fixture();
|
|
51
|
+
try {
|
|
52
|
+
const created = changes.createChange(fx.db, {
|
|
53
|
+
id: 'chg-context',
|
|
54
|
+
title: 'Keep context current',
|
|
55
|
+
kind: 'standard',
|
|
56
|
+
intent: 'Add a continuous change lane after project delivery.',
|
|
57
|
+
provider_refs: {
|
|
58
|
+
memory: { provider: 'cloud-mem', reference: 'cmem://project/fixture', status: 'available' },
|
|
59
|
+
code_graph: {
|
|
60
|
+
provider: 'codebase-memory-mcp', project: 'fixture', revision: 'graph-7',
|
|
61
|
+
indexed_head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' }).trim(),
|
|
62
|
+
status: 'fresh',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}, { rootDir: fx.rootDir });
|
|
66
|
+
|
|
67
|
+
assert.equal(created.change.status, 'active');
|
|
68
|
+
assert.equal(created.change.kind, 'standard');
|
|
69
|
+
assert.ok(fs.existsSync(created.intent_path));
|
|
70
|
+
assert.ok(fs.existsSync(created.context_manifest_path));
|
|
71
|
+
|
|
72
|
+
const manifest = JSON.parse(fs.readFileSync(created.context_manifest_path, 'utf8'));
|
|
73
|
+
assert.equal(manifest.change.id, 'chg-context');
|
|
74
|
+
assert.equal(manifest.providers.memory.provider, 'cloud-mem');
|
|
75
|
+
assert.equal(manifest.providers.code_graph.provider, 'codebase-memory-mcp');
|
|
76
|
+
assert.equal(manifest.providers.code_graph.status, 'fresh');
|
|
77
|
+
assert.equal(JSON.stringify(manifest).includes('prompt'), false);
|
|
78
|
+
assert.equal(JSON.stringify(manifest).includes('transcript'), false);
|
|
79
|
+
} finally {
|
|
80
|
+
cleanup(fx);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('convergeChange blocks incomplete work and marks a fully evidenced standard change ready', () => {
|
|
85
|
+
const fx = fixture();
|
|
86
|
+
try {
|
|
87
|
+
changes.createChange(fx.db, {
|
|
88
|
+
id: 'chg-converge', title: 'Converge artifacts', kind: 'standard',
|
|
89
|
+
intent: 'Require code, specs, tests, docs, and review evidence to agree.',
|
|
90
|
+
}, { rootDir: fx.rootDir });
|
|
91
|
+
|
|
92
|
+
const blocked = changes.convergeChange(fx.db, {
|
|
93
|
+
id: 'chg-converge', evidence: standardEvidence(),
|
|
94
|
+
}, { rootDir: fx.rootDir });
|
|
95
|
+
assert.equal(blocked.ready, false);
|
|
96
|
+
assert.ok(blocked.blockers.includes('NO_TASKS'));
|
|
97
|
+
assert.ok(blocked.blockers.includes('DOCS_IMPACT_UNKNOWN'));
|
|
98
|
+
assert.ok(blocked.blockers.includes('SPEC_DELTA_MISSING'));
|
|
99
|
+
const blockedEvent = fx.db.prepare(
|
|
100
|
+
"SELECT type, change_id FROM events WHERE change_id = ? ORDER BY id DESC LIMIT 1",
|
|
101
|
+
).get('chg-converge');
|
|
102
|
+
assert.deepEqual(blockedEvent, { type: 'change_blocked', change_id: 'chg-converge' });
|
|
103
|
+
|
|
104
|
+
fs.mkdirSync(path.join(fx.rootDir, 'docs'), { recursive: true });
|
|
105
|
+
fs.writeFileSync(path.join(fx.rootDir, 'docs', 'feature.md'), '# Continuous changes\n');
|
|
106
|
+
const deltaDir = path.join(fx.rootDir, '.ultra', 'changes', 'active', 'chg-converge', 'delta');
|
|
107
|
+
fs.mkdirSync(deltaDir, { recursive: true });
|
|
108
|
+
fs.writeFileSync(path.join(deltaDir, 'product.md'), '# Delta\n\nDaily changes remain traceable.\n');
|
|
109
|
+
fs.writeFileSync(
|
|
110
|
+
path.join(fx.rootDir, '.ultra', 'changes', 'active', 'chg-converge', 'plan.md'),
|
|
111
|
+
'# Plan\n\nImplement and verify the continuous change contract.\n',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
changes.updateChange(fx.db, 'chg-converge', {
|
|
115
|
+
docs_impact: { status: 'required', files: ['docs/feature.md'], rationale: 'User-facing workflow changed.' },
|
|
116
|
+
});
|
|
117
|
+
ops.createTask(fx.db, {
|
|
118
|
+
id: 'change-task', title: 'Implement change lifecycle', type: 'feature', priority: 'P0',
|
|
119
|
+
change_id: 'chg-converge',
|
|
120
|
+
});
|
|
121
|
+
ops.updateTaskStatus(fx.db, 'change-task', 'in_progress');
|
|
122
|
+
ops.updateTaskStatus(fx.db, 'change-task', 'completed');
|
|
123
|
+
changes.compileContext(fx.db, { id: 'chg-converge' }, { rootDir: fx.rootDir });
|
|
124
|
+
|
|
125
|
+
const ready = changes.convergeChange(fx.db, {
|
|
126
|
+
id: 'chg-converge', evidence: standardEvidence(),
|
|
127
|
+
}, { rootDir: fx.rootDir });
|
|
128
|
+
assert.equal(ready.ready, true);
|
|
129
|
+
assert.equal(ready.status, 'ready');
|
|
130
|
+
assert.deepEqual(ready.blockers, []);
|
|
131
|
+
assert.ok(fs.existsSync(ready.verification_path));
|
|
132
|
+
} finally {
|
|
133
|
+
cleanup(fx);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('archiveChange moves a ready change into immutable history and records baseline reconciliation', () => {
|
|
138
|
+
const fx = fixture();
|
|
139
|
+
try {
|
|
140
|
+
changes.createChange(fx.db, {
|
|
141
|
+
id: 'chg-archive', title: 'Archive verified change', kind: 'quick',
|
|
142
|
+
intent: 'Preserve the completed change as project history.',
|
|
143
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal test-only behavior.' },
|
|
144
|
+
}, { rootDir: fx.rootDir });
|
|
145
|
+
ops.createTask(fx.db, {
|
|
146
|
+
id: 'archive-task', title: 'Complete archive path', type: 'bugfix', priority: 'P1',
|
|
147
|
+
change_id: 'chg-archive',
|
|
148
|
+
});
|
|
149
|
+
ops.updateTaskStatus(fx.db, 'archive-task', 'in_progress');
|
|
150
|
+
ops.updateTaskStatus(fx.db, 'archive-task', 'completed');
|
|
151
|
+
changes.compileContext(fx.db, { id: 'chg-archive' }, { rootDir: fx.rootDir });
|
|
152
|
+
const converged = changes.convergeChange(fx.db, {
|
|
153
|
+
id: 'chg-archive',
|
|
154
|
+
evidence: [
|
|
155
|
+
{ category: 'diff', status: 'pass', evidence: 'diff reviewed' },
|
|
156
|
+
{ category: 'tests', status: 'pass', evidence: 'tests passed' },
|
|
157
|
+
{ category: 'spec', status: 'not_applicable', evidence: 'No behavior contract changed.' },
|
|
158
|
+
],
|
|
159
|
+
}, { rootDir: fx.rootDir });
|
|
160
|
+
assert.equal(converged.ready, true);
|
|
161
|
+
|
|
162
|
+
const archived = changes.archiveChange(fx.db, {
|
|
163
|
+
id: 'chg-archive', summary: 'Verified quick fix archived.',
|
|
164
|
+
no_baseline_change_reason: 'No canonical spec changed.',
|
|
165
|
+
}, { rootDir: fx.rootDir });
|
|
166
|
+
assert.equal(archived.change.status, 'archived');
|
|
167
|
+
assert.ok(fs.existsSync(archived.archive_path));
|
|
168
|
+
assert.equal(fs.existsSync(path.join(fx.rootDir, '.ultra', 'changes', 'active', 'chg-archive')), false);
|
|
169
|
+
const artifacts = fx.db.prepare(
|
|
170
|
+
'SELECT kind, path, status FROM artifacts WHERE change_id = ? ORDER BY kind',
|
|
171
|
+
).all('chg-archive');
|
|
172
|
+
assert.ok(artifacts.some((artifact) => artifact.kind === 'archive_summary'));
|
|
173
|
+
for (const artifact of artifacts) {
|
|
174
|
+
assert.equal(artifact.status, 'archived');
|
|
175
|
+
assert.doesNotMatch(artifact.path, /changes\/active/);
|
|
176
|
+
assert.ok(fs.existsSync(path.join(fx.rootDir, artifact.path)), artifact.path);
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
cleanup(fx);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('provider references reject embedded memory or graph payloads', () => {
|
|
184
|
+
const fx = fixture();
|
|
185
|
+
try {
|
|
186
|
+
assert.throws(
|
|
187
|
+
() => changes.createChange(fx.db, {
|
|
188
|
+
id: 'chg-provider-content', title: 'Reject provider payload', kind: 'quick',
|
|
189
|
+
intent: 'Keep external provider content outside Ultra state.',
|
|
190
|
+
provider_refs: {
|
|
191
|
+
memory: { provider: 'cloud-mem', status: 'available', content: 'captured transcript' },
|
|
192
|
+
},
|
|
193
|
+
}, { rootDir: fx.rootDir }),
|
|
194
|
+
(error) => error.code === 'PROVIDER_CONTENT_FORBIDDEN',
|
|
195
|
+
);
|
|
196
|
+
} finally {
|
|
197
|
+
cleanup(fx);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('createChange rejects whitespace-only title and intent outside the MCP boundary', () => {
|
|
202
|
+
const fx = fixture();
|
|
203
|
+
try {
|
|
204
|
+
assert.throws(
|
|
205
|
+
() => changes.createChange(fx.db, {
|
|
206
|
+
id: 'chg-empty-title', title: ' ', kind: 'quick', intent: 'Valid intent.',
|
|
207
|
+
}, { rootDir: fx.rootDir }),
|
|
208
|
+
(error) => error.code === 'VALIDATION_ERROR',
|
|
209
|
+
);
|
|
210
|
+
assert.throws(
|
|
211
|
+
() => changes.createChange(fx.db, {
|
|
212
|
+
id: 'chg-empty-intent', title: 'Valid title', kind: 'quick', intent: '\t',
|
|
213
|
+
}, { rootDir: fx.rootDir }),
|
|
214
|
+
(error) => error.code === 'VALIDATION_ERROR',
|
|
215
|
+
);
|
|
216
|
+
} finally {
|
|
217
|
+
cleanup(fx);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('updateChange keeps intent.md synchronized with authoritative change metadata', () => {
|
|
222
|
+
const fx = fixture();
|
|
223
|
+
try {
|
|
224
|
+
const created = changes.createChange(fx.db, {
|
|
225
|
+
id: 'chg-update-intent', title: 'Original title', kind: 'quick',
|
|
226
|
+
intent: 'Original intent.',
|
|
227
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal-only change.' },
|
|
228
|
+
}, { rootDir: fx.rootDir });
|
|
229
|
+
|
|
230
|
+
changes.updateChange(fx.db, 'chg-update-intent', {
|
|
231
|
+
title: 'Updated title',
|
|
232
|
+
intent: 'Updated intent with current acceptance behavior.',
|
|
233
|
+
docs_impact: { status: 'required', files: ['README.md'], rationale: 'Usage changed.' },
|
|
234
|
+
}, { rootDir: fx.rootDir });
|
|
235
|
+
|
|
236
|
+
const text = fs.readFileSync(created.intent_path, 'utf8');
|
|
237
|
+
assert.match(text, /^# Updated title/m);
|
|
238
|
+
assert.match(text, /Updated intent with current acceptance behavior\./);
|
|
239
|
+
assert.match(text, /Documentation impact: `required`/);
|
|
240
|
+
const artifact = fx.db.prepare(
|
|
241
|
+
"SELECT content_hash FROM artifacts WHERE change_id = ? AND kind = 'intent'",
|
|
242
|
+
).get('chg-update-intent');
|
|
243
|
+
assert.match(artifact.content_hash, /^[0-9a-f]{64}$/);
|
|
244
|
+
} finally {
|
|
245
|
+
cleanup(fx);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('updateChange rejects whitespace-only title or intent outside the MCP boundary', () => {
|
|
250
|
+
const fx = fixture();
|
|
251
|
+
try {
|
|
252
|
+
changes.createChange(fx.db, {
|
|
253
|
+
id: 'chg-update-validation', title: 'Valid title', kind: 'quick',
|
|
254
|
+
intent: 'Valid intent.',
|
|
255
|
+
}, { rootDir: fx.rootDir });
|
|
256
|
+
assert.throws(
|
|
257
|
+
() => changes.updateChange(fx.db, 'chg-update-validation', { title: ' ' }, { rootDir: fx.rootDir }),
|
|
258
|
+
(error) => error.code === 'VALIDATION_ERROR',
|
|
259
|
+
);
|
|
260
|
+
assert.throws(
|
|
261
|
+
() => changes.updateChange(fx.db, 'chg-update-validation', { intent: '\t' }, { rootDir: fx.rootDir }),
|
|
262
|
+
(error) => error.code === 'VALIDATION_ERROR',
|
|
263
|
+
);
|
|
264
|
+
} finally {
|
|
265
|
+
cleanup(fx);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('incident changes create a durable structured diagnosis artifact', () => {
|
|
270
|
+
const fx = fixture();
|
|
271
|
+
try {
|
|
272
|
+
const created = changes.createChange(fx.db, {
|
|
273
|
+
id: 'chg-incident-diagnosis', title: 'Diagnose runtime failure', kind: 'incident',
|
|
274
|
+
intent: 'Reproduce and fix the runtime failure at its root cause.',
|
|
275
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal runtime repair.' },
|
|
276
|
+
}, { rootDir: fx.rootDir });
|
|
277
|
+
|
|
278
|
+
const diagnosisPath = path.join(path.dirname(created.intent_path), 'diagnosis.md');
|
|
279
|
+
assert.ok(fs.existsSync(diagnosisPath));
|
|
280
|
+
const text = fs.readFileSync(diagnosisPath, 'utf8');
|
|
281
|
+
for (const heading of ['Reproduction', 'Hypotheses', 'Root cause', 'Regression test', 'Recovery']) {
|
|
282
|
+
assert.match(text, new RegExp(`^## ${heading}$`, 'm'));
|
|
283
|
+
}
|
|
284
|
+
const artifact = fx.db.prepare(
|
|
285
|
+
"SELECT kind, path, content_hash FROM artifacts WHERE change_id = ? AND kind = 'diagnosis'",
|
|
286
|
+
).get('chg-incident-diagnosis');
|
|
287
|
+
assert.equal(artifact.kind, 'diagnosis');
|
|
288
|
+
assert.match(artifact.content_hash, /^[0-9a-f]{64}$/);
|
|
289
|
+
assert.equal(path.join(fx.rootDir, artifact.path), diagnosisPath);
|
|
290
|
+
} finally {
|
|
291
|
+
cleanup(fx);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('incident convergence requires every structured diagnosis section and refreshes its artifact hash', () => {
|
|
296
|
+
const fx = fixture();
|
|
297
|
+
try {
|
|
298
|
+
const created = changes.createChange(fx.db, {
|
|
299
|
+
id: 'chg-incident-converge', title: 'Converge runtime diagnosis', kind: 'incident',
|
|
300
|
+
intent: 'Require inspectable debugging evidence before incident closure.',
|
|
301
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal runtime repair.' },
|
|
302
|
+
}, { rootDir: fx.rootDir });
|
|
303
|
+
ops.createTask(fx.db, {
|
|
304
|
+
id: 'incident-task', title: 'Fix diagnosed failure', type: 'bugfix', priority: 'P0',
|
|
305
|
+
change_id: 'chg-incident-converge',
|
|
306
|
+
});
|
|
307
|
+
ops.updateTaskStatus(fx.db, 'incident-task', 'in_progress');
|
|
308
|
+
ops.updateTaskStatus(fx.db, 'incident-task', 'completed');
|
|
309
|
+
changes.compileContext(fx.db, { id: 'chg-incident-converge' }, { rootDir: fx.rootDir });
|
|
310
|
+
|
|
311
|
+
const blocked = changes.convergeChange(fx.db, {
|
|
312
|
+
id: 'chg-incident-converge', evidence: incidentEvidence(),
|
|
313
|
+
}, { rootDir: fx.rootDir });
|
|
314
|
+
assert.equal(blocked.ready, false);
|
|
315
|
+
assert.ok(blocked.blockers.includes('DIAGNOSIS_SECTION_MISSING:reproduction'));
|
|
316
|
+
assert.ok(blocked.blockers.includes('DIAGNOSIS_SECTION_MISSING:root-cause'));
|
|
317
|
+
|
|
318
|
+
const diagnosisPath = path.join(path.dirname(created.intent_path), 'diagnosis.md');
|
|
319
|
+
const beforeHash = fx.db.prepare(
|
|
320
|
+
"SELECT content_hash FROM artifacts WHERE change_id = ? AND kind = 'diagnosis'",
|
|
321
|
+
).get('chg-incident-converge').content_hash;
|
|
322
|
+
fs.writeFileSync(diagnosisPath, [
|
|
323
|
+
'# Incident diagnosis: Converge runtime diagnosis', '',
|
|
324
|
+
'## Reproduction', '', 'The projection worker fails after an interrupted claim.', '',
|
|
325
|
+
'## Hypotheses', '', 'A stale running job is never returned to the pending queue.', '',
|
|
326
|
+
'## Root cause', '', 'Boot recovery did not consume interrupted projection state.', '',
|
|
327
|
+
'## Regression test', '', 'The test seeds a stale running job and verifies requeue.', '',
|
|
328
|
+
'## Recovery', '', 'Requeue the job after the stale cutoff and replay projection.', '',
|
|
329
|
+
].join('\n'));
|
|
330
|
+
|
|
331
|
+
const ready = changes.convergeChange(fx.db, {
|
|
332
|
+
id: 'chg-incident-converge', evidence: incidentEvidence(),
|
|
333
|
+
}, { rootDir: fx.rootDir });
|
|
334
|
+
assert.equal(ready.ready, true);
|
|
335
|
+
const afterHash = fx.db.prepare(
|
|
336
|
+
"SELECT content_hash FROM artifacts WHERE change_id = ? AND kind = 'diagnosis'",
|
|
337
|
+
).get('chg-incident-converge').content_hash;
|
|
338
|
+
assert.notEqual(afterHash, beforeHash);
|
|
339
|
+
assert.equal(
|
|
340
|
+
afterHash,
|
|
341
|
+
require('node:crypto').createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
|
|
342
|
+
);
|
|
343
|
+
} finally {
|
|
344
|
+
cleanup(fx);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { REQUIRED_TABLES, tableNames } = require('./state-db.cjs');
|
|
7
|
+
const ops = require('./state-ops.cjs');
|
|
8
|
+
const runtime = require('./runtime-state.cjs');
|
|
9
|
+
const projector = require('./projector.cjs');
|
|
10
|
+
const recovery = require('../../orchestrator/recovery.cjs');
|
|
11
|
+
|
|
12
|
+
const SPEC_CONSUMER = 'spec-staleness';
|
|
13
|
+
|
|
14
|
+
class DoctorError extends Error {
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'DoctorError';
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function timestamp() {
|
|
23
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
27
|
+
const tables = new Set(tableNames(db));
|
|
28
|
+
const missing = REQUIRED_TABLES.filter((name) => !tables.has(name));
|
|
29
|
+
const integrity = db.pragma('quick_check', { simple: true });
|
|
30
|
+
const incidents = missing.length === 0 ? runtime.listIncidents(db, { status: 'open' }) : [];
|
|
31
|
+
const pending = missing.length === 0 ? runtime.listProjectionJobs(db, { status: 'pending' }) : [];
|
|
32
|
+
const running = missing.length === 0 ? runtime.listProjectionJobs(db, { status: 'running' }) : [];
|
|
33
|
+
const failed = missing.length === 0 ? runtime.listProjectionJobs(db, { status: 'failed' }) : [];
|
|
34
|
+
const orphanSessions = missing.length === 0
|
|
35
|
+
? db.prepare("SELECT COUNT(*) AS count FROM sessions WHERE status = 'orphan'").get().count
|
|
36
|
+
: 0;
|
|
37
|
+
const eventCursor = missing.length === 0
|
|
38
|
+
? Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM events').get().id)
|
|
39
|
+
: 0;
|
|
40
|
+
const projectedCursor = missing.length === 0
|
|
41
|
+
? Number(db.prepare(
|
|
42
|
+
"SELECT COALESCE(MAX(event_cursor), 0) AS id FROM projection_jobs WHERE status = 'completed'",
|
|
43
|
+
).get().id)
|
|
44
|
+
: 0;
|
|
45
|
+
const activeMissing = missing.length === 0
|
|
46
|
+
? db.prepare(
|
|
47
|
+
`SELECT artifact_root FROM changes
|
|
48
|
+
WHERE status IN ('active', 'blocked', 'ready') AND artifact_root IS NOT NULL`,
|
|
49
|
+
).all().filter((row) => row.artifact_root && !fs.existsSync(path.resolve(rootDir, row.artifact_root))).length
|
|
50
|
+
: 0;
|
|
51
|
+
const degraded = integrity !== 'ok' || missing.length > 0 || incidents.length > 0
|
|
52
|
+
|| pending.length > 0 || running.length > 0 || failed.length > 0 || orphanSessions > 0 || activeMissing > 0
|
|
53
|
+
|| eventCursor > projectedCursor;
|
|
54
|
+
return {
|
|
55
|
+
status: degraded ? 'degraded' : 'healthy',
|
|
56
|
+
checks: {
|
|
57
|
+
state_db: { status: integrity === 'ok' && missing.length === 0 ? 'pass' : 'fail', integrity, missing_tables: missing },
|
|
58
|
+
incidents: { status: incidents.length === 0 ? 'pass' : 'fail', open: incidents.length, items: incidents },
|
|
59
|
+
projections: {
|
|
60
|
+
status: pending.length === 0 && running.length === 0 && failed.length === 0 && eventCursor <= projectedCursor ? 'pass' : 'fail',
|
|
61
|
+
pending: pending.length, running: running.length, failed: failed.length,
|
|
62
|
+
event_cursor: eventCursor, projected_cursor: projectedCursor,
|
|
63
|
+
},
|
|
64
|
+
sessions: { status: orphanSessions === 0 ? 'pass' : 'fail', orphan: orphanSessions },
|
|
65
|
+
change_artifacts: { status: activeMissing === 0 ? 'pass' : 'fail', missing: activeMissing },
|
|
66
|
+
external_providers: {
|
|
67
|
+
status: 'pass', ownership: 'external',
|
|
68
|
+
note: 'Ultra stores provider metadata references only and never owns memory or code-graph content.',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function backupDatabase(db, rootDir) {
|
|
75
|
+
const backupDir = path.join(rootDir, '.ultra', 'backups');
|
|
76
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
77
|
+
const backupPath = path.join(backupDir, `state-${timestamp()}.db`);
|
|
78
|
+
try { await db.backup(backupPath); }
|
|
79
|
+
catch (error) { throw new DoctorError('BACKUP_FAILED', `state.db backup failed: ${error.message}`); }
|
|
80
|
+
return backupPath;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runDoctor(db, {
|
|
84
|
+
rootDir = process.cwd(), repair = false, project = projector.projectAll,
|
|
85
|
+
projectionStaleAfterMs = 5 * 60 * 1000,
|
|
86
|
+
} = {}) {
|
|
87
|
+
if (!repair) return { ...inspectSystem(db, { rootDir }), repair_performed: false };
|
|
88
|
+
const backupPath = await backupDatabase(db, rootDir);
|
|
89
|
+
const recovered = recovery.recoverOnBoot(db);
|
|
90
|
+
const cursor = runtime.readConsumerCursor(db, SPEC_CONSUMER);
|
|
91
|
+
const consumed = ops.consumeSpecChangedEvents(db, { since_id: cursor, limit: 500 });
|
|
92
|
+
runtime.writeConsumerCursor(db, SPEC_CONSUMER, consumed.next_since_id);
|
|
93
|
+
const interrupted = runtime.requeueInterruptedProjections(db, {
|
|
94
|
+
staleBefore: new Date(Date.now() - Math.max(0, projectionStaleAfterMs)).toISOString(),
|
|
95
|
+
});
|
|
96
|
+
const requeued = runtime.requeueFailedProjections(db);
|
|
97
|
+
const ensured = runtime.ensureProjectionJob(db, { tool_name: 'system.doctor' });
|
|
98
|
+
const projections = runtime.processProjectionJobs(db, { rootDir, project, limit: 500 });
|
|
99
|
+
return {
|
|
100
|
+
...inspectSystem(db, { rootDir }),
|
|
101
|
+
repair_performed: true,
|
|
102
|
+
backup_path: backupPath,
|
|
103
|
+
repair: { recovered, consumed, interrupted, requeued, ensured, projections },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
DoctorError,
|
|
109
|
+
SPEC_CONSUMER,
|
|
110
|
+
inspectSystem,
|
|
111
|
+
runDoctor,
|
|
112
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
10
|
+
const ops = require('./state-ops.cjs');
|
|
11
|
+
const runtime = require('./runtime-state.cjs');
|
|
12
|
+
const doctor = require('./doctor.cjs');
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-doctor-'));
|
|
16
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
17
|
+
return { rootDir, db };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cleanup({ rootDir, db }) {
|
|
21
|
+
closeStateDb(db);
|
|
22
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('doctor reports structured health for an initialized Ultra project', async () => {
|
|
26
|
+
const fx = fixture();
|
|
27
|
+
try {
|
|
28
|
+
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
29
|
+
assert.equal(report.status, 'healthy');
|
|
30
|
+
assert.equal(report.repair_performed, false);
|
|
31
|
+
assert.equal(report.checks.state_db.status, 'pass');
|
|
32
|
+
assert.equal(report.checks.external_providers.ownership, 'external');
|
|
33
|
+
} finally {
|
|
34
|
+
cleanup(fx);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('doctor repair is backup-first and drains projection/spec-event recovery work', async () => {
|
|
39
|
+
const fx = fixture();
|
|
40
|
+
try {
|
|
41
|
+
ops.createTask(fx.db, {
|
|
42
|
+
id: 'stale-target', title: 'Refresh context', type: 'feature', priority: 'P1',
|
|
43
|
+
trace_to: '.ultra/specs/product.md#continuous-change',
|
|
44
|
+
});
|
|
45
|
+
ops.appendEvent(fx.db, {
|
|
46
|
+
type: 'spec_changed',
|
|
47
|
+
payload: { sections: ['.ultra/specs/product.md#continuous-change'] },
|
|
48
|
+
});
|
|
49
|
+
runtime.enqueueProjection(fx.db, { tool_name: 'task.append_event', event_cursor: 2 });
|
|
50
|
+
|
|
51
|
+
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir, repair: true });
|
|
52
|
+
assert.equal(report.repair_performed, true);
|
|
53
|
+
assert.ok(fs.existsSync(report.backup_path));
|
|
54
|
+
assert.equal(ops.readTask(fx.db, 'stale-target').stale, true);
|
|
55
|
+
assert.equal(runtime.readConsumerCursor(fx.db, 'spec-staleness'), 2);
|
|
56
|
+
assert.equal(runtime.listProjectionJobs(fx.db, { status: 'pending' }).length, 0);
|
|
57
|
+
assert.equal(report.status, 'healthy');
|
|
58
|
+
} finally {
|
|
59
|
+
cleanup(fx);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('doctor reports running projection work and repairs only stale interrupted jobs', async () => {
|
|
64
|
+
const fx = fixture();
|
|
65
|
+
try {
|
|
66
|
+
ops.appendEvent(fx.db, { type: 'spec_changed', payload: { sections: [] } });
|
|
67
|
+
const job = runtime.enqueueProjection(fx.db, { tool_name: 'task.append_event', event_cursor: 1 });
|
|
68
|
+
fx.db.prepare("UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ?")
|
|
69
|
+
.run('2000-01-01T00:00:00.000Z', job.id);
|
|
70
|
+
|
|
71
|
+
const before = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
72
|
+
assert.equal(before.status, 'degraded');
|
|
73
|
+
assert.equal(before.checks.projections.running, 1);
|
|
74
|
+
|
|
75
|
+
const after = await doctor.runDoctor(fx.db, {
|
|
76
|
+
rootDir: fx.rootDir,
|
|
77
|
+
repair: true,
|
|
78
|
+
projectionStaleAfterMs: 0,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(after.repair.interrupted.requeued, 1);
|
|
81
|
+
assert.equal(after.checks.projections.running, 0);
|
|
82
|
+
assert.equal(after.status, 'healthy');
|
|
83
|
+
} finally {
|
|
84
|
+
cleanup(fx);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
const fs = require('node:fs');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
|
+
const { initStateDb } = require('./state-db.cjs');
|
|
18
|
+
|
|
17
19
|
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
18
20
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
19
21
|
: path.resolve(__dirname, '..', '..');
|
|
@@ -109,6 +111,20 @@ function injectTasksJson(ultraDir, { project_name, project_type, stack }) {
|
|
|
109
111
|
fs.writeFileSync(tasksFile, JSON.stringify(data, null, 2) + '\n');
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
function rollbackInitialization(ultraDir, backupPath) {
|
|
115
|
+
const errors = [];
|
|
116
|
+
try {
|
|
117
|
+
if (fs.existsSync(ultraDir)) fs.rmSync(ultraDir, { recursive: true, force: true });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
errors.push(`partial cleanup failed: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
if (backupPath) {
|
|
122
|
+
try { fs.renameSync(backupPath, ultraDir); }
|
|
123
|
+
catch (error) { errors.push(`backup restore failed: ${error.message}`); }
|
|
124
|
+
}
|
|
125
|
+
return errors;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
function initProject({
|
|
113
129
|
target_dir,
|
|
114
130
|
project_name,
|
|
@@ -138,20 +154,30 @@ function initProject({
|
|
|
138
154
|
status = 'overwritten';
|
|
139
155
|
}
|
|
140
156
|
|
|
141
|
-
try { fs.mkdirSync(ultraDir, { recursive: true }); }
|
|
142
|
-
catch (err) { throw new InitProjectError('IO_ERROR', `cannot create .ultra/: ${err.message}`, { retriable: true, cause: err }); }
|
|
143
|
-
|
|
144
157
|
let copiedFiles;
|
|
145
158
|
try {
|
|
159
|
+
fs.mkdirSync(ultraDir, { recursive: true });
|
|
146
160
|
copiedFiles = copyTemplate(source, ultraDir);
|
|
161
|
+
injectTasksJson(ultraDir, { project_name, project_type, stack });
|
|
162
|
+
const stateDbPath = path.join(ultraDir, 'state.db');
|
|
163
|
+
const { db } = initStateDb(stateDbPath);
|
|
164
|
+
db.close();
|
|
147
165
|
} catch (err) {
|
|
148
|
-
|
|
166
|
+
const rollbackErrors = rollbackInitialization(ultraDir, backupPath);
|
|
167
|
+
const rollbackNote = rollbackErrors.length > 0
|
|
168
|
+
? `; rollback incomplete: ${rollbackErrors.join('; ')}`
|
|
169
|
+
: '; prior state restored';
|
|
170
|
+
throw new InitProjectError(
|
|
171
|
+
'IO_ERROR',
|
|
172
|
+
`project initialization failed: ${err.message}${rollbackNote}`,
|
|
173
|
+
{ retriable: true, cause: err },
|
|
174
|
+
);
|
|
149
175
|
}
|
|
150
176
|
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
const stateDbPath = path.join(ultraDir, 'state.db');
|
|
153
178
|
const result = {
|
|
154
179
|
created_path: ultraDir,
|
|
180
|
+
state_db_path: stateDbPath,
|
|
155
181
|
status,
|
|
156
182
|
copied_files: copiedFiles,
|
|
157
183
|
};
|