ultra-builder-pro-cli 0.11.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 +42 -1
- package/README.md +21 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +4 -3
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +2 -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 +16 -8
- package/docs/ARCHITECTURE.md +32 -5
- package/docs/PLAN.zh-CN.md +7 -5
- package/docs/ROADMAP.md +4 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- 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 +1 -1
- package/skills/ultra-change/SKILL.md +24 -13
- package/skills/ultra-deliver/SKILL.md +15 -5
- package/skills/ultra-dev/SKILL.md +4 -0
- package/skills/ultra-doctor/SKILL.md +8 -2
- package/skills/ultra-init/SKILL.md +46 -50
- package/skills/ultra-init/references/brownfield-adoption.md +65 -0
- package/skills/ultra-plan/SKILL.md +9 -2
- package/skills/ultra-research/SKILL.md +10 -5
- package/skills/ultra-status/SKILL.md +13 -6
- package/spec/cli-protocol.md +4 -0
- package/spec/mcp-tools.yaml +223 -4
- package/spec/schemas/mcp-tools.schema.json +1 -1
- 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
|
@@ -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 {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Implementation for MCP tool `task.init_project` and the CLI fallback
|
|
4
|
-
// `ultra-tools init`. Copies the bundled templates/.ultra/ skeleton
|
|
5
|
-
// target project root
|
|
4
|
+
// `ultra-tools task init-project`. Copies the bundled templates/.ultra/ skeleton
|
|
5
|
+
// to the target project root and records project metadata in authoritative state.
|
|
6
6
|
//
|
|
7
7
|
// Error contract matches spec/mcp-tools.yaml#task.init_project:
|
|
8
8
|
// ULTRA_DIR_EXISTS | TEMPLATE_MISSING | TARGET_NOT_DIR | IO_ERROR
|
|
@@ -15,6 +15,8 @@ const fs = require('node:fs');
|
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
17
|
const { initStateDb } = require('./state-db.cjs');
|
|
18
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
19
|
+
const runtimeState = require('./runtime-state.cjs');
|
|
18
20
|
|
|
19
21
|
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
20
22
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
@@ -95,20 +97,30 @@ function copyTemplate(source, dest) {
|
|
|
95
97
|
return files;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
function
|
|
100
|
+
function validateTasksTemplate(ultraDir) {
|
|
99
101
|
const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
|
|
100
102
|
if (!fs.existsSync(tasksFile)) return;
|
|
101
103
|
let data;
|
|
102
104
|
try { data = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); }
|
|
103
105
|
catch (err) { throw new InitProjectError('IO_ERROR', `tasks.json malformed: ${err.message}`, { cause: err }); }
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.tasks)) {
|
|
107
|
+
throw new InitProjectError('IO_ERROR', 'tasks.json template must contain a tasks array');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const IGNORED_ADOPTION_ENTRIES = new Set([
|
|
112
|
+
'.DS_Store', '.git', '.gitignore', '.ultra', 'node_modules', 'vendor', '.venv', 'venv',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
function detectProjectMode(absTarget, requested = 'auto') {
|
|
116
|
+
if (!['auto', 'greenfield', 'brownfield'].includes(requested)) {
|
|
117
|
+
throw new InitProjectError('VALIDATION_ERROR', `unsupported initialization mode: ${requested}`);
|
|
118
|
+
}
|
|
119
|
+
if (requested !== 'auto') return requested;
|
|
120
|
+
const meaningful = fs.readdirSync(absTarget, { withFileTypes: true })
|
|
121
|
+
.some((entry) => !IGNORED_ADOPTION_ENTRIES.has(entry.name)
|
|
122
|
+
&& !entry.name.startsWith('.ultra.backup.'));
|
|
123
|
+
return meaningful ? 'brownfield' : 'greenfield';
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
function rollbackInitialization(ultraDir, backupPath) {
|
|
@@ -130,6 +142,7 @@ function initProject({
|
|
|
130
142
|
project_name,
|
|
131
143
|
project_type,
|
|
132
144
|
stack,
|
|
145
|
+
mode = 'auto',
|
|
133
146
|
overwrite = false,
|
|
134
147
|
source_template,
|
|
135
148
|
} = {}) {
|
|
@@ -139,6 +152,7 @@ function initProject({
|
|
|
139
152
|
|
|
140
153
|
const absTarget = resolveTargetDir(target_dir);
|
|
141
154
|
ensureTargetDir(absTarget);
|
|
155
|
+
const resolvedMode = detectProjectMode(absTarget, mode);
|
|
142
156
|
const source = ensureTemplate(source_template);
|
|
143
157
|
const ultraDir = path.join(absTarget, '.ultra');
|
|
144
158
|
|
|
@@ -155,13 +169,27 @@ function initProject({
|
|
|
155
169
|
}
|
|
156
170
|
|
|
157
171
|
let copiedFiles;
|
|
172
|
+
let baselineResult;
|
|
158
173
|
try {
|
|
159
174
|
fs.mkdirSync(ultraDir, { recursive: true });
|
|
160
175
|
copiedFiles = copyTemplate(source, ultraDir);
|
|
161
|
-
|
|
176
|
+
validateTasksTemplate(ultraDir);
|
|
162
177
|
const stateDbPath = path.join(ultraDir, 'state.db');
|
|
163
178
|
const { db } = initStateDb(stateDbPath);
|
|
164
|
-
|
|
179
|
+
try {
|
|
180
|
+
baselineResult = baselines.startBaseline(db, {
|
|
181
|
+
id: 'project-baseline', project_name, project_type, stack,
|
|
182
|
+
mode: resolvedMode, repository_revision: baselines.gitHead(absTarget), scope: ['.'],
|
|
183
|
+
}, { rootDir: absTarget });
|
|
184
|
+
const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
|
|
185
|
+
const projection = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
|
|
186
|
+
const projected = projection.jobs.find((item) => item.id === job.id);
|
|
187
|
+
if (!projected || projected.status !== 'completed') {
|
|
188
|
+
throw new Error(projected?.error || 'initial projection did not complete');
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
db.close();
|
|
192
|
+
}
|
|
165
193
|
} catch (err) {
|
|
166
194
|
const rollbackErrors = rollbackInitialization(ultraDir, backupPath);
|
|
167
195
|
const rollbackNote = rollbackErrors.length > 0
|
|
@@ -179,10 +207,17 @@ function initProject({
|
|
|
179
207
|
created_path: ultraDir,
|
|
180
208
|
state_db_path: stateDbPath,
|
|
181
209
|
status,
|
|
210
|
+
mode: resolvedMode,
|
|
211
|
+
baseline: {
|
|
212
|
+
id: baselineResult.id,
|
|
213
|
+
mode: baselineResult.mode,
|
|
214
|
+
status: baselineResult.status,
|
|
215
|
+
repository_revision: baselineResult.repository_revision,
|
|
216
|
+
},
|
|
182
217
|
copied_files: copiedFiles,
|
|
183
218
|
};
|
|
184
219
|
if (backupPath) result.backup_path = backupPath;
|
|
185
220
|
return result;
|
|
186
221
|
}
|
|
187
222
|
|
|
188
|
-
module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE };
|
|
223
|
+
module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
|
|
4
|
+
const PROVIDER_FIELDS = new Set([
|
|
5
|
+
'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
class ProviderRefsError extends Error {
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ProviderRefsError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeProviderRefs(value, ErrorClass = ProviderRefsError) {
|
|
17
|
+
if (value == null) return {};
|
|
18
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
19
|
+
throw new ErrorClass('VALIDATION_ERROR', 'provider_refs must be an object');
|
|
20
|
+
}
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [kind, ref] of Object.entries(value)) {
|
|
23
|
+
if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
24
|
+
throw new ErrorClass('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
|
|
25
|
+
}
|
|
26
|
+
for (const key of Object.keys(ref)) {
|
|
27
|
+
if (!PROVIDER_FIELDS.has(key)) {
|
|
28
|
+
throw new ErrorClass(
|
|
29
|
+
'PROVIDER_CONTENT_FORBIDDEN',
|
|
30
|
+
`provider_refs.${kind}.${key} is not metadata; provider content remains external`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
|
|
35
|
+
throw new ErrorClass('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
|
|
36
|
+
}
|
|
37
|
+
out[kind] = { ...ref, provider: ref.provider.trim() };
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { ProviderRefsError, normalizeProviderRefs };
|
|
@@ -20,6 +20,11 @@ function fixture() {
|
|
|
20
20
|
execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
|
|
21
21
|
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
|
|
22
22
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
23
|
+
db.prepare(
|
|
24
|
+
`INSERT INTO baselines
|
|
25
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
26
|
+
VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
|
|
27
|
+
).run(new Date().toISOString());
|
|
23
28
|
changes.createChange(db, {
|
|
24
29
|
id: 'learning-change', title: 'Learning change', kind: 'quick',
|
|
25
30
|
intent: 'Exercise guarded specification-learning transitions.',
|
|
@@ -9,10 +9,11 @@ const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
|
9
9
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
10
10
|
: path.resolve(__dirname, '..', '..');
|
|
11
11
|
const SCHEMA_FILE = path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql');
|
|
12
|
-
const EXPECTED_VERSION = '
|
|
12
|
+
const EXPECTED_VERSION = '11.0';
|
|
13
13
|
const KIMI_SCHEMA_VERSION = '9.1';
|
|
14
14
|
|
|
15
15
|
const REQUIRED_TABLES = Object.freeze([
|
|
16
|
+
'baselines',
|
|
16
17
|
'tasks',
|
|
17
18
|
'events',
|
|
18
19
|
'sessions',
|
|
@@ -31,6 +32,43 @@ const REQUIRED_TABLES = Object.freeze([
|
|
|
31
32
|
'event_consumers',
|
|
32
33
|
]);
|
|
33
34
|
|
|
35
|
+
function applyBaselineUpgrade(db, fromVersion) {
|
|
36
|
+
db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS baselines (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
project_name TEXT NOT NULL,
|
|
40
|
+
project_type TEXT,
|
|
41
|
+
stack TEXT,
|
|
42
|
+
mode TEXT NOT NULL CHECK (mode IN ('greenfield', 'brownfield', 'migrated')),
|
|
43
|
+
status TEXT NOT NULL DEFAULT 'draft'
|
|
44
|
+
CHECK (status IN ('draft', 'adopting', 'blocked', 'ready', 'superseded')),
|
|
45
|
+
repository_root TEXT NOT NULL DEFAULT '.',
|
|
46
|
+
scope_json TEXT NOT NULL DEFAULT '["."]',
|
|
47
|
+
repository_revision TEXT,
|
|
48
|
+
spec_refs_json TEXT NOT NULL DEFAULT '[]',
|
|
49
|
+
evidence_json TEXT NOT NULL DEFAULT '[]',
|
|
50
|
+
verification_json TEXT NOT NULL DEFAULT '[]',
|
|
51
|
+
unknowns_json TEXT NOT NULL DEFAULT '[]',
|
|
52
|
+
provider_refs_json TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
approved_by TEXT,
|
|
54
|
+
approval_note TEXT,
|
|
55
|
+
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
56
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
57
|
+
converged_at TEXT
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS baselines_status ON baselines(status, updated_at);
|
|
60
|
+
`);
|
|
61
|
+
if (fromVersion && fromVersion !== EXPECTED_VERSION) {
|
|
62
|
+
db.prepare(
|
|
63
|
+
`INSERT OR IGNORE INTO baselines
|
|
64
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
65
|
+
VALUES ('migrated-baseline', 'Migrated Ultra project', 'migrated', 'ready',
|
|
66
|
+
'schema-migration', 'Legacy project preserved during schema 11.0 migration',
|
|
67
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
|
|
68
|
+
).run();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
function readSchemaSql() {
|
|
35
73
|
if (!fs.existsSync(SCHEMA_FILE)) {
|
|
36
74
|
throw new Error(`state-db schema missing at ${SCHEMA_FILE}`);
|
|
@@ -223,22 +261,43 @@ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
|
|
|
223
261
|
db.transaction(() => {
|
|
224
262
|
applyCompatibleColumns(db);
|
|
225
263
|
const contextChanged = applyContextSpineUpgrade(db);
|
|
226
|
-
|
|
264
|
+
const contextMigration = db.prepare(
|
|
265
|
+
"SELECT 1 FROM migration_history WHERE to_version = '10.0' AND status = 'success' LIMIT 1",
|
|
266
|
+
).get();
|
|
267
|
+
const needsContextMigration = Boolean(
|
|
268
|
+
fromVersion && !['10.0', EXPECTED_VERSION].includes(fromVersion) && !contextMigration,
|
|
269
|
+
);
|
|
270
|
+
if (contextChanged || needsContextMigration) {
|
|
227
271
|
db.prepare(
|
|
228
272
|
`INSERT INTO migration_history
|
|
229
273
|
(from_version, to_version, direction, status, notes)
|
|
230
274
|
VALUES (?, ?, 'forward', 'success', ?)`,
|
|
231
275
|
).run(
|
|
232
276
|
runtimeChanged ? KIMI_SCHEMA_VERSION : (fromVersion || 'unknown'),
|
|
233
|
-
|
|
277
|
+
'10.0',
|
|
234
278
|
'Add Context Spine role/gate readiness, execution contracts, breadcrumbs, and specification learning',
|
|
235
279
|
);
|
|
236
280
|
}
|
|
281
|
+
applyBaselineUpgrade(db, fromVersion);
|
|
282
|
+
const baselineMigration = db.prepare(
|
|
283
|
+
"SELECT 1 FROM migration_history WHERE to_version = '11.0' AND status = 'success' LIMIT 1",
|
|
284
|
+
).get();
|
|
285
|
+
if (fromVersion && fromVersion !== EXPECTED_VERSION && !baselineMigration) {
|
|
286
|
+
db.prepare(
|
|
287
|
+
`INSERT INTO migration_history
|
|
288
|
+
(from_version, to_version, direction, status, notes)
|
|
289
|
+
VALUES (?, ?, 'forward', 'success', ?)`,
|
|
290
|
+
).run(
|
|
291
|
+
fromVersion === '10.0' ? fromVersion : '10.0',
|
|
292
|
+
EXPECTED_VERSION,
|
|
293
|
+
'Add authoritative greenfield and brownfield baseline adoption, evidence, convergence, and drift state',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
237
296
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
|
|
238
297
|
db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
|
|
239
298
|
db.prepare(
|
|
240
299
|
'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
|
|
241
|
-
).run(EXPECTED_VERSION, '
|
|
300
|
+
).run(EXPECTED_VERSION, 'Greenfield and brownfield baseline adoption, convergence, and drift authority');
|
|
242
301
|
})();
|
|
243
302
|
}
|
|
244
303
|
|
|
@@ -123,7 +123,7 @@ test('initStateDb migrates existing runtime constraints to Kimi without losing r
|
|
|
123
123
|
const legacy = openStateDb(file);
|
|
124
124
|
const legacySchema = fs.readFileSync(SCHEMA_FILE, 'utf8').replaceAll(", 'kimi'", '');
|
|
125
125
|
legacy.exec(legacySchema);
|
|
126
|
-
legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0')").run();
|
|
126
|
+
legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0', '11.0')").run();
|
|
127
127
|
legacy.prepare(
|
|
128
128
|
"INSERT INTO tasks (id, title, type, priority) VALUES ('task-old', 'Old', 'feature', 'P1')",
|
|
129
129
|
).run();
|
|
@@ -146,16 +146,21 @@ test('initStateDb migrates existing runtime constraints to Kimi without losing r
|
|
|
146
146
|
closeStateDb(legacy);
|
|
147
147
|
|
|
148
148
|
const upgraded = initStateDb(file);
|
|
149
|
-
assert.equal(upgraded.schema_version, '
|
|
149
|
+
assert.equal(upgraded.schema_version, '11.0');
|
|
150
150
|
assert.equal(upgraded.db.prepare("SELECT runtime FROM sessions WHERE sid = 'session-old'").get().runtime, 'codex');
|
|
151
151
|
assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE session_id = 'session-old'").get().n, 1);
|
|
152
152
|
assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM incidents WHERE session_id = 'session-old'").get().n, 1);
|
|
153
153
|
assert.deepEqual(upgraded.db.pragma('foreign_key_check'), []);
|
|
154
154
|
const migrations = upgraded.db.prepare(
|
|
155
|
-
"SELECT to_version, notes FROM migration_history WHERE to_version IN ('9.1', '10.0') ORDER BY id",
|
|
155
|
+
"SELECT to_version, notes FROM migration_history WHERE to_version IN ('9.1', '10.0', '11.0') ORDER BY id",
|
|
156
156
|
).all();
|
|
157
157
|
assert.ok(migrations.some((row) => row.to_version === '9.1' && /Kimi/.test(row.notes)));
|
|
158
158
|
assert.ok(migrations.some((row) => row.to_version === '10.0' && /Context Spine/.test(row.notes)));
|
|
159
|
+
assert.ok(migrations.some((row) => row.to_version === '11.0' && /baseline adoption/i.test(row.notes)));
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
upgraded.db.prepare("SELECT mode, status FROM baselines WHERE id = 'migrated-baseline'").get(),
|
|
162
|
+
{ mode: 'migrated', status: 'ready' },
|
|
163
|
+
);
|
|
159
164
|
|
|
160
165
|
upgraded.db.prepare(
|
|
161
166
|
"INSERT INTO tasks (id, title, type, priority) VALUES ('task-kimi', 'Kimi', 'feature', 'P1')",
|