ultra-builder-pro-cli 0.10.0 → 0.12.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/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +34 -231
- 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 +86 -1
- package/README.md +21 -9
- package/adapters/_shared/codex-assets.cjs +24 -342
- package/adapters/_shared/handbook.cjs +4 -3
- package/adapters/_shared/runtime-assets.cjs +24 -0
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +11 -4
- package/adapters/claude.js +28 -5
- package/adapters/kimi.js +15 -138
- package/adapters/opencode.js +22 -49
- package/adapters/tests/claude.test.cjs +22 -8
- package/adapters/tests/codex.test.cjs +21 -8
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/kimi.test.cjs +11 -11
- package/adapters/tests/opencode.test.cjs +47 -8
- package/agents/code-reviewer.md +24 -85
- package/agents/debugger.md +30 -132
- package/agents/review-code.md +24 -104
- package/agents/review-comments.md +20 -83
- package/agents/review-coordinator.md +40 -137
- package/agents/review-design.md +21 -118
- package/agents/review-errors.md +22 -70
- package/agents/review-spec.md +36 -0
- package/agents/review-tests.md +20 -76
- package/agents/tdd-runner.md +26 -84
- package/commands/learn.md +5 -36
- package/commands/ultra-change.md +5 -43
- package/commands/ultra-deliver.md +4 -46
- package/commands/ultra-dev.md +4 -48
- package/commands/ultra-doctor.md +4 -30
- package/commands/ultra-init.md +5 -47
- package/commands/ultra-plan.md +5 -48
- package/commands/ultra-research.md +5 -54
- package/commands/ultra-status.md +5 -44
- package/commands/ultra-test.md +5 -45
- package/commands/ultra-think.md +5 -40
- package/docs/AGENT-CONTEXT.md +16 -8
- package/docs/ARCHITECTURE.md +32 -5
- package/docs/PLAN.zh-CN.md +8 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/SKILL-AUTHORING.md +75 -0
- package/docs/STATE-DB-ACCESS-POLICY.md +17 -11
- package/docs/USER-HANDBOOK-CONTRACT.md +8 -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 +120 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/baseline-workflow.cjs +415 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +225 -0
- package/mcp-server/lib/change-workflow.cjs +24 -27
- package/mcp-server/lib/change-workflow.test.cjs +202 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +15 -0
- package/mcp-server/lib/doctor.cjs +14 -0
- package/mcp-server/lib/doctor.test.cjs +25 -1
- package/mcp-server/lib/init-project.cjs +49 -14
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-db.cjs +63 -4
- package/mcp-server/lib/state-db.test.cjs +8 -3
- package/mcp-server/server.cjs +48 -3
- package/mcp-server/tests/init-project.test.cjs +54 -8
- package/mcp-server/tests/server-contract.test.cjs +99 -2
- package/mcp-server/tests/state-db.test.cjs +11 -2
- package/package.json +3 -2
- package/skills/cc-collab/SKILL.md +16 -18
- package/skills/code-review-expert/SKILL.md +39 -114
- package/skills/code-review-expert/references/correctness-reliability.md +30 -0
- package/skills/code-review-expert/references/design-boundaries.md +26 -0
- package/skills/code-review-expert/references/removal-plan.md +17 -45
- package/skills/codex-collab/SKILL.md +23 -28
- package/skills/codex-collab/evals/evals.json +18 -23
- package/skills/integration-rules/SKILL.md +28 -67
- package/skills/learn/SKILL.md +46 -106
- package/skills/security-rules/SKILL.md +34 -56
- package/skills/testing-rules/SKILL.md +28 -52
- package/skills/ultra-change/SKILL.md +68 -114
- package/skills/ultra-deliver/SKILL.md +58 -86
- package/skills/ultra-dev/SKILL.md +46 -105
- package/skills/ultra-doctor/SKILL.md +45 -72
- package/skills/ultra-init/SKILL.md +62 -147
- package/skills/ultra-init/references/brownfield-adoption.md +65 -0
- package/skills/ultra-plan/SKILL.md +56 -89
- package/skills/ultra-research/SKILL.md +64 -152
- package/skills/ultra-research/references/architecture.md +38 -0
- package/skills/ultra-research/references/problem-product.md +39 -0
- package/skills/ultra-research/references/quality-delivery.md +34 -0
- package/skills/ultra-research/references/synthesis.md +38 -0
- package/skills/ultra-research/references/users-scope.md +40 -0
- package/skills/ultra-review/SKILL.md +54 -98
- package/skills/ultra-review/references/unified-schema.md +76 -101
- package/skills/ultra-review/scripts/review_wait.py +242 -77
- package/skills/ultra-status/SKILL.md +41 -56
- package/skills/ultra-test/SKILL.md +37 -72
- package/skills/ultra-think/SKILL.md +43 -102
- package/skills/ultra-verify/SKILL.md +44 -84
- package/skills/ultra-verify/evals/evals.json +24 -27
- package/spec/cli-protocol.md +4 -0
- package/spec/mcp-tools.yaml +223 -4
- package/spec/schemas/command-manifest.schema.json +7 -25
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/skill-manifest.schema.json +9 -79
- package/spec/schemas/state-db.sql +33 -2
- package/ultra-tools/commands/status.cjs +20 -2
- package/ultra-tools/commands/task.cjs +4 -1
- package/ultra-tools/status-command.test.cjs +20 -0
- package/ultra-tools/system-command.test.cjs +5 -0
- package/skills/code-review-expert/references/code-quality-checklist.md +0 -112
- package/skills/code-review-expert/references/integration-checklist.md +0 -46
- package/skills/code-review-expert/references/security-checklist.md +0 -100
- package/skills/code-review-expert/references/solid-checklist.md +0 -65
- package/skills/ultra-research/steps/step-00-problem-validation.md +0 -154
- package/skills/ultra-research/steps/step-01-opportunity-discovery.md +0 -172
- package/skills/ultra-research/steps/step-02-market-assessment.md +0 -195
- package/skills/ultra-research/steps/step-03-competitive-landscape.md +0 -197
- package/skills/ultra-research/steps/step-04-product-strategy.md +0 -196
- package/skills/ultra-research/steps/step-05-assumptions-validation.md +0 -234
- package/skills/ultra-research/steps/step-10-user-personas.md +0 -169
- package/skills/ultra-research/steps/step-11-user-scenarios.md +0 -181
- package/skills/ultra-research/steps/step-20-user-stories.md +0 -156
- package/skills/ultra-research/steps/step-21-features-scope.md +0 -157
- package/skills/ultra-research/steps/step-22-success-metrics.md +0 -195
- package/skills/ultra-research/steps/step-30-architecture-context.md +0 -154
- package/skills/ultra-research/steps/step-31-solution-strategy.md +0 -172
- package/skills/ultra-research/steps/step-32-building-blocks.md +0 -188
- package/skills/ultra-research/steps/step-40-deployment.md +0 -123
- package/skills/ultra-research/steps/step-41-quality-risks.md +0 -135
- package/skills/ultra-research/steps/step-99-synthesis.md +0 -188
- package/skills/ultra-review/scripts/review_verdict_update.py +0 -99
|
@@ -8,12 +8,10 @@ const { spawnSync } = require('node:child_process');
|
|
|
8
8
|
const ops = require('./state-ops.cjs');
|
|
9
9
|
const contextSpine = require('./context-spine.cjs');
|
|
10
10
|
const specLearning = require('./spec-learning.cjs');
|
|
11
|
+
const providerRefs = require('./provider-refs.cjs');
|
|
12
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
11
13
|
|
|
12
14
|
const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
13
|
-
const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
|
|
14
|
-
const PROVIDER_FIELDS = new Set([
|
|
15
|
-
'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
|
|
16
|
-
]);
|
|
17
15
|
const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
|
|
18
16
|
const CHANGE_TRANSITIONS = Object.freeze({
|
|
19
17
|
active: new Set(['active', 'blocked', 'cancelled']),
|
|
@@ -83,29 +81,7 @@ function normalizeDocsImpact(value) {
|
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
function normalizeProviderRefs(value) {
|
|
86
|
-
|
|
87
|
-
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
88
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', 'provider_refs must be an object');
|
|
89
|
-
}
|
|
90
|
-
const out = {};
|
|
91
|
-
for (const [kind, ref] of Object.entries(value)) {
|
|
92
|
-
if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
93
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
|
|
94
|
-
}
|
|
95
|
-
for (const key of Object.keys(ref)) {
|
|
96
|
-
if (!PROVIDER_FIELDS.has(key)) {
|
|
97
|
-
throw new ChangeWorkflowError(
|
|
98
|
-
'PROVIDER_CONTENT_FORBIDDEN',
|
|
99
|
-
`provider_refs.${kind}.${key} is not metadata; provider content remains external`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
|
|
104
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
|
|
105
|
-
}
|
|
106
|
-
out[kind] = { ...ref };
|
|
107
|
-
}
|
|
108
|
-
return out;
|
|
84
|
+
return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
|
|
109
85
|
}
|
|
110
86
|
|
|
111
87
|
function gitHead(rootDir) {
|
|
@@ -254,6 +230,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
254
230
|
readiness: spine.readiness,
|
|
255
231
|
context: spine.context,
|
|
256
232
|
execution_contract: spine.execution_contract,
|
|
233
|
+
baseline: spine.baseline,
|
|
257
234
|
resume: spine.resume,
|
|
258
235
|
providers,
|
|
259
236
|
provider_boundary: 'metadata references only; memory and code graph content remain external',
|
|
@@ -504,6 +481,13 @@ function evidenceBlockers(change, evidence, tasks) {
|
|
|
504
481
|
return blockers;
|
|
505
482
|
}
|
|
506
483
|
|
|
484
|
+
function baselineConvergenceBlockers(db) {
|
|
485
|
+
const baseline = baselines.readBaseline(db);
|
|
486
|
+
if (!baseline) return ['BASELINE_MISSING'];
|
|
487
|
+
if (baseline.status !== 'ready') return [`BASELINE_NOT_READY:${baseline.status}`];
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
|
|
507
491
|
function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
508
492
|
const change = readChange(db, input.id);
|
|
509
493
|
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
|
|
@@ -512,6 +496,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
512
496
|
}
|
|
513
497
|
const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
|
|
514
498
|
const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
|
|
499
|
+
for (const blocker of baselineConvergenceBlockers(db)) blockers.add(blocker);
|
|
515
500
|
if (tasks.length === 0) blockers.add('NO_TASKS');
|
|
516
501
|
if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
|
|
517
502
|
if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
|
|
@@ -671,6 +656,18 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
671
656
|
});
|
|
672
657
|
db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
|
|
673
658
|
.run(nowIso(), change.id);
|
|
659
|
+
baselines.reconcileBaseline(db, {
|
|
660
|
+
baseline_updates: updates,
|
|
661
|
+
change_id: change.id,
|
|
662
|
+
}, { rootDir });
|
|
663
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
664
|
+
if (baselineHealth.blockers.length > 0) {
|
|
665
|
+
throw new ChangeWorkflowError(
|
|
666
|
+
'BASELINE_RECONCILIATION_INCOMPLETE',
|
|
667
|
+
`baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
|
|
668
|
+
{ blockers: baselineHealth.blockers },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
674
671
|
ops.appendEventInTx(db, {
|
|
675
672
|
type: 'change_archived', change_id: change.id,
|
|
676
673
|
payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
|
|
@@ -10,6 +10,7 @@ const { execFileSync } = require('node:child_process');
|
|
|
10
10
|
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
11
11
|
const ops = require('./state-ops.cjs');
|
|
12
12
|
const changes = require('./change-workflow.cjs');
|
|
13
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
13
14
|
|
|
14
15
|
function fixture() {
|
|
15
16
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-change-'));
|
|
@@ -20,6 +21,11 @@ function fixture() {
|
|
|
20
21
|
execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
|
|
21
22
|
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
|
|
22
23
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
24
|
+
db.prepare(
|
|
25
|
+
`INSERT INTO baselines
|
|
26
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
27
|
+
VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
|
|
28
|
+
).run(new Date().toISOString());
|
|
23
29
|
return { rootDir, db };
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -179,14 +185,82 @@ test('convergeChange blocks incomplete work and marks a fully evidenced standard
|
|
|
179
185
|
}
|
|
180
186
|
});
|
|
181
187
|
|
|
188
|
+
test('convergeChange requires an approved baseline after active work has remained executable', () => {
|
|
189
|
+
const fx = fixture();
|
|
190
|
+
try {
|
|
191
|
+
changes.createChange(fx.db, {
|
|
192
|
+
id: 'chg-baseline-gate', title: 'Keep runtime work moving', kind: 'quick',
|
|
193
|
+
intent: 'Defer baseline closure to convergence without losing implementation progress.',
|
|
194
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal workflow behavior only.' },
|
|
195
|
+
}, { rootDir: fx.rootDir });
|
|
196
|
+
ops.createTask(fx.db, {
|
|
197
|
+
id: 'baseline-gate-task', title: 'Exercise convergence boundary', type: 'feature', priority: 'P0',
|
|
198
|
+
change_id: 'chg-baseline-gate',
|
|
199
|
+
});
|
|
200
|
+
ops.updateTaskStatus(fx.db, 'baseline-gate-task', 'in_progress');
|
|
201
|
+
ops.updateTaskStatus(fx.db, 'baseline-gate-task', 'completed');
|
|
202
|
+
changes.compileContext(fx.db, {
|
|
203
|
+
id: 'chg-baseline-gate', ...executionContext('baseline-gate-task'),
|
|
204
|
+
}, { rootDir: fx.rootDir });
|
|
205
|
+
fx.db.prepare('DELETE FROM baselines').run();
|
|
206
|
+
|
|
207
|
+
const result = changes.convergeChange(fx.db, {
|
|
208
|
+
id: 'chg-baseline-gate',
|
|
209
|
+
evidence: [
|
|
210
|
+
{ category: 'diff', status: 'pass', evidence: 'diff reviewed' },
|
|
211
|
+
{ category: 'tests', status: 'pass', evidence: 'workflow regression passed', seam: 'convergence gate' },
|
|
212
|
+
{ category: 'spec', status: 'pass', evidence: 'change remains aligned' },
|
|
213
|
+
],
|
|
214
|
+
}, { rootDir: fx.rootDir });
|
|
215
|
+
|
|
216
|
+
assert.equal(result.ready, false);
|
|
217
|
+
assert.ok(result.blockers.includes('BASELINE_MISSING'));
|
|
218
|
+
} finally {
|
|
219
|
+
cleanup(fx);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
182
223
|
test('archiveChange moves a ready change into immutable history and records baseline reconciliation', () => {
|
|
183
224
|
const fx = fixture();
|
|
184
225
|
try {
|
|
226
|
+
fs.mkdirSync(path.join(fx.rootDir, '.ultra', 'specs'), { recursive: true });
|
|
227
|
+
fs.writeFileSync(path.join(fx.rootDir, '.ultra', 'specs', 'product.md'), '# Product\n\nCurrent behavior.\n');
|
|
228
|
+
fs.writeFileSync(path.join(fx.rootDir, '.ultra', 'specs', 'architecture.md'), '# Architecture\n\nCurrent boundary.\n');
|
|
229
|
+
const fileDigest = (file) => require('node:crypto').createHash('sha256')
|
|
230
|
+
.update(fs.readFileSync(file)).digest('hex');
|
|
231
|
+
fx.db.prepare(
|
|
232
|
+
`UPDATE baselines SET mode = 'brownfield', repository_revision = ?, spec_refs_json = ?
|
|
233
|
+
WHERE id = 'test-baseline'`,
|
|
234
|
+
).run(
|
|
235
|
+
execFileSync('git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' }).trim(),
|
|
236
|
+
JSON.stringify([
|
|
237
|
+
{
|
|
238
|
+
kind: 'product', path: '.ultra/specs/product.md',
|
|
239
|
+
digest: fileDigest(path.join(fx.rootDir, '.ultra', 'specs', 'product.md')),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
kind: 'architecture', path: '.ultra/specs/architecture.md',
|
|
243
|
+
digest: fileDigest(path.join(fx.rootDir, '.ultra', 'specs', 'architecture.md')),
|
|
244
|
+
},
|
|
245
|
+
]),
|
|
246
|
+
);
|
|
185
247
|
changes.createChange(fx.db, {
|
|
186
248
|
id: 'chg-archive', title: 'Archive verified change', kind: 'quick',
|
|
187
249
|
intent: 'Preserve the completed change as project history.',
|
|
188
250
|
docs_impact: { status: 'none', files: [], rationale: 'Internal test-only behavior.' },
|
|
189
251
|
}, { rootDir: fx.rootDir });
|
|
252
|
+
fs.appendFileSync(
|
|
253
|
+
path.join(fx.rootDir, '.ultra', 'specs', 'product.md'),
|
|
254
|
+
'\nBehavior learned and approved by this change.\n',
|
|
255
|
+
);
|
|
256
|
+
execFileSync('git', ['add', '.ultra/specs/product.md'], { cwd: fx.rootDir });
|
|
257
|
+
execFileSync('git', ['commit', '-q', '-m', 'update baseline behavior'], { cwd: fx.rootDir });
|
|
258
|
+
const changedHead = execFileSync(
|
|
259
|
+
'git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' },
|
|
260
|
+
).trim();
|
|
261
|
+
const drift = baselines.inspectBaseline(fx.db, { rootDir: fx.rootDir });
|
|
262
|
+
assert.ok(drift.blockers.includes('BASELINE_HEAD_STALE'));
|
|
263
|
+
assert.ok(drift.blockers.includes('BASELINE_SPEC_STALE:.ultra/specs/product.md'));
|
|
190
264
|
ops.createTask(fx.db, {
|
|
191
265
|
id: 'archive-task', title: 'Complete archive path', type: 'bugfix', priority: 'P1',
|
|
192
266
|
change_id: 'chg-archive',
|
|
@@ -214,7 +288,7 @@ test('archiveChange moves a ready change into immutable history and records base
|
|
|
214
288
|
|
|
215
289
|
const archived = changes.archiveChange(fx.db, {
|
|
216
290
|
id: 'chg-archive', summary: 'Verified quick fix archived.',
|
|
217
|
-
|
|
291
|
+
baseline_updates: ['.ultra/specs/product.md'],
|
|
218
292
|
}, { rootDir: fx.rootDir });
|
|
219
293
|
assert.equal(archived.change.status, 'archived');
|
|
220
294
|
assert.ok(fs.existsSync(archived.archive_path));
|
|
@@ -228,6 +302,84 @@ test('archiveChange moves a ready change into immutable history and records base
|
|
|
228
302
|
assert.doesNotMatch(artifact.path, /changes\/active/);
|
|
229
303
|
assert.ok(fs.existsSync(path.join(fx.rootDir, artifact.path)), artifact.path);
|
|
230
304
|
}
|
|
305
|
+
const reconciliation = fx.db.prepare(
|
|
306
|
+
"SELECT type, payload_json FROM events WHERE type = 'baseline_reconciled' ORDER BY id DESC LIMIT 1",
|
|
307
|
+
).get();
|
|
308
|
+
assert.equal(reconciliation.type, 'baseline_reconciled');
|
|
309
|
+
assert.deepEqual(JSON.parse(reconciliation.payload_json).baseline_updates, ['.ultra/specs/product.md']);
|
|
310
|
+
const reconciled = baselines.inspectBaseline(fx.db, { rootDir: fx.rootDir });
|
|
311
|
+
assert.equal(reconciled.status, 'pass');
|
|
312
|
+
assert.equal(reconciled.baseline.repository_revision, changedHead);
|
|
313
|
+
} finally {
|
|
314
|
+
cleanup(fx);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('archiveChange rolls back when declared reconciliation leaves a tracked specification stale', () => {
|
|
319
|
+
const fx = fixture();
|
|
320
|
+
try {
|
|
321
|
+
const specPath = path.join(fx.rootDir, '.ultra', 'specs', 'product.md');
|
|
322
|
+
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
|
323
|
+
fs.writeFileSync(specPath, '# Product\n\nOriginal behavior.\n');
|
|
324
|
+
const originalHead = execFileSync(
|
|
325
|
+
'git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' },
|
|
326
|
+
).trim();
|
|
327
|
+
const originalDigest = require('node:crypto').createHash('sha256')
|
|
328
|
+
.update(fs.readFileSync(specPath)).digest('hex');
|
|
329
|
+
fx.db.prepare(
|
|
330
|
+
`UPDATE baselines SET mode = 'brownfield', repository_revision = ?, spec_refs_json = ?
|
|
331
|
+
WHERE id = 'test-baseline'`,
|
|
332
|
+
).run(originalHead, JSON.stringify([
|
|
333
|
+
{ kind: 'product', path: '.ultra/specs/product.md', digest: originalDigest },
|
|
334
|
+
]));
|
|
335
|
+
changes.createChange(fx.db, {
|
|
336
|
+
id: 'chg-reconcile-rollback', title: 'Reject incomplete reconciliation', kind: 'quick',
|
|
337
|
+
intent: 'Keep the active change recoverable until every tracked specification is reconciled.',
|
|
338
|
+
docs_impact: { status: 'none', files: [], rationale: 'Workflow integrity only.' },
|
|
339
|
+
}, { rootDir: fx.rootDir });
|
|
340
|
+
fs.appendFileSync(specPath, '\nChanged behavior that must be declared.\n');
|
|
341
|
+
execFileSync('git', ['add', '.ultra/specs/product.md'], { cwd: fx.rootDir });
|
|
342
|
+
execFileSync('git', ['commit', '-q', '-m', 'change tracked product spec'], { cwd: fx.rootDir });
|
|
343
|
+
ops.createTask(fx.db, {
|
|
344
|
+
id: 'reconcile-rollback-task', title: 'Exercise archive rollback', type: 'feature', priority: 'P0',
|
|
345
|
+
change_id: 'chg-reconcile-rollback',
|
|
346
|
+
});
|
|
347
|
+
ops.updateTaskStatus(fx.db, 'reconcile-rollback-task', 'in_progress');
|
|
348
|
+
ops.updateTaskStatus(fx.db, 'reconcile-rollback-task', 'completed');
|
|
349
|
+
changes.compileContext(fx.db, {
|
|
350
|
+
id: 'chg-reconcile-rollback', ...executionContext('reconcile-rollback-task'),
|
|
351
|
+
}, { rootDir: fx.rootDir });
|
|
352
|
+
const converged = changes.convergeChange(fx.db, {
|
|
353
|
+
id: 'chg-reconcile-rollback',
|
|
354
|
+
evidence: [
|
|
355
|
+
{ category: 'diff', status: 'pass', evidence: 'archive diff reviewed' },
|
|
356
|
+
{
|
|
357
|
+
category: 'tests', status: 'pass', evidence: 'archive rollback regression passed',
|
|
358
|
+
seam: 'archive reconciliation boundary',
|
|
359
|
+
},
|
|
360
|
+
{ category: 'spec', status: 'pass', evidence: 'tracked specification change identified' },
|
|
361
|
+
],
|
|
362
|
+
}, { rootDir: fx.rootDir });
|
|
363
|
+
assert.equal(converged.ready, true);
|
|
364
|
+
|
|
365
|
+
assert.throws(
|
|
366
|
+
() => changes.archiveChange(fx.db, {
|
|
367
|
+
id: 'chg-reconcile-rollback', summary: 'Attempt an incomplete archive.',
|
|
368
|
+
no_baseline_change_reason: 'No baseline update declared.',
|
|
369
|
+
}, { rootDir: fx.rootDir }),
|
|
370
|
+
(error) => error.code === 'BASELINE_RECONCILIATION_INCOMPLETE'
|
|
371
|
+
&& error.details.blockers.includes('BASELINE_SPEC_STALE:.ultra/specs/product.md'),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const active = changes.readChange(fx.db, 'chg-reconcile-rollback');
|
|
375
|
+
assert.equal(active.status, 'ready');
|
|
376
|
+
assert.ok(fs.existsSync(path.join(fx.rootDir, active.artifact_root)));
|
|
377
|
+
assert.equal(fs.existsSync(path.join(fx.rootDir, active.artifact_root, 'archive-summary.md')), false);
|
|
378
|
+
assert.equal(baselines.readBaseline(fx.db).repository_revision, originalHead);
|
|
379
|
+
const reconciliationEvents = fx.db.prepare(
|
|
380
|
+
"SELECT COUNT(*) AS count FROM events WHERE type = 'baseline_reconciled' AND change_id = ?",
|
|
381
|
+
).get('chg-reconcile-rollback');
|
|
382
|
+
assert.equal(reconciliationEvents.count, 0);
|
|
231
383
|
} finally {
|
|
232
384
|
cleanup(fx);
|
|
233
385
|
}
|
|
@@ -447,12 +599,12 @@ test('compileContext v2 persists role-scoped context, budget, execution contract
|
|
|
447
599
|
}
|
|
448
600
|
});
|
|
449
601
|
|
|
450
|
-
test('compileContext blocks a required missing ref
|
|
602
|
+
test('compileContext blocks a required missing ref', () => {
|
|
451
603
|
const fx = fixture();
|
|
452
604
|
try {
|
|
453
605
|
changes.createChange(fx.db, {
|
|
454
606
|
id: 'chg-context-budget', title: 'Bound context packet', kind: 'quick',
|
|
455
|
-
intent: 'Fail closed when required context is missing
|
|
607
|
+
intent: 'Fail closed when required context is missing while reporting size pressure.',
|
|
456
608
|
}, { rootDir: fx.rootDir });
|
|
457
609
|
ops.createTask(fx.db, {
|
|
458
610
|
id: 'budget-task', title: 'Compile bounded packet', type: 'bugfix', priority: 'P0',
|
|
@@ -466,14 +618,58 @@ test('compileContext blocks a required missing ref or an over-budget role packet
|
|
|
466
618
|
{ ref: 'missing-contract.md', kind: 'spec', reason: 'Required contract', required: true },
|
|
467
619
|
{ ref: 'README.md', kind: 'source', reason: 'Current implementation', required: true },
|
|
468
620
|
],
|
|
469
|
-
budget: { max_tokens:
|
|
621
|
+
budget: { max_tokens: 2_000, max_files: 4 },
|
|
470
622
|
}),
|
|
471
623
|
}, { rootDir: fx.rootDir });
|
|
472
624
|
|
|
473
625
|
assert.equal(compiled.manifest.readiness.status, 'blocked');
|
|
474
626
|
assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_REQUIRED_REF_MISSING:missing-contract.md'));
|
|
475
|
-
|
|
476
|
-
|
|
627
|
+
} finally {
|
|
628
|
+
cleanup(fx);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('an incident can proceed with four necessary large files while context budgets remain advisory', () => {
|
|
633
|
+
const fx = fixture();
|
|
634
|
+
try {
|
|
635
|
+
const refs = [];
|
|
636
|
+
for (let index = 1; index <= 4; index += 1) {
|
|
637
|
+
const ref = `incident-runtime-${index}.cjs`;
|
|
638
|
+
fs.writeFileSync(path.join(fx.rootDir, ref), 'x'.repeat(8_000));
|
|
639
|
+
refs.push({
|
|
640
|
+
ref, kind: 'source', reason: `Required incident runtime boundary ${index}`, required: true,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
changes.createChange(fx.db, {
|
|
644
|
+
id: 'chg-incident-context', title: 'Recover IM runtime', kind: 'incident',
|
|
645
|
+
intent: 'Keep an urgent root-cause fix executable when its required files exceed guidance.',
|
|
646
|
+
}, { rootDir: fx.rootDir });
|
|
647
|
+
ops.createTask(fx.db, {
|
|
648
|
+
id: 'incident-context-task', title: 'Repair IM terminal transition', type: 'bugfix', priority: 'P0',
|
|
649
|
+
change_id: 'chg-incident-context',
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const compiled = changes.compileContext(fx.db, {
|
|
653
|
+
id: 'chg-incident-context',
|
|
654
|
+
...executionContext('incident-context-task', {
|
|
655
|
+
context_refs: refs,
|
|
656
|
+
budget: { max_tokens: 100, max_files: 2 },
|
|
657
|
+
execution_contract: {
|
|
658
|
+
slice_kind: 'tracer_bullet',
|
|
659
|
+
public_seam: 'IM terminal transition',
|
|
660
|
+
verification_command: 'node --test im-terminal.test.cjs',
|
|
661
|
+
context_budget_percent: 80,
|
|
662
|
+
},
|
|
663
|
+
}),
|
|
664
|
+
}, { rootDir: fx.rootDir });
|
|
665
|
+
|
|
666
|
+
assert.equal(compiled.manifest.readiness.status, 'ready');
|
|
667
|
+
assert.deepEqual(compiled.manifest.readiness.blockers, []);
|
|
668
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('CONTEXT_FILE_BUDGET_EXCEEDED'));
|
|
669
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('CONTEXT_TOKEN_BUDGET_EXCEEDED'));
|
|
670
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('EXECUTION_CONTEXT_BUDGET_ADVISORY'));
|
|
671
|
+
assert.equal(compiled.manifest.context.file_count, 4);
|
|
672
|
+
assert.ok(compiled.manifest.context.token_estimate > 100);
|
|
477
673
|
} finally {
|
|
478
674
|
cleanup(fx);
|
|
479
675
|
}
|
|
@@ -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' };
|
|
@@ -8,6 +8,7 @@ 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');
|
|
11
12
|
|
|
12
13
|
const SPEC_CONSUMER = 'spec-staleness';
|
|
13
14
|
|
|
@@ -48,6 +49,9 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
48
49
|
WHERE status IN ('active', 'blocked', 'ready') AND artifact_root IS NOT NULL`,
|
|
49
50
|
).all().filter((row) => row.artifact_root && !fs.existsSync(path.resolve(rootDir, row.artifact_root))).length
|
|
50
51
|
: 0;
|
|
52
|
+
const baseline = missing.length === 0
|
|
53
|
+
? baselines.inspectBaseline(db, { rootDir })
|
|
54
|
+
: { status: 'fail', blockers: ['BASELINE_STATE_UNAVAILABLE'], warnings: [], baseline: null };
|
|
51
55
|
const degraded = integrity !== 'ok' || missing.length > 0 || incidents.length > 0
|
|
52
56
|
|| pending.length > 0 || running.length > 0 || failed.length > 0 || orphanSessions > 0 || activeMissing > 0
|
|
53
57
|
|| eventCursor > projectedCursor;
|
|
@@ -63,6 +67,16 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
|
|
|
63
67
|
},
|
|
64
68
|
sessions: { status: orphanSessions === 0 ? 'pass' : 'fail', orphan: orphanSessions },
|
|
65
69
|
change_artifacts: { status: activeMissing === 0 ? 'pass' : 'fail', missing: activeMissing },
|
|
70
|
+
baseline: {
|
|
71
|
+
status: baseline.status === 'pass' ? 'pass' : 'warning',
|
|
72
|
+
readiness: baseline.status,
|
|
73
|
+
mode: baseline.baseline?.mode || null,
|
|
74
|
+
baseline_status: baseline.baseline?.status || null,
|
|
75
|
+
id: baseline.baseline?.id || null,
|
|
76
|
+
repository_revision: baseline.baseline?.repository_revision || null,
|
|
77
|
+
blockers: baseline.blockers,
|
|
78
|
+
warnings: baseline.warnings,
|
|
79
|
+
},
|
|
66
80
|
external_providers: {
|
|
67
81
|
status: 'pass', ownership: 'external',
|
|
68
82
|
note: 'Ultra stores provider metadata references only and never owns memory or code-graph content.',
|
|
@@ -10,10 +10,18 @@ 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');
|
|
13
14
|
|
|
14
|
-
function fixture() {
|
|
15
|
+
function fixture({ migratedBaseline = true } = {}) {
|
|
15
16
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-doctor-'));
|
|
16
17
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
18
|
+
if (migratedBaseline) {
|
|
19
|
+
db.prepare(
|
|
20
|
+
`INSERT INTO baselines
|
|
21
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
22
|
+
VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
|
|
23
|
+
).run(new Date().toISOString());
|
|
24
|
+
}
|
|
17
25
|
return { rootDir, db };
|
|
18
26
|
}
|
|
19
27
|
|
|
@@ -35,6 +43,22 @@ test('doctor reports structured health for an initialized Ultra project', async
|
|
|
35
43
|
}
|
|
36
44
|
});
|
|
37
45
|
|
|
46
|
+
test('doctor reports incomplete brownfield adoption as advisory rather than authority failure', async () => {
|
|
47
|
+
const fx = fixture({ migratedBaseline: false });
|
|
48
|
+
try {
|
|
49
|
+
baselines.startBaseline(fx.db, {
|
|
50
|
+
id: 'adoption', project_name: 'legacy', mode: 'brownfield', scope: ['.'],
|
|
51
|
+
}, { rootDir: fx.rootDir, emitEvent: false });
|
|
52
|
+
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
53
|
+
assert.equal(report.status, 'healthy');
|
|
54
|
+
assert.equal(report.checks.baseline.status, 'warning');
|
|
55
|
+
assert.equal(report.checks.baseline.mode, 'brownfield');
|
|
56
|
+
assert.ok(report.checks.baseline.blockers.includes('BASELINE_NOT_READY:adopting'));
|
|
57
|
+
} finally {
|
|
58
|
+
cleanup(fx);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
38
62
|
test('doctor repair is backup-first and drains projection/spec-event recovery work', async () => {
|
|
39
63
|
const fx = fixture();
|
|
40
64
|
try {
|