ultra-builder-pro-cli 0.12.1 → 0.14.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/discovery.md +10 -0
- package/CHANGELOG.md +95 -1
- package/README.md +31 -16
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +21 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +10 -6
- package/docs/ARCHITECTURE.md +40 -17
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +2 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -299
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- 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 +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +14 -37
- package/mcp-server/lib/task-expander.test.cjs +80 -164
- package/mcp-server/server.cjs +34 -37
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +391 -17
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +20 -7
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +133 -19
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
|
@@ -11,6 +11,7 @@ const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
|
11
11
|
const ops = require('./state-ops.cjs');
|
|
12
12
|
const changes = require('./change-workflow.cjs');
|
|
13
13
|
const baselines = require('./baseline-workflow.cjs');
|
|
14
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
14
15
|
|
|
15
16
|
function fixture() {
|
|
16
17
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-change-'));
|
|
@@ -24,7 +25,7 @@ function fixture() {
|
|
|
24
25
|
db.prepare(
|
|
25
26
|
`INSERT INTO baselines
|
|
26
27
|
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
27
|
-
VALUES ('test-baseline', 'fixture', '
|
|
28
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
28
29
|
).run(new Date().toISOString());
|
|
29
30
|
return { rootDir, db };
|
|
30
31
|
}
|
|
@@ -130,6 +131,163 @@ test('createChange persists a change and an inspectable external-provider contex
|
|
|
130
131
|
}
|
|
131
132
|
});
|
|
132
133
|
|
|
134
|
+
test('new ordinary changes require a ready baseline while incidents require an explicit break-glass record', () => {
|
|
135
|
+
const fx = fixture();
|
|
136
|
+
try {
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => changes.createChange(fx.db, {
|
|
139
|
+
id: 'chg-invalid-bypass', title: 'Ordinary work', kind: 'quick',
|
|
140
|
+
intent: 'Do not attach break-glass approval to ordinary work.',
|
|
141
|
+
baseline_bypass: { reason: 'Not applicable.', approved_by: 'owner' },
|
|
142
|
+
}, { rootDir: fx.rootDir }),
|
|
143
|
+
(error) => error.code === 'VALIDATION_ERROR',
|
|
144
|
+
);
|
|
145
|
+
fx.db.prepare('DELETE FROM baselines').run();
|
|
146
|
+
assert.throws(
|
|
147
|
+
() => changes.createChange(fx.db, {
|
|
148
|
+
id: 'chg-normal-without-baseline', title: 'Do ordinary work', kind: 'quick',
|
|
149
|
+
intent: 'Do not start ordinary work before adoption.',
|
|
150
|
+
}, { rootDir: fx.rootDir }),
|
|
151
|
+
(error) => error.code === 'BASELINE_NOT_READY',
|
|
152
|
+
);
|
|
153
|
+
assert.throws(
|
|
154
|
+
() => changes.createChange(fx.db, {
|
|
155
|
+
id: 'chg-incident-without-approval', title: 'Recover production', kind: 'incident',
|
|
156
|
+
intent: 'Restore the public runtime without waiting for adoption.',
|
|
157
|
+
}, { rootDir: fx.rootDir }),
|
|
158
|
+
(error) => error.code === 'BASELINE_BYPASS_REQUIRED',
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const created = changes.createChange(fx.db, {
|
|
162
|
+
id: 'chg-incident-break-glass', title: 'Recover production', kind: 'incident',
|
|
163
|
+
intent: 'Restore the public runtime without waiting for adoption.',
|
|
164
|
+
baseline_bypass: {
|
|
165
|
+
reason: 'Production is unavailable and baseline adoption cannot precede recovery.',
|
|
166
|
+
approved_by: 'incident-commander',
|
|
167
|
+
},
|
|
168
|
+
}, { rootDir: fx.rootDir });
|
|
169
|
+
assert.equal(created.change.baseline_bypass.approved_by, 'incident-commander');
|
|
170
|
+
assert.equal(created.change.baseline_bypass.mode, 'incident_break_glass');
|
|
171
|
+
assert.match(created.change.baseline_bypass.recorded_at, /^\d{4}-\d{2}-\d{2}T/);
|
|
172
|
+
} finally { cleanup(fx); }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('task creation authority rejects missing and terminal change ownership even with a healthy baseline', () => {
|
|
176
|
+
const fx = fixture();
|
|
177
|
+
try {
|
|
178
|
+
assert.throws(
|
|
179
|
+
() => changes.assertTaskCreationAllowed(fx.db, { change_id: 'missing-change' }, { rootDir: fx.rootDir }),
|
|
180
|
+
(error) => error.code === 'CHANGE_NOT_FOUND',
|
|
181
|
+
);
|
|
182
|
+
for (const status of ['ready', 'archived', 'cancelled']) {
|
|
183
|
+
const id = `terminal-${status}`;
|
|
184
|
+
changes.createChange(fx.db, {
|
|
185
|
+
id, title: `Terminal ${status}`, kind: 'quick',
|
|
186
|
+
intent: 'Verify terminal changes cannot receive new tasks.',
|
|
187
|
+
}, { rootDir: fx.rootDir });
|
|
188
|
+
fx.db.prepare('UPDATE changes SET status = ? WHERE id = ?').run(status, id);
|
|
189
|
+
assert.throws(
|
|
190
|
+
() => changes.assertTaskCreationAllowed(fx.db, { change_id: id }, { rootDir: fx.rootDir }),
|
|
191
|
+
(error) => error.code === 'CHANGE_NOT_MUTABLE',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} finally { cleanup(fx); }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('incident break-glass remains executable and archives with a mandatory baseline reconciliation gap', () => {
|
|
198
|
+
const fx = fixture();
|
|
199
|
+
try {
|
|
200
|
+
fx.db.prepare(
|
|
201
|
+
"UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
|
|
202
|
+
).run();
|
|
203
|
+
const created = changes.createChange(fx.db, {
|
|
204
|
+
id: 'chg-break-glass-closure', title: 'Restore the production path', kind: 'incident',
|
|
205
|
+
intent: 'Repair the urgent runtime failure before baseline adoption can finish.',
|
|
206
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal runtime recovery.' },
|
|
207
|
+
baseline_bypass: {
|
|
208
|
+
reason: 'Production recovery cannot wait for the incomplete brownfield baseline.',
|
|
209
|
+
approved_by: 'incident-commander',
|
|
210
|
+
},
|
|
211
|
+
}, { rootDir: fx.rootDir });
|
|
212
|
+
ops.createTask(fx.db, {
|
|
213
|
+
id: 'break-glass-task', title: 'Repair urgent runtime failure', type: 'bugfix', priority: 'P0',
|
|
214
|
+
change_id: 'chg-break-glass-closure',
|
|
215
|
+
});
|
|
216
|
+
ops.updateTaskStatus(fx.db, 'break-glass-task', 'in_progress');
|
|
217
|
+
ops.updateTaskStatus(fx.db, 'break-glass-task', 'completed');
|
|
218
|
+
const compiled = changes.compileContext(fx.db, {
|
|
219
|
+
id: 'chg-break-glass-closure', ...executionContext('break-glass-task'),
|
|
220
|
+
}, { rootDir: fx.rootDir });
|
|
221
|
+
assert.equal(compiled.manifest.readiness.status, 'ready');
|
|
222
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('BASELINE_NOT_READY:adopting'));
|
|
223
|
+
|
|
224
|
+
const diagnosisPath = path.join(path.dirname(created.intent_path), 'diagnosis.md');
|
|
225
|
+
fs.writeFileSync(diagnosisPath, [
|
|
226
|
+
'# Incident diagnosis: Restore the production path', '',
|
|
227
|
+
'## Reproduction', '', 'The public runtime fails on the terminal transition.', '',
|
|
228
|
+
'## Hypotheses', '', 'The transition loses its durable terminal write.', '',
|
|
229
|
+
'## Root cause', '', 'The terminal path returns before the durable write completes.', '',
|
|
230
|
+
'## Regression test', '', 'The regression test reproduces the lost write and then passes.', '',
|
|
231
|
+
'## Recovery', '', 'Replay the terminal write and verify the public runtime.', '',
|
|
232
|
+
].join('\n'));
|
|
233
|
+
|
|
234
|
+
const converged = changes.convergeChange(fx.db, {
|
|
235
|
+
id: 'chg-break-glass-closure', evidence: incidentEvidence(),
|
|
236
|
+
}, { rootDir: fx.rootDir });
|
|
237
|
+
assert.equal(converged.ready, true);
|
|
238
|
+
|
|
239
|
+
const archived = changes.archiveChange(fx.db, {
|
|
240
|
+
id: 'chg-break-glass-closure', summary: 'Production restored with regression evidence.',
|
|
241
|
+
no_baseline_change_reason: 'Baseline adoption is tracked by the reconciliation gap.',
|
|
242
|
+
}, { rootDir: fx.rootDir });
|
|
243
|
+
assert.equal(archived.change.status, 'archived');
|
|
244
|
+
assert.equal(archived.baseline_bypass, true);
|
|
245
|
+
const baseline = baselines.readBaseline(fx.db, 'test-baseline');
|
|
246
|
+
assert.equal(baseline.status, 'adopting');
|
|
247
|
+
assert.deepEqual(
|
|
248
|
+
baseline.gaps.map((gap) => ({ id: gap.id, category: gap.category, status: gap.status, blocking: gap.blocking })),
|
|
249
|
+
[{
|
|
250
|
+
id: 'incident-chg-break-glass-closure-reconciliation',
|
|
251
|
+
category: 'baseline_blocker', status: 'open', blocking: true,
|
|
252
|
+
}],
|
|
253
|
+
);
|
|
254
|
+
assert.equal(baseline.gaps[0].owner, 'incident-commander');
|
|
255
|
+
assert.ok(baseline.gaps[0].evidence_refs.some((ref) => ref.includes('changes/archive/')));
|
|
256
|
+
} finally { cleanup(fx); }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('an already-active ordinary change remains executable when baseline adoption later becomes incomplete', () => {
|
|
260
|
+
const fx = fixture();
|
|
261
|
+
try {
|
|
262
|
+
changes.createChange(fx.db, {
|
|
263
|
+
id: 'chg-grandfathered-context', title: 'Finish active work', kind: 'quick',
|
|
264
|
+
intent: 'Preserve an already-active bounded change while baseline recovery runs.',
|
|
265
|
+
}, { rootDir: fx.rootDir });
|
|
266
|
+
ops.createTask(fx.db, {
|
|
267
|
+
id: 'grandfathered-task', title: 'Finish active work', type: 'bugfix', priority: 'P0',
|
|
268
|
+
change_id: 'chg-grandfathered-context',
|
|
269
|
+
});
|
|
270
|
+
fx.db.prepare(
|
|
271
|
+
"UPDATE baselines SET status = 'adopting', mode = 'brownfield' WHERE id = 'test-baseline'",
|
|
272
|
+
).run();
|
|
273
|
+
|
|
274
|
+
assert.doesNotThrow(() => changes.assertTaskCreationAllowed(fx.db, {
|
|
275
|
+
change_id: 'chg-grandfathered-context',
|
|
276
|
+
}, { rootDir: fx.rootDir }));
|
|
277
|
+
assert.throws(
|
|
278
|
+
() => changes.assertTaskCreationAllowed(fx.db, {}, { rootDir: fx.rootDir }),
|
|
279
|
+
(error) => error.code === 'BASELINE_NOT_READY',
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const compiled = changes.compileContext(fx.db, {
|
|
283
|
+
id: 'chg-grandfathered-context', ...executionContext('grandfathered-task'),
|
|
284
|
+
}, { rootDir: fx.rootDir });
|
|
285
|
+
assert.equal(compiled.manifest.readiness.status, 'ready');
|
|
286
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('BASELINE_NOT_READY:adopting'));
|
|
287
|
+
assert.deepEqual(compiled.manifest.readiness.blockers, []);
|
|
288
|
+
} finally { cleanup(fx); }
|
|
289
|
+
});
|
|
290
|
+
|
|
133
291
|
test('convergeChange blocks incomplete work and marks a fully evidenced standard change ready', () => {
|
|
134
292
|
const fx = fixture();
|
|
135
293
|
try {
|
|
@@ -220,6 +378,27 @@ test('convergeChange requires an approved baseline after active work has remaine
|
|
|
220
378
|
}
|
|
221
379
|
});
|
|
222
380
|
|
|
381
|
+
test('change creation rejects a schema-migration compatibility baseline until re-adoption', () => {
|
|
382
|
+
const fx = fixture();
|
|
383
|
+
try {
|
|
384
|
+
fx.db.prepare(
|
|
385
|
+
`UPDATE baselines SET mode = 'migrated', approved_by = 'schema-migration',
|
|
386
|
+
approval_note = 'compatibility only' WHERE id = 'test-baseline'`,
|
|
387
|
+
).run();
|
|
388
|
+
assert.throws(
|
|
389
|
+
() => changes.createChange(fx.db, {
|
|
390
|
+
id: 'chg-migrated-gate', title: 'Require owner adoption', kind: 'quick',
|
|
391
|
+
intent: 'Do not let schema migration impersonate project-owner approval.',
|
|
392
|
+
docs_impact: { status: 'none', files: [], rationale: 'Gate behavior only.' },
|
|
393
|
+
}, { rootDir: fx.rootDir }),
|
|
394
|
+
(error) => error.code === 'BASELINE_NOT_READY'
|
|
395
|
+
&& error.details.blockers.includes('BASELINE_MIGRATION_REVIEW_REQUIRED'),
|
|
396
|
+
);
|
|
397
|
+
} finally {
|
|
398
|
+
cleanup(fx);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
223
402
|
test('archiveChange moves a ready change into immutable history and records baseline reconciliation', () => {
|
|
224
403
|
const fx = fixture();
|
|
225
404
|
try {
|
|
@@ -385,6 +564,61 @@ test('archiveChange rolls back when declared reconciliation leaves a tracked spe
|
|
|
385
564
|
}
|
|
386
565
|
});
|
|
387
566
|
|
|
567
|
+
test('archiveChange resumes a durable journal after the filesystem move outlives the process', () => {
|
|
568
|
+
const fx = fixture();
|
|
569
|
+
try {
|
|
570
|
+
changes.createChange(fx.db, {
|
|
571
|
+
id: 'chg-crash-resume', title: 'Resume interrupted archive', kind: 'quick',
|
|
572
|
+
intent: 'Finish an authorized archive after a process crash.',
|
|
573
|
+
docs_impact: { status: 'none', files: [], rationale: 'Recovery-only behavior.' },
|
|
574
|
+
}, { rootDir: fx.rootDir });
|
|
575
|
+
ops.createTask(fx.db, {
|
|
576
|
+
id: 'crash-resume-task', title: 'Verify archive recovery', type: 'bugfix', priority: 'P0',
|
|
577
|
+
change_id: 'chg-crash-resume',
|
|
578
|
+
});
|
|
579
|
+
ops.updateTaskStatus(fx.db, 'crash-resume-task', 'in_progress');
|
|
580
|
+
ops.updateTaskStatus(fx.db, 'crash-resume-task', 'completed');
|
|
581
|
+
changes.compileContext(fx.db, {
|
|
582
|
+
id: 'chg-crash-resume', ...executionContext('crash-resume-task'),
|
|
583
|
+
}, { rootDir: fx.rootDir });
|
|
584
|
+
const converged = changes.convergeChange(fx.db, {
|
|
585
|
+
id: 'chg-crash-resume',
|
|
586
|
+
evidence: [
|
|
587
|
+
{ category: 'diff', status: 'pass', evidence: 'recovery diff reviewed' },
|
|
588
|
+
{
|
|
589
|
+
category: 'tests', status: 'pass', evidence: 'crash recovery passed', seam: 'archive retry',
|
|
590
|
+
signal: {
|
|
591
|
+
command: 'node --test archive-journal.test.cjs',
|
|
592
|
+
expected_red: 'archive retry fails after the filesystem move',
|
|
593
|
+
observed_red: true, observed_green: true, deterministic: true, duration_ms: 10,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{ category: 'spec', status: 'not_applicable', evidence: 'No contract change.' },
|
|
597
|
+
],
|
|
598
|
+
}, { rootDir: fx.rootDir });
|
|
599
|
+
assert.equal(converged.ready, true);
|
|
600
|
+
const input = {
|
|
601
|
+
id: 'chg-crash-resume', summary: 'Resume the interrupted archive safely.',
|
|
602
|
+
no_baseline_change_reason: 'No baseline content changed.',
|
|
603
|
+
};
|
|
604
|
+
const change = changes.readChange(fx.db, input.id);
|
|
605
|
+
const interrupted = archiveJournal.prepareArchiveMove({
|
|
606
|
+
rootDir: fx.rootDir, change, summary: input.summary, baselineUpdates: [],
|
|
607
|
+
noBaselineChangeReason: input.no_baseline_change_reason,
|
|
608
|
+
});
|
|
609
|
+
assert.equal(changes.readChange(fx.db, input.id).status, 'ready');
|
|
610
|
+
assert.equal(fs.existsSync(interrupted.source), false);
|
|
611
|
+
assert.equal(fs.existsSync(interrupted.destination), true);
|
|
612
|
+
|
|
613
|
+
const resumed = changes.archiveChange(fx.db, input, { rootDir: fx.rootDir });
|
|
614
|
+
assert.equal(resumed.change.status, 'archived');
|
|
615
|
+
assert.equal(resumed.archive_path, interrupted.destination);
|
|
616
|
+
assert.equal(archiveJournal.listArchiveIntents(fx.rootDir).length, 0);
|
|
617
|
+
} finally {
|
|
618
|
+
cleanup(fx);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
388
622
|
test('provider references reject embedded memory or graph payloads', () => {
|
|
389
623
|
const fx = fixture();
|
|
390
624
|
try {
|
|
@@ -52,6 +52,11 @@ test('a task is not plan-ready before its execution contract is compiled', () =>
|
|
|
52
52
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-plan-'));
|
|
53
53
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
54
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());
|
|
55
60
|
const { change } = createChange(db, {
|
|
56
61
|
id: 'planning-contract', title: 'Planning contract', kind: 'standard',
|
|
57
62
|
intent: 'Require an executable fresh-context slice before implementation.',
|
|
@@ -9,6 +9,8 @@ const runtime = require('./runtime-state.cjs');
|
|
|
9
9
|
const projector = require('./projector.cjs');
|
|
10
10
|
const recovery = require('../../orchestrator/recovery.cjs');
|
|
11
11
|
const baselines = require('./baseline-workflow.cjs');
|
|
12
|
+
const changes = require('./change-workflow.cjs');
|
|
13
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
12
14
|
|
|
13
15
|
const SPEC_CONSUMER = 'spec-staleness';
|
|
14
16
|
|
|
@@ -49,11 +51,14 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
49
51
|
WHERE status IN ('active', 'blocked', 'ready') AND artifact_root IS NOT NULL`,
|
|
50
52
|
).all().filter((row) => row.artifact_root && !fs.existsSync(path.resolve(rootDir, row.artifact_root))).length
|
|
51
53
|
: 0;
|
|
54
|
+
const archiveIntents = missing.length === 0 ? archiveJournal.listArchiveIntents(rootDir) : [];
|
|
55
|
+
const corruptArchiveIntents = archiveIntents.filter((item) => item.error).length;
|
|
52
56
|
const baseline = missing.length === 0
|
|
53
57
|
? baselines.inspectBaseline(db, { rootDir })
|
|
54
58
|
: { status: 'fail', blockers: ['BASELINE_STATE_UNAVAILABLE'], warnings: [], baseline: null };
|
|
55
59
|
const degraded = integrity !== 'ok' || missing.length > 0 || incidents.length > 0
|
|
56
60
|
|| pending.length > 0 || running.length > 0 || failed.length > 0 || orphanSessions > 0 || activeMissing > 0
|
|
61
|
+
|| archiveIntents.length > 0
|
|
57
62
|
|| eventCursor > projectedCursor;
|
|
58
63
|
return {
|
|
59
64
|
status: degraded ? 'degraded' : 'healthy',
|
|
@@ -67,6 +72,10 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
67
72
|
},
|
|
68
73
|
sessions: { status: orphanSessions === 0 ? 'pass' : 'fail', orphan: orphanSessions },
|
|
69
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
|
+
},
|
|
70
79
|
baseline: {
|
|
71
80
|
status: baseline.status === 'pass' ? 'pass' : 'warning',
|
|
72
81
|
readiness: baseline.status,
|
|
@@ -74,6 +83,9 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
74
83
|
baseline_status: baseline.baseline?.status || null,
|
|
75
84
|
id: baseline.baseline?.id || null,
|
|
76
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,
|
|
77
89
|
blockers: baseline.blockers,
|
|
78
90
|
warnings: baseline.warnings,
|
|
79
91
|
},
|
|
@@ -100,6 +112,7 @@ async function runDoctor(db, {
|
|
|
100
112
|
} = {}) {
|
|
101
113
|
if (!repair) return { ...inspectSystem(db, { rootDir }), repair_performed: false };
|
|
102
114
|
const backupPath = await backupDatabase(db, rootDir);
|
|
115
|
+
const archives = changes.recoverInterruptedArchives(db, { rootDir });
|
|
103
116
|
const recovered = recovery.recoverOnBoot(db);
|
|
104
117
|
const cursor = runtime.readConsumerCursor(db, SPEC_CONSUMER);
|
|
105
118
|
const consumed = ops.consumeSpecChangedEvents(db, { since_id: cursor, limit: 500 });
|
|
@@ -114,7 +127,7 @@ async function runDoctor(db, {
|
|
|
114
127
|
...inspectSystem(db, { rootDir }),
|
|
115
128
|
repair_performed: true,
|
|
116
129
|
backup_path: backupPath,
|
|
117
|
-
repair: { recovered, consumed, interrupted, requeued, ensured, projections },
|
|
130
|
+
repair: { archives, recovered, consumed, interrupted, requeued, ensured, projections },
|
|
118
131
|
};
|
|
119
132
|
}
|
|
120
133
|
|
|
@@ -11,15 +11,17 @@ const ops = require('./state-ops.cjs');
|
|
|
11
11
|
const runtime = require('./runtime-state.cjs');
|
|
12
12
|
const doctor = require('./doctor.cjs');
|
|
13
13
|
const baselines = require('./baseline-workflow.cjs');
|
|
14
|
+
const changes = require('./change-workflow.cjs');
|
|
15
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
14
16
|
|
|
15
|
-
function fixture({
|
|
17
|
+
function fixture({ baseline = true } = {}) {
|
|
16
18
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-doctor-'));
|
|
17
19
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
18
|
-
if (
|
|
20
|
+
if (baseline) {
|
|
19
21
|
db.prepare(
|
|
20
22
|
`INSERT INTO baselines
|
|
21
23
|
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
22
|
-
VALUES ('test-baseline', 'fixture', '
|
|
24
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
23
25
|
).run(new Date().toISOString());
|
|
24
26
|
}
|
|
25
27
|
return { rootDir, db };
|
|
@@ -44,7 +46,7 @@ test('doctor reports structured health for an initialized Ultra project', async
|
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
test('doctor reports incomplete brownfield adoption as advisory rather than authority failure', async () => {
|
|
47
|
-
const fx = fixture({
|
|
49
|
+
const fx = fixture({ baseline: false });
|
|
48
50
|
try {
|
|
49
51
|
baselines.startBaseline(fx.db, {
|
|
50
52
|
id: 'adoption', project_name: 'legacy', mode: 'brownfield', scope: ['.'],
|
|
@@ -53,6 +55,9 @@ test('doctor reports incomplete brownfield adoption as advisory rather than auth
|
|
|
53
55
|
assert.equal(report.status, 'healthy');
|
|
54
56
|
assert.equal(report.checks.baseline.status, 'warning');
|
|
55
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
|
+
});
|
|
56
61
|
assert.ok(report.checks.baseline.blockers.includes('BASELINE_NOT_READY:adopting'));
|
|
57
62
|
} finally {
|
|
58
63
|
cleanup(fx);
|
|
@@ -108,3 +113,36 @@ test('doctor reports running projection work and repairs only stale interrupted
|
|
|
108
113
|
cleanup(fx);
|
|
109
114
|
}
|
|
110
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
|
+
});
|