ultra-builder-pro-cli 0.11.0 → 0.13.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.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +41 -228
- package/.ultra-template/specs/product.md +44 -200
- package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
- package/.ultra-template/tasks/tasks.json +6 -0
- package/CHANGELOG.md +94 -1
- package/README.md +37 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +5 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +10 -2
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/opencode.test.cjs +36 -1
- package/commands/ultra-init.md +3 -3
- package/docs/AGENT-CONTEXT.md +17 -8
- package/docs/ARCHITECTURE.md +51 -8
- package/docs/PLAN.zh-CN.md +9 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
- package/hooks/README.md +4 -3
- package/hooks/adapters/codex.py +1 -1
- package/hooks/adapters/kimi.py +1 -1
- package/hooks/context_spine.py +153 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +781 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
- package/mcp-server/lib/change-workflow.cjs +256 -85
- package/mcp-server/lib/change-workflow.test.cjs +436 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +20 -0
- package/mcp-server/lib/doctor.cjs +28 -1
- package/mcp-server/lib/doctor.test.cjs +63 -1
- package/mcp-server/lib/init-project.cjs +334 -16
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +202 -21
- package/mcp-server/lib/state-db.test.cjs +140 -3
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +62 -6
- package/mcp-server/tests/init-project.test.cjs +216 -9
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +478 -3
- package/mcp-server/tests/state-db.test.cjs +23 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +32 -13
- package/skills/ultra-deliver/SKILL.md +19 -5
- package/skills/ultra-dev/SKILL.md +8 -2
- package/skills/ultra-doctor/SKILL.md +30 -9
- package/skills/ultra-init/SKILL.md +107 -66
- package/skills/ultra-init/references/brownfield-adoption.md +93 -0
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-research/SKILL.md +17 -5
- package/skills/ultra-status/SKILL.md +15 -6
- package/spec/cli-protocol.md +15 -1
- package/spec/mcp-tools.yaml +316 -11
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +44 -2
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +29 -2
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +11 -2
- package/ultra-tools/status-command.test.cjs +49 -0
- package/ultra-tools/system-command.test.cjs +256 -0
|
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
7
8
|
|
|
8
9
|
const ROLES = new Set(['plan', 'implement', 'check', 'review']);
|
|
9
10
|
const GATES = new Set([
|
|
@@ -12,6 +13,12 @@ const GATES = new Set([
|
|
|
12
13
|
const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
|
|
13
14
|
const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
|
|
14
15
|
const DEFAULT_BUDGET = Object.freeze({ max_tokens: 12_000, max_files: 12 });
|
|
16
|
+
const ADVISORY_CONTEXT_CODES = new Set([
|
|
17
|
+
'CONTEXT_FILE_BUDGET_EXCEEDED',
|
|
18
|
+
'CONTEXT_TOKEN_BUDGET_EXCEEDED',
|
|
19
|
+
'EXECUTION_CONTEXT_BUDGET_EXCEEDED',
|
|
20
|
+
'EXECUTION_CONTEXT_BUDGET_ADVISORY',
|
|
21
|
+
]);
|
|
15
22
|
|
|
16
23
|
class ContextSpineError extends Error {
|
|
17
24
|
constructor(code, message) {
|
|
@@ -149,8 +156,13 @@ function defaultGate(role) {
|
|
|
149
156
|
}[role];
|
|
150
157
|
}
|
|
151
158
|
|
|
152
|
-
function deriveNextAction({ change, tasks, task, role, readiness, explicit }) {
|
|
153
|
-
if (readiness === 'blocked')
|
|
159
|
+
function deriveNextAction({ change, tasks, task, role, readiness, blockers = [], explicit }) {
|
|
160
|
+
if (readiness === 'blocked') {
|
|
161
|
+
if (blockers.some((blocker) => blocker.startsWith('BASELINE_'))) {
|
|
162
|
+
return 'Complete or refresh the Ultra project baseline, then recompile change.context.';
|
|
163
|
+
}
|
|
164
|
+
return 'Resolve the context readiness blockers, then recompile change.context.';
|
|
165
|
+
}
|
|
154
166
|
if (explicit && String(explicit).trim()) return String(explicit).trim();
|
|
155
167
|
if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
|
|
156
168
|
if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
|
|
@@ -199,12 +211,19 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
|
|
|
199
211
|
input, inherited.execution_contract, { taskId: input.task_id },
|
|
200
212
|
);
|
|
201
213
|
const blockers = [];
|
|
214
|
+
const warnings = [];
|
|
215
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
216
|
+
// An active change must remain executable even when adoption is incomplete or
|
|
217
|
+
// the repository has moved. Approved adoption is required at convergence and
|
|
218
|
+
// full revision/spec health is required at archive; here both are recovery
|
|
219
|
+
// guidance, not a refusal.
|
|
220
|
+
warnings.push(...baselineHealth.blockers, ...baselineHealth.warnings);
|
|
202
221
|
for (const item of items) {
|
|
203
222
|
if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
|
|
204
223
|
if (item.required && item.status === 'stale') blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
|
|
205
224
|
}
|
|
206
|
-
if (fileCount > budget.max_files)
|
|
207
|
-
if (tokenEstimate > budget.max_tokens)
|
|
225
|
+
if (fileCount > budget.max_files) warnings.push('CONTEXT_FILE_BUDGET_EXCEEDED');
|
|
226
|
+
if (tokenEstimate > budget.max_tokens) warnings.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
|
|
208
227
|
if (input.task_id) {
|
|
209
228
|
if (!executionContract) blockers.push('EXECUTION_CONTRACT_MISSING');
|
|
210
229
|
else {
|
|
@@ -212,21 +231,29 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
|
|
|
212
231
|
if (!executionContract.verification_command) blockers.push('EXECUTION_VERIFICATION_COMMAND_MISSING');
|
|
213
232
|
if (executionContract.slice_kind !== 'expand_contract'
|
|
214
233
|
&& executionContract.context_budget_percent > 40) {
|
|
215
|
-
|
|
234
|
+
warnings.push('EXECUTION_CONTEXT_BUDGET_ADVISORY');
|
|
216
235
|
}
|
|
217
236
|
}
|
|
218
237
|
}
|
|
219
238
|
const readiness = blockers.length === 0 ? 'ready' : 'blocked';
|
|
220
239
|
const nextAction = deriveNextAction({
|
|
221
|
-
change, tasks, task, role, readiness, explicit: input.next_action,
|
|
240
|
+
change, tasks, task, role, readiness, blockers, explicit: input.next_action,
|
|
222
241
|
});
|
|
223
242
|
return {
|
|
224
243
|
role,
|
|
225
244
|
gate,
|
|
226
245
|
next_action: nextAction,
|
|
227
|
-
readiness: { status: readiness, blockers },
|
|
246
|
+
readiness: { status: readiness, blockers, warnings: [...new Set(warnings)] },
|
|
228
247
|
context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
|
|
229
248
|
execution_contract: executionContract,
|
|
249
|
+
baseline: baselineHealth.baseline ? {
|
|
250
|
+
id: baselineHealth.baseline.id,
|
|
251
|
+
mode: baselineHealth.baseline.mode,
|
|
252
|
+
status: baselineHealth.baseline.status,
|
|
253
|
+
repository_revision: baselineHealth.baseline.repository_revision,
|
|
254
|
+
health: baselineHealth.status,
|
|
255
|
+
warnings: [...new Set([...baselineHealth.blockers, ...baselineHealth.warnings])],
|
|
256
|
+
} : null,
|
|
230
257
|
selected_task: task,
|
|
231
258
|
resume: {
|
|
232
259
|
change_id: change.id,
|
|
@@ -250,6 +277,7 @@ function recommendedWorkflow(change, task, tasks, readiness) {
|
|
|
250
277
|
}
|
|
251
278
|
|
|
252
279
|
function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
280
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
253
281
|
const change = id
|
|
254
282
|
? db.prepare('SELECT * FROM changes WHERE id = ?').get(id)
|
|
255
283
|
: db.prepare(
|
|
@@ -259,10 +287,32 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
|
259
287
|
throw new ContextSpineError('CHANGE_NOT_FOUND', `change ${id} not found`);
|
|
260
288
|
}
|
|
261
289
|
if (!change) {
|
|
290
|
+
const baseline = baselineHealth.baseline;
|
|
291
|
+
if (baselineHealth.status !== 'pass') {
|
|
292
|
+
const nextAction = baseline
|
|
293
|
+
? `Complete ${baseline.mode} baseline adoption ${baseline.id} through ultra-init.`
|
|
294
|
+
: 'Initialize the project and start baseline adoption with ultra-init.';
|
|
295
|
+
return {
|
|
296
|
+
change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
|
|
297
|
+
blockers: baselineHealth.blockers, warnings: baselineHealth.warnings, next_action: nextAction,
|
|
298
|
+
recommended_workflow: 'ultra-init', context_manifest_path: null,
|
|
299
|
+
context_manifest_hash: null, git_head: currentHead(rootDir),
|
|
300
|
+
baseline: baseline ? {
|
|
301
|
+
id: baseline.id, mode: baseline.mode, status: baseline.status,
|
|
302
|
+
repository_revision: baseline.repository_revision,
|
|
303
|
+
} : null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
262
306
|
return {
|
|
263
307
|
change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
|
|
264
308
|
next_action: 'Start daily work with ultra-change.', recommended_workflow: 'ultra-change',
|
|
265
309
|
context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
|
|
310
|
+
blockers: [], warnings: baselineHealth.warnings,
|
|
311
|
+
baseline: {
|
|
312
|
+
id: baselineHealth.baseline.id, mode: baselineHealth.baseline.mode,
|
|
313
|
+
status: baselineHealth.baseline.status,
|
|
314
|
+
repository_revision: baselineHealth.baseline.repository_revision,
|
|
315
|
+
},
|
|
266
316
|
};
|
|
267
317
|
}
|
|
268
318
|
const tasks = db.prepare('SELECT * FROM tasks WHERE change_id = ? ORDER BY created_at ASC').all(change.id);
|
|
@@ -275,18 +325,26 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
|
275
325
|
const task = tasks.find((row) => row.id === taskId) || null;
|
|
276
326
|
const head = currentHead(rootDir);
|
|
277
327
|
const snapshotMissing = !snapshot;
|
|
278
|
-
const legacyContext = Boolean(
|
|
328
|
+
const legacyContext = Boolean(
|
|
329
|
+
snapshot && (!context.resume || !context.context || !context.readiness || !('baseline' in context)),
|
|
330
|
+
);
|
|
279
331
|
const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
|
|
280
332
|
const stateChanged = context.resume?.task_status !== undefined
|
|
281
333
|
&& context.resume.task_status !== (task?.status || null);
|
|
282
334
|
const blockers = [];
|
|
335
|
+
const warnings = [...baselineHealth.blockers, ...baselineHealth.warnings];
|
|
283
336
|
if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
|
|
284
337
|
else {
|
|
285
338
|
if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
|
|
286
339
|
if (headStale) blockers.push('CONTEXT_HEAD_STALE');
|
|
287
340
|
if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
|
|
288
341
|
for (const blocker of parseJson(snapshot.blockers_json, [])) {
|
|
289
|
-
if (
|
|
342
|
+
if (ADVISORY_CONTEXT_CODES.has(blocker) || blocker.startsWith('BASELINE_')) {
|
|
343
|
+
if (!warnings.includes(blocker)) warnings.push(blocker);
|
|
344
|
+
} else if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
345
|
+
}
|
|
346
|
+
for (const warning of context.readiness?.warnings || []) {
|
|
347
|
+
if (!warnings.includes(warning)) warnings.push(warning);
|
|
290
348
|
}
|
|
291
349
|
}
|
|
292
350
|
const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
|
|
@@ -302,6 +360,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
|
302
360
|
} else {
|
|
303
361
|
nextAction = deriveNextAction({
|
|
304
362
|
change, tasks, task, role: snapshot?.role || 'plan', readiness,
|
|
363
|
+
blockers,
|
|
305
364
|
explicit: snapshot?.next_action,
|
|
306
365
|
});
|
|
307
366
|
}
|
|
@@ -315,11 +374,17 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
|
315
374
|
gate: snapshot?.gate || 'alignment',
|
|
316
375
|
readiness,
|
|
317
376
|
blockers,
|
|
377
|
+
warnings: [...new Set(warnings)],
|
|
318
378
|
next_action: nextAction,
|
|
319
379
|
recommended_workflow: recommendedWorkflow(change, task, tasks, readiness),
|
|
320
380
|
context_manifest_path: snapshot?.manifest_path || null,
|
|
321
381
|
context_manifest_hash: snapshot?.manifest_hash || null,
|
|
322
382
|
git_head: head,
|
|
383
|
+
baseline: baselineHealth.baseline ? {
|
|
384
|
+
id: baselineHealth.baseline.id, mode: baselineHealth.baseline.mode,
|
|
385
|
+
status: baselineHealth.baseline.status,
|
|
386
|
+
repository_revision: baselineHealth.baseline.repository_revision,
|
|
387
|
+
} : null,
|
|
323
388
|
};
|
|
324
389
|
}
|
|
325
390
|
|
|
@@ -11,6 +11,21 @@ const { initStateDb } = require('./state-db.cjs');
|
|
|
11
11
|
const { createChange } = require('./change-workflow.cjs');
|
|
12
12
|
const { createTask } = require('./state-ops.cjs');
|
|
13
13
|
|
|
14
|
+
test('breadcrumb routes a project without a converged baseline back to adoption', () => {
|
|
15
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-baseline-'));
|
|
16
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
17
|
+
try {
|
|
18
|
+
const breadcrumb = readBreadcrumb(db, {}, { rootDir });
|
|
19
|
+
assert.equal(breadcrumb.readiness, 'blocked');
|
|
20
|
+
assert.ok(breadcrumb.blockers.includes('BASELINE_MISSING'));
|
|
21
|
+
assert.equal(breadcrumb.recommended_workflow, 'ultra-init');
|
|
22
|
+
assert.match(breadcrumb.next_action, /baseline|adoption/i);
|
|
23
|
+
} finally {
|
|
24
|
+
db.close();
|
|
25
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
14
29
|
test('deriveNextAction gives one state-specific action instead of a workflow dump', () => {
|
|
15
30
|
const change = { id: 'context-spine', status: 'active' };
|
|
16
31
|
const task = { id: 'task-1', status: 'in_progress' };
|
|
@@ -37,6 +52,11 @@ test('a task is not plan-ready before its execution contract is compiled', () =>
|
|
|
37
52
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-plan-'));
|
|
38
53
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
39
54
|
try {
|
|
55
|
+
db.prepare(
|
|
56
|
+
`INSERT INTO baselines
|
|
57
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
58
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
59
|
+
).run(new Date().toISOString());
|
|
40
60
|
const { change } = createChange(db, {
|
|
41
61
|
id: 'planning-contract', title: 'Planning contract', kind: 'standard',
|
|
42
62
|
intent: 'Require an executable fresh-context slice before implementation.',
|
|
@@ -8,6 +8,9 @@ const ops = require('./state-ops.cjs');
|
|
|
8
8
|
const runtime = require('./runtime-state.cjs');
|
|
9
9
|
const projector = require('./projector.cjs');
|
|
10
10
|
const recovery = require('../../orchestrator/recovery.cjs');
|
|
11
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
12
|
+
const changes = require('./change-workflow.cjs');
|
|
13
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
11
14
|
|
|
12
15
|
const SPEC_CONSUMER = 'spec-staleness';
|
|
13
16
|
|
|
@@ -48,8 +51,14 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
48
51
|
WHERE status IN ('active', 'blocked', 'ready') AND artifact_root IS NOT NULL`,
|
|
49
52
|
).all().filter((row) => row.artifact_root && !fs.existsSync(path.resolve(rootDir, row.artifact_root))).length
|
|
50
53
|
: 0;
|
|
54
|
+
const archiveIntents = missing.length === 0 ? archiveJournal.listArchiveIntents(rootDir) : [];
|
|
55
|
+
const corruptArchiveIntents = archiveIntents.filter((item) => item.error).length;
|
|
56
|
+
const baseline = missing.length === 0
|
|
57
|
+
? baselines.inspectBaseline(db, { rootDir })
|
|
58
|
+
: { status: 'fail', blockers: ['BASELINE_STATE_UNAVAILABLE'], warnings: [], baseline: null };
|
|
51
59
|
const degraded = integrity !== 'ok' || missing.length > 0 || incidents.length > 0
|
|
52
60
|
|| pending.length > 0 || running.length > 0 || failed.length > 0 || orphanSessions > 0 || activeMissing > 0
|
|
61
|
+
|| archiveIntents.length > 0
|
|
53
62
|
|| eventCursor > projectedCursor;
|
|
54
63
|
return {
|
|
55
64
|
status: degraded ? 'degraded' : 'healthy',
|
|
@@ -63,6 +72,23 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
63
72
|
},
|
|
64
73
|
sessions: { status: orphanSessions === 0 ? 'pass' : 'fail', orphan: orphanSessions },
|
|
65
74
|
change_artifacts: { status: activeMissing === 0 ? 'pass' : 'fail', missing: activeMissing },
|
|
75
|
+
archive_recovery: {
|
|
76
|
+
status: archiveIntents.length === 0 ? 'pass' : 'fail',
|
|
77
|
+
pending: archiveIntents.length, corrupt: corruptArchiveIntents,
|
|
78
|
+
},
|
|
79
|
+
baseline: {
|
|
80
|
+
status: baseline.status === 'pass' ? 'pass' : 'warning',
|
|
81
|
+
readiness: baseline.status,
|
|
82
|
+
mode: baseline.baseline?.mode || null,
|
|
83
|
+
baseline_status: baseline.baseline?.status || null,
|
|
84
|
+
id: baseline.baseline?.id || null,
|
|
85
|
+
repository_revision: baseline.baseline?.repository_revision || null,
|
|
86
|
+
repository_branch: baseline.baseline?.repository_branch || null,
|
|
87
|
+
worktree_state: baseline.baseline?.worktree_state || 'unavailable',
|
|
88
|
+
gaps: baseline.baseline ? baselines.summarizeGaps(baseline.baseline.gaps) : null,
|
|
89
|
+
blockers: baseline.blockers,
|
|
90
|
+
warnings: baseline.warnings,
|
|
91
|
+
},
|
|
66
92
|
external_providers: {
|
|
67
93
|
status: 'pass', ownership: 'external',
|
|
68
94
|
note: 'Ultra stores provider metadata references only and never owns memory or code-graph content.',
|
|
@@ -86,6 +112,7 @@ async function runDoctor(db, {
|
|
|
86
112
|
} = {}) {
|
|
87
113
|
if (!repair) return { ...inspectSystem(db, { rootDir }), repair_performed: false };
|
|
88
114
|
const backupPath = await backupDatabase(db, rootDir);
|
|
115
|
+
const archives = changes.recoverInterruptedArchives(db, { rootDir });
|
|
89
116
|
const recovered = recovery.recoverOnBoot(db);
|
|
90
117
|
const cursor = runtime.readConsumerCursor(db, SPEC_CONSUMER);
|
|
91
118
|
const consumed = ops.consumeSpecChangedEvents(db, { since_id: cursor, limit: 500 });
|
|
@@ -100,7 +127,7 @@ async function runDoctor(db, {
|
|
|
100
127
|
...inspectSystem(db, { rootDir }),
|
|
101
128
|
repair_performed: true,
|
|
102
129
|
backup_path: backupPath,
|
|
103
|
-
repair: { recovered, consumed, interrupted, requeued, ensured, projections },
|
|
130
|
+
repair: { archives, recovered, consumed, interrupted, requeued, ensured, projections },
|
|
104
131
|
};
|
|
105
132
|
}
|
|
106
133
|
|
|
@@ -10,10 +10,20 @@ const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
|
10
10
|
const ops = require('./state-ops.cjs');
|
|
11
11
|
const runtime = require('./runtime-state.cjs');
|
|
12
12
|
const doctor = require('./doctor.cjs');
|
|
13
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
14
|
+
const changes = require('./change-workflow.cjs');
|
|
15
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
13
16
|
|
|
14
|
-
function fixture() {
|
|
17
|
+
function fixture({ baseline = true } = {}) {
|
|
15
18
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-doctor-'));
|
|
16
19
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
20
|
+
if (baseline) {
|
|
21
|
+
db.prepare(
|
|
22
|
+
`INSERT INTO baselines
|
|
23
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
24
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
25
|
+
).run(new Date().toISOString());
|
|
26
|
+
}
|
|
17
27
|
return { rootDir, db };
|
|
18
28
|
}
|
|
19
29
|
|
|
@@ -35,6 +45,25 @@ test('doctor reports structured health for an initialized Ultra project', async
|
|
|
35
45
|
}
|
|
36
46
|
});
|
|
37
47
|
|
|
48
|
+
test('doctor reports incomplete brownfield adoption as advisory rather than authority failure', async () => {
|
|
49
|
+
const fx = fixture({ baseline: false });
|
|
50
|
+
try {
|
|
51
|
+
baselines.startBaseline(fx.db, {
|
|
52
|
+
id: 'adoption', project_name: 'legacy', mode: 'brownfield', scope: ['.'],
|
|
53
|
+
}, { rootDir: fx.rootDir, emitEvent: false });
|
|
54
|
+
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
55
|
+
assert.equal(report.status, 'healthy');
|
|
56
|
+
assert.equal(report.checks.baseline.status, 'warning');
|
|
57
|
+
assert.equal(report.checks.baseline.mode, 'brownfield');
|
|
58
|
+
assert.deepEqual(report.checks.baseline.gaps, {
|
|
59
|
+
total: 0, open: 0, blocking: 0, by_category: {},
|
|
60
|
+
});
|
|
61
|
+
assert.ok(report.checks.baseline.blockers.includes('BASELINE_NOT_READY:adopting'));
|
|
62
|
+
} finally {
|
|
63
|
+
cleanup(fx);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
38
67
|
test('doctor repair is backup-first and drains projection/spec-event recovery work', async () => {
|
|
39
68
|
const fx = fixture();
|
|
40
69
|
try {
|
|
@@ -84,3 +113,36 @@ test('doctor reports running projection work and repairs only stale interrupted
|
|
|
84
113
|
cleanup(fx);
|
|
85
114
|
}
|
|
86
115
|
});
|
|
116
|
+
|
|
117
|
+
test('doctor repair resumes an archive journal left after a process crash', async () => {
|
|
118
|
+
const fx = fixture();
|
|
119
|
+
try {
|
|
120
|
+
const activeRoot = path.join(fx.rootDir, '.ultra', 'changes', 'active', 'doctor-archive');
|
|
121
|
+
fs.mkdirSync(activeRoot, { recursive: true });
|
|
122
|
+
fs.writeFileSync(path.join(activeRoot, 'intent.md'), '# Authorized archive\n');
|
|
123
|
+
fx.db.prepare(
|
|
124
|
+
`INSERT INTO changes
|
|
125
|
+
(id, title, kind, status, intent, docs_impact_json, provider_refs_json, artifact_root)
|
|
126
|
+
VALUES ('doctor-archive', 'Recover archive', 'quick', 'ready', 'Recover it',
|
|
127
|
+
'{"status":"none","files":[],"rationale":"recovery"}', '{}',
|
|
128
|
+
'.ultra/changes/active/doctor-archive')`,
|
|
129
|
+
).run();
|
|
130
|
+
const prepared = archiveJournal.prepareArchiveMove({
|
|
131
|
+
rootDir: fx.rootDir, change: changes.readChange(fx.db, 'doctor-archive'),
|
|
132
|
+
summary: 'Resume the authorized archive.', baselineUpdates: [],
|
|
133
|
+
noBaselineChangeReason: 'No baseline content changed.',
|
|
134
|
+
});
|
|
135
|
+
assert.equal(fs.existsSync(prepared.source), false);
|
|
136
|
+
const before = doctor.inspectSystem(fx.db, { rootDir: fx.rootDir });
|
|
137
|
+
assert.equal(before.status, 'degraded');
|
|
138
|
+
assert.equal(before.checks.archive_recovery.pending, 1);
|
|
139
|
+
|
|
140
|
+
const after = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir, repair: true });
|
|
141
|
+
assert.equal(after.repair.archives.resumed, 1);
|
|
142
|
+
assert.equal(changes.readChange(fx.db, 'doctor-archive').status, 'archived');
|
|
143
|
+
assert.equal(after.checks.archive_recovery.pending, 0);
|
|
144
|
+
assert.equal(fs.existsSync(path.join(prepared.destination, archiveJournal.INTENT_FILE)), false);
|
|
145
|
+
} finally {
|
|
146
|
+
cleanup(fx);
|
|
147
|
+
}
|
|
148
|
+
});
|