ultra-builder-pro-cli 0.8.1 → 0.10.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +79 -1
  2. package/README.md +45 -26
  3. package/adapters/_shared/codex-assets.cjs +38 -0
  4. package/adapters/_shared/handbook.cjs +10 -1
  5. package/adapters/_shared/path-rewrite.cjs +3 -0
  6. package/adapters/_shared/runtime-assets.cjs +3 -1
  7. package/adapters/_shared/tests/handbook.test.cjs +9 -1
  8. package/adapters/_shared/tests/path-rewrite.test.cjs +3 -1
  9. package/adapters/_shared/tests/runtime-assets.test.cjs +5 -1
  10. package/adapters/kimi.js +642 -0
  11. package/adapters/opencode.js +46 -17
  12. package/adapters/tests/claude.test.cjs +4 -1
  13. package/adapters/tests/codex.test.cjs +4 -3
  14. package/adapters/tests/kimi-hook.test.cjs +122 -0
  15. package/adapters/tests/kimi.test.cjs +259 -0
  16. package/adapters/tests/opencode.test.cjs +46 -3
  17. package/bin/handbook.js +2 -2
  18. package/bin/install.js +9 -4
  19. package/bin/orchestrator.js +1 -1
  20. package/docs/AGENT-CONTEXT.md +39 -15
  21. package/docs/ARCHITECTURE.md +35 -9
  22. package/docs/PLAN.zh-CN.md +5 -4
  23. package/docs/ROADMAP.md +8 -7
  24. package/docs/RUNTIME-COMPAT-MATRIX.md +110 -64
  25. package/docs/USER-HANDBOOK-CONTRACT.md +13 -3
  26. package/hooks/README.md +6 -4
  27. package/hooks/active_task_context.py +14 -0
  28. package/hooks/adapters/kimi.py +162 -0
  29. package/hooks/context_spine.py +218 -0
  30. package/hooks/health_check.py +1 -1
  31. package/hooks/workflow_context.py +16 -123
  32. package/hooks/workflow_resume.py +21 -0
  33. package/mcp-server/lib/change-workflow.cjs +89 -11
  34. package/mcp-server/lib/change-workflow.test.cjs +202 -7
  35. package/mcp-server/lib/context-spine.cjs +331 -0
  36. package/mcp-server/lib/context-spine.test.cjs +73 -0
  37. package/mcp-server/lib/spec-learning.cjs +191 -0
  38. package/mcp-server/lib/spec-learning.test.cjs +62 -0
  39. package/mcp-server/lib/state-db.cjs +149 -4
  40. package/mcp-server/lib/state-db.test.cjs +61 -0
  41. package/mcp-server/server.cjs +31 -1
  42. package/mcp-server/tests/db-ops.test.cjs +3 -3
  43. package/mcp-server/tests/init-project.test.cjs +2 -2
  44. package/mcp-server/tests/server-contract.test.cjs +74 -0
  45. package/mcp-server/tests/state-db.test.cjs +20 -3
  46. package/package.json +4 -2
  47. package/skills/learn/SKILL.md +1 -0
  48. package/skills/ultra-change/SKILL.md +91 -118
  49. package/skills/ultra-deliver/SKILL.md +93 -221
  50. package/skills/ultra-dev/SKILL.md +81 -310
  51. package/skills/ultra-doctor/SKILL.md +1 -0
  52. package/skills/ultra-init/SKILL.md +1 -0
  53. package/skills/ultra-plan/SKILL.md +72 -292
  54. package/skills/ultra-review/SKILL.md +79 -407
  55. package/skills/ultra-status/SKILL.md +55 -168
  56. package/skills/ultra-test/SKILL.md +64 -183
  57. package/skills/ultra-think/SKILL.md +1 -0
  58. package/spec/cli-protocol.md +3 -0
  59. package/spec/mcp-tools.yaml +136 -7
  60. package/spec/schemas/skill-manifest.schema.json +2 -2
  61. package/spec/schemas/state-db.sql +35 -2
@@ -9,7 +9,8 @@ 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 = '9.0';
12
+ const EXPECTED_VERSION = '10.0';
13
+ const KIMI_SCHEMA_VERSION = '9.1';
13
14
 
14
15
  const REQUIRED_TABLES = Object.freeze([
15
16
  'tasks',
@@ -23,6 +24,7 @@ const REQUIRED_TABLES = Object.freeze([
23
24
  'changes',
24
25
  'artifacts',
25
26
  'context_snapshots',
27
+ 'spec_learning_candidates',
26
28
  'trace_links',
27
29
  'incidents',
28
30
  'projection_jobs',
@@ -71,6 +73,13 @@ function columnNames(db, table) {
71
73
  return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
72
74
  }
73
75
 
76
+ function latestSchemaVersion(db) {
77
+ if (!tableNames(db).includes('schema_version')) return null;
78
+ return db.prepare(
79
+ 'SELECT version FROM schema_version ORDER BY applied_at DESC, rowid DESC LIMIT 1',
80
+ ).get()?.version || null;
81
+ }
82
+
74
83
  function applyCompatibleColumns(db) {
75
84
  const tables = new Set(tableNames(db));
76
85
  if (!tables.has('tasks')) return;
@@ -87,14 +96,149 @@ function applyCompatibleColumns(db) {
87
96
  }
88
97
  }
89
98
 
90
- function applyCompatibleUpgrades(db) {
99
+ function applyContextSpineUpgrade(db) {
100
+ const tables = new Set(tableNames(db));
101
+ if (!tables.has('context_snapshots')) return false;
102
+ let changed = !tables.has('spec_learning_candidates');
103
+ const columns = columnNames(db, 'context_snapshots');
104
+ const additions = [
105
+ ['role', "TEXT NOT NULL DEFAULT 'plan' CHECK (role IN ('plan', 'implement', 'check', 'review'))"],
106
+ ['gate', "TEXT NOT NULL DEFAULT 'alignment' CHECK (gate IN ('alignment', 'planning', 'implementation', 'verification', 'review', 'convergence', 'recovery'))"],
107
+ ['next_action', "TEXT NOT NULL DEFAULT 'Resolve the next Ultra workflow action.'"],
108
+ ['readiness', "TEXT NOT NULL DEFAULT 'ready' CHECK (readiness IN ('ready', 'blocked'))"],
109
+ ['blockers_json', "TEXT NOT NULL DEFAULT '[]'"],
110
+ ['context_json', "TEXT NOT NULL DEFAULT '{}'"],
111
+ ['token_estimate', 'INTEGER NOT NULL DEFAULT 0 CHECK (token_estimate >= 0)'],
112
+ ['token_budget', 'INTEGER NOT NULL DEFAULT 12000 CHECK (token_budget > 0)'],
113
+ ];
114
+ for (const [name, definition] of additions) {
115
+ if (!columns.has(name)) {
116
+ db.exec(`ALTER TABLE context_snapshots ADD COLUMN ${name} ${definition}`);
117
+ changed = true;
118
+ }
119
+ }
120
+ db.exec(`
121
+ CREATE TABLE IF NOT EXISTS spec_learning_candidates (
122
+ id TEXT PRIMARY KEY,
123
+ change_id TEXT NOT NULL REFERENCES changes(id) ON DELETE CASCADE,
124
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
125
+ target_ref TEXT NOT NULL,
126
+ summary TEXT NOT NULL,
127
+ evidence_json TEXT NOT NULL DEFAULT '[]',
128
+ status TEXT NOT NULL DEFAULT 'proposed'
129
+ CHECK (status IN ('proposed', 'approved', 'rejected', 'applied')),
130
+ resolution TEXT,
131
+ proposed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
132
+ resolved_at TEXT,
133
+ applied_at TEXT
134
+ );
135
+ CREATE INDEX IF NOT EXISTS spec_learning_change
136
+ ON spec_learning_candidates(change_id, status, proposed_at);
137
+ `);
138
+ return changed;
139
+ }
140
+
141
+ function tableSupportsKimi(db, table) {
142
+ const row = db.prepare(
143
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
144
+ ).get(table);
145
+ return typeof row?.sql === 'string' && row.sql.includes("'kimi'");
146
+ }
147
+
148
+ function upgradeRuntimeConstraints(db, fromVersion = latestSchemaVersion(db)) {
149
+ if (tableSupportsKimi(db, 'events') && tableSupportsKimi(db, 'sessions')) return false;
150
+ const previousVersion = fromVersion || 'unknown';
151
+ const foreignKeys = db.pragma('foreign_keys', { simple: true });
152
+ db.pragma('foreign_keys = OFF');
153
+ try {
154
+ db.transaction(() => {
155
+ db.exec(`
156
+ DROP TABLE IF EXISTS events_runtime_upgrade;
157
+ CREATE TABLE events_runtime_upgrade (
158
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
159
+ ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
160
+ type TEXT NOT NULL,
161
+ task_id TEXT,
162
+ change_id TEXT,
163
+ session_id TEXT,
164
+ runtime TEXT CHECK (runtime IS NULL OR runtime IN ('claude', 'opencode', 'codex', 'kimi')),
165
+ payload_json TEXT
166
+ );
167
+ INSERT INTO events_runtime_upgrade
168
+ (id, ts, type, task_id, change_id, session_id, runtime, payload_json)
169
+ SELECT id, ts, type, task_id, change_id, session_id, runtime, payload_json FROM events;
170
+ DROP TABLE events;
171
+ ALTER TABLE events_runtime_upgrade RENAME TO events;
172
+ CREATE INDEX events_ts_type ON events(ts, type);
173
+ CREATE INDEX events_task ON events(task_id, id);
174
+ CREATE INDEX events_session ON events(session_id, id);
175
+ CREATE INDEX events_change ON events(change_id, id);
176
+
177
+ DROP TABLE IF EXISTS sessions_runtime_upgrade;
178
+ CREATE TABLE sessions_runtime_upgrade (
179
+ sid TEXT PRIMARY KEY,
180
+ task_id TEXT NOT NULL REFERENCES tasks(id),
181
+ runtime TEXT NOT NULL CHECK (runtime IN ('claude', 'opencode', 'codex', 'kimi')),
182
+ pid INTEGER,
183
+ worktree_path TEXT NOT NULL,
184
+ artifact_dir TEXT NOT NULL,
185
+ status TEXT NOT NULL DEFAULT 'running'
186
+ CHECK (status IN ('running', 'completed', 'crashed', 'orphan')),
187
+ lease_expires_at TEXT NOT NULL,
188
+ heartbeat_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
189
+ started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
190
+ );
191
+ INSERT INTO sessions_runtime_upgrade
192
+ (sid, task_id, runtime, pid, worktree_path, artifact_dir, status,
193
+ lease_expires_at, heartbeat_at, started_at)
194
+ SELECT sid, task_id, runtime, pid, worktree_path, artifact_dir, status,
195
+ lease_expires_at, heartbeat_at, started_at FROM sessions;
196
+ DROP TABLE sessions;
197
+ ALTER TABLE sessions_runtime_upgrade RENAME TO sessions;
198
+ CREATE INDEX sessions_active ON sessions(status, task_id);
199
+ CREATE INDEX sessions_lease ON sessions(lease_expires_at) WHERE status = 'running';
200
+ `);
201
+ db.prepare(
202
+ `INSERT INTO migration_history
203
+ (from_version, to_version, direction, status, notes)
204
+ VALUES (?, ?, 'forward', 'success', ?)`,
205
+ ).run(
206
+ previousVersion,
207
+ KIMI_SCHEMA_VERSION,
208
+ 'Add Kimi to durable event and session runtime constraints',
209
+ );
210
+ const violations = db.pragma('foreign_key_check');
211
+ if (violations.length > 0) {
212
+ throw new Error(`runtime constraint migration produced ${violations.length} foreign key violation(s)`);
213
+ }
214
+ })();
215
+ } finally {
216
+ db.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
217
+ }
218
+ return true;
219
+ }
220
+
221
+ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
222
+ const runtimeChanged = upgradeRuntimeConstraints(db, fromVersion);
91
223
  db.transaction(() => {
92
224
  applyCompatibleColumns(db);
225
+ const contextChanged = applyContextSpineUpgrade(db);
226
+ if (contextChanged || (fromVersion && fromVersion !== EXPECTED_VERSION)) {
227
+ db.prepare(
228
+ `INSERT INTO migration_history
229
+ (from_version, to_version, direction, status, notes)
230
+ VALUES (?, ?, 'forward', 'success', ?)`,
231
+ ).run(
232
+ runtimeChanged ? KIMI_SCHEMA_VERSION : (fromVersion || 'unknown'),
233
+ EXPECTED_VERSION,
234
+ 'Add Context Spine role/gate readiness, execution contracts, breadcrumbs, and specification learning',
235
+ );
236
+ }
93
237
  db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
94
238
  db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
95
239
  db.prepare(
96
240
  'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
97
- ).run(EXPECTED_VERSION, 'Continuous change units and recoverable runtime state');
241
+ ).run(EXPECTED_VERSION, 'Role-scoped context snapshots, deterministic breadcrumbs, and approval-gated specification learning');
98
242
  })();
99
243
  }
100
244
 
@@ -116,12 +260,13 @@ function ensureSchemaVersion(db) {
116
260
  function initStateDb(dbPath) {
117
261
  const db = openStateDb(dbPath);
118
262
  const existing = new Set(tableNames(db));
263
+ const fromVersion = latestSchemaVersion(db);
119
264
  applyCompatibleColumns(db);
120
265
  const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
121
266
  if (missing.length > 0) {
122
267
  applySchema(db);
123
268
  }
124
- applyCompatibleUpgrades(db);
269
+ applyCompatibleUpgrades(db, fromVersion);
125
270
  const version = ensureSchemaVersion(db);
126
271
  return {
127
272
  db,
@@ -9,11 +9,13 @@ const path = require('node:path');
9
9
  const {
10
10
  EXPECTED_VERSION,
11
11
  REQUIRED_TABLES,
12
+ SCHEMA_FILE,
12
13
  initStateDb,
13
14
  closeStateDb,
14
15
  openStateDb,
15
16
  tableNames,
16
17
  } = require('./state-db.cjs');
18
+ const stateOps = require('./state-ops.cjs');
17
19
 
18
20
  function tmpDbPath(prefix = 'ubp-state') {
19
21
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
@@ -114,3 +116,62 @@ test('Phase 8A.1 schema: tasks.parent_id column + tasks_parent partial index + s
114
116
  fs.rmSync(dir, { recursive: true, force: true });
115
117
  }
116
118
  });
119
+
120
+ test('initStateDb migrates existing runtime constraints to Kimi without losing references', () => {
121
+ const { dir, file } = tmpDbPath('ubp-kimi-runtime-upgrade');
122
+ try {
123
+ const legacy = openStateDb(file);
124
+ const legacySchema = fs.readFileSync(SCHEMA_FILE, 'utf8').replaceAll(", 'kimi'", '');
125
+ legacy.exec(legacySchema);
126
+ legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0')").run();
127
+ legacy.prepare(
128
+ "INSERT INTO tasks (id, title, type, priority) VALUES ('task-old', 'Old', 'feature', 'P1')",
129
+ ).run();
130
+ legacy.prepare(
131
+ `INSERT INTO sessions
132
+ (sid, task_id, runtime, worktree_path, artifact_dir, lease_expires_at)
133
+ VALUES ('session-old', 'task-old', 'codex', '/tmp/worktree', '/tmp/artifacts', '2099-01-01T00:00:00.000Z')`,
134
+ ).run();
135
+ legacy.prepare(
136
+ "INSERT INTO telemetry (session_id, event_type, tool_name) VALUES ('session-old', 'tool_call', 'task.list')",
137
+ ).run();
138
+ legacy.prepare(
139
+ `INSERT INTO incidents
140
+ (id, code, severity, message, session_id)
141
+ VALUES ('incident-old', 'OLD', 'warning', 'preserve me', 'session-old')`,
142
+ ).run();
143
+ legacy.prepare(
144
+ "INSERT INTO events (type, session_id, runtime) VALUES ('session_spawned', 'session-old', 'codex')",
145
+ ).run();
146
+ closeStateDb(legacy);
147
+
148
+ const upgraded = initStateDb(file);
149
+ assert.equal(upgraded.schema_version, '10.0');
150
+ assert.equal(upgraded.db.prepare("SELECT runtime FROM sessions WHERE sid = 'session-old'").get().runtime, 'codex');
151
+ assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE session_id = 'session-old'").get().n, 1);
152
+ assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM incidents WHERE session_id = 'session-old'").get().n, 1);
153
+ assert.deepEqual(upgraded.db.pragma('foreign_key_check'), []);
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",
156
+ ).all();
157
+ assert.ok(migrations.some((row) => row.to_version === '9.1' && /Kimi/.test(row.notes)));
158
+ assert.ok(migrations.some((row) => row.to_version === '10.0' && /Context Spine/.test(row.notes)));
159
+
160
+ upgraded.db.prepare(
161
+ "INSERT INTO tasks (id, title, type, priority) VALUES ('task-kimi', 'Kimi', 'feature', 'P1')",
162
+ ).run();
163
+ const session = stateOps.createSession(upgraded.db, {
164
+ sid: 'session-kimi',
165
+ task_id: 'task-kimi',
166
+ runtime: 'kimi',
167
+ worktree_path: '/tmp/kimi-worktree',
168
+ artifact_dir: '/tmp/kimi-artifacts',
169
+ });
170
+ assert.equal(session.runtime, 'kimi');
171
+ const event = stateOps.appendEvent(upgraded.db, { type: 'kimi-ready', runtime: 'kimi' });
172
+ assert.ok(event.event_id > 0);
173
+ closeStateDb(upgraded.db);
174
+ } finally {
175
+ fs.rmSync(dir, { recursive: true, force: true });
176
+ }
177
+ });
@@ -73,6 +73,9 @@ const CHANGE_TOOLS = Object.freeze([
73
73
  'change.get',
74
74
  'change.list',
75
75
  'change.context',
76
+ 'change.breadcrumb',
77
+ 'change.learning_propose',
78
+ 'change.learning_resolve',
76
79
  'change.converge',
77
80
  'change.archive',
78
81
  ]);
@@ -95,6 +98,7 @@ const MUTATING_TOOLS = new Set([
95
98
  'session.spawn', 'session.close', 'session.heartbeat',
96
99
  'plan.export',
97
100
  'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
101
+ 'change.learning_propose', 'change.learning_resolve',
98
102
  ]);
99
103
 
100
104
  function loadRegisteredTools() {
@@ -368,7 +372,12 @@ async function dispatchTool(name, input, db, ctx = {}) {
368
372
  err.code = 'CHANGE_NOT_FOUND';
369
373
  throw err;
370
374
  }
371
- return { change };
375
+ return {
376
+ change: {
377
+ ...change,
378
+ learning_candidates: changes.listSpecLearning(db, change.id),
379
+ },
380
+ };
372
381
  }
373
382
  case 'change.list': {
374
383
  const rows = changes.listChanges(db, input || {});
@@ -382,6 +391,27 @@ async function dispatchTool(name, input, db, ctx = {}) {
382
391
  manifest: out.manifest,
383
392
  };
384
393
  }
394
+ case 'change.breadcrumb': {
395
+ return {
396
+ breadcrumb: changes.readBreadcrumb(
397
+ db, input || {}, { rootDir: ctx.rootDir || process.cwd() },
398
+ ),
399
+ };
400
+ }
401
+ case 'change.learning_propose': {
402
+ return {
403
+ candidate: changes.proposeSpecLearning(
404
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
405
+ ),
406
+ };
407
+ }
408
+ case 'change.learning_resolve': {
409
+ return {
410
+ candidate: changes.resolveSpecLearning(
411
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
412
+ ),
413
+ };
414
+ }
385
415
  case 'change.converge': {
386
416
  return changes.convergeChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
387
417
  }
@@ -11,7 +11,7 @@ const Database = require('better-sqlite3');
11
11
 
12
12
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
13
13
  const CLI = path.join(REPO_ROOT, 'ultra-tools', 'cli.cjs');
14
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
14
+ const { EXPECTED_VERSION, initStateDb, closeStateDb } = require('../lib/state-db.cjs');
15
15
  const ops = require('../lib/state-ops.cjs');
16
16
 
17
17
  function tmpDir() {
@@ -97,8 +97,8 @@ test('db backup writes a file that opens independently with the same schema', ()
97
97
 
98
98
  // Independently open the backup with a fresh connection
99
99
  const snap = new Database(backupPath);
100
- const v = snap.prepare("SELECT version FROM schema_version").get();
101
- assert.equal(v.version, '4.5');
100
+ const v = snap.prepare('SELECT version FROM schema_version WHERE version = ?').get(EXPECTED_VERSION);
101
+ assert.equal(v.version, EXPECTED_VERSION);
102
102
  const t = snap.prepare("SELECT id FROM tasks WHERE id = 'b-1'").get();
103
103
  assert.equal(t.id, 'b-1');
104
104
  snap.close();
@@ -36,8 +36,8 @@ test('initProject copies bundled template into .ultra/', () => {
36
36
  assert.ok(fs.existsSync(r.state_db_path));
37
37
  const db = new Database(r.state_db_path, { readonly: true });
38
38
  try {
39
- const version = db.prepare("SELECT version FROM schema_version WHERE version = '9.0'").get();
40
- assert.equal(version.version, '9.0');
39
+ const version = db.prepare("SELECT version FROM schema_version WHERE version = '9.1'").get();
40
+ assert.equal(version.version, '9.1');
41
41
  assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE name = 'changes'").get());
42
42
  } finally {
43
43
  db.close();
@@ -75,10 +75,13 @@ test('listTools returns workflow tools and exposes no Ultra memory API', async (
75
75
  const names = list.tools.map((t) => t.name).sort();
76
76
  assert.deepEqual(names, [
77
77
  'change.archive',
78
+ 'change.breadcrumb',
78
79
  'change.context',
79
80
  'change.converge',
80
81
  'change.create',
81
82
  'change.get',
83
+ 'change.learning_propose',
84
+ 'change.learning_resolve',
82
85
  'change.list',
83
86
  'change.update',
84
87
  'plan.export',
@@ -142,9 +145,19 @@ test('change.create + change.context expose a continuous change unit with extern
142
145
  name: 'change.context', arguments: { id: 'mcp-change' },
143
146
  }));
144
147
  assert.equal(context.manifest.change.id, 'mcp-change');
148
+ assert.equal(context.manifest.schema_version, '2.0');
149
+ assert.equal(context.manifest.role, 'plan');
150
+ assert.equal(context.manifest.readiness.status, 'ready');
145
151
  assert.equal(context.manifest.providers.memory.provider, 'cloud-mem');
146
152
  assert.equal(context.manifest.provider_boundary.includes('content remain external'), true);
147
153
 
154
+ const breadcrumb = readToolPayload(await client.callTool({
155
+ name: 'change.breadcrumb', arguments: { id: 'mcp-change' },
156
+ }));
157
+ assert.equal(breadcrumb.breadcrumb.change_id, 'mcp-change');
158
+ assert.equal(breadcrumb.breadcrumb.recommended_workflow, 'ultra-change');
159
+ assert.match(breadcrumb.breadcrumb.context_manifest_hash, /^[0-9a-f]{64}$/);
160
+
148
161
  const listed = readToolPayload(await client.callTool({
149
162
  name: 'change.list', arguments: { status: 'active' },
150
163
  }));
@@ -156,6 +169,67 @@ test('change.create + change.context expose a continuous change unit with extern
156
169
  }
157
170
  });
158
171
 
172
+ test('spec-learning MCP tools persist an approval-gated candidate and projection', async () => {
173
+ const proj = tmpProject();
174
+ try {
175
+ await withClient(proj, async (client) => {
176
+ await client.callTool({
177
+ name: 'change.create',
178
+ arguments: {
179
+ id: 'learning-change', title: 'Learn stable contract', kind: 'quick',
180
+ intent: 'Persist only approved specification discoveries.',
181
+ docs_impact: { status: 'none', files: [], rationale: 'Contract fixture.' },
182
+ },
183
+ });
184
+ await client.callTool({
185
+ name: 'task.create',
186
+ arguments: {
187
+ id: 'learning-task', title: 'Discover contract', type: 'feature', priority: 'P1',
188
+ change_id: 'learning-change',
189
+ },
190
+ });
191
+ const proposed = readToolPayload(await client.callTool({
192
+ name: 'change.learning_propose',
193
+ arguments: {
194
+ id: 'learning-contract', change_id: 'learning-change', task_id: 'learning-task',
195
+ target_ref: 'README.md#contract', summary: 'Context compilation fails closed on stale refs.',
196
+ evidence: ['node --test context-spine.test.cjs'],
197
+ },
198
+ }));
199
+ assert.equal(proposed.candidate.status, 'proposed');
200
+ const withLearning = readToolPayload(await client.callTool({
201
+ name: 'change.get', arguments: { id: 'learning-change' },
202
+ }));
203
+ assert.deepEqual(
204
+ withLearning.change.learning_candidates.map((candidate) => candidate.id),
205
+ ['learning-contract'],
206
+ );
207
+
208
+ const approved = readToolPayload(await client.callTool({
209
+ name: 'change.learning_resolve',
210
+ arguments: {
211
+ change_id: 'learning-change', candidate_id: 'learning-contract', decision: 'approve',
212
+ resolution: 'Stable behavior contract.',
213
+ },
214
+ }));
215
+ assert.equal(approved.candidate.status, 'approved');
216
+ const applied = readToolPayload(await client.callTool({
217
+ name: 'change.learning_resolve',
218
+ arguments: {
219
+ change_id: 'learning-change', candidate_id: 'learning-contract', decision: 'apply',
220
+ resolution: 'Applied to README.md#contract.',
221
+ },
222
+ }));
223
+ assert.equal(applied.candidate.status, 'applied');
224
+ assert.ok(fs.existsSync(path.join(
225
+ proj.dir, '.ultra', 'changes', 'active', 'learning-change', 'spec-learning.json',
226
+ )));
227
+ });
228
+ } finally {
229
+ fs.rmSync(proj.dir, { recursive: true, force: true });
230
+ }
231
+ });
232
+
159
233
  test('system.doctor returns structured provider ownership and projection health', async () => {
160
234
  const proj = tmpProject();
161
235
  try {
@@ -8,7 +8,7 @@ const path = require('node:path');
8
8
 
9
9
  const stateDb = require('../lib/state-db.cjs');
10
10
 
11
- test('initStateDb upgrades a pre-9.0 database in place with task/event change linkage', () => {
11
+ test('initStateDb upgrades a pre-10.0 database in place with Context Spine state', () => {
12
12
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-db-upgrade-'));
13
13
  const file = path.join(dir, 'state.db');
14
14
  try {
@@ -59,8 +59,25 @@ test('initStateDb upgrades a pre-9.0 database in place with task/event change li
59
59
  assert.ok(columns.includes('change_id'));
60
60
  const eventColumns = upgraded.db.prepare('PRAGMA table_info(events)').all().map((row) => row.name);
61
61
  assert.ok(eventColumns.includes('change_id'));
62
- assert.equal(upgraded.schema_version, '9.0');
63
- assert.ok(upgraded.db.prepare("SELECT 1 FROM schema_version WHERE version = '9.0'").get());
62
+ assert.equal(upgraded.schema_version, '10.0');
63
+ assert.ok(upgraded.db.prepare("SELECT 1 FROM schema_version WHERE version = '10.0'").get());
64
+ const contextColumns = upgraded.db.prepare('PRAGMA table_info(context_snapshots)').all()
65
+ .map((row) => row.name);
66
+ for (const column of ['role', 'gate', 'next_action', 'readiness', 'context_json', 'token_budget']) {
67
+ assert.ok(contextColumns.includes(column), column);
68
+ }
69
+ assert.ok(upgraded.db.prepare(
70
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'spec_learning_candidates'",
71
+ ).get());
72
+ const contextMigration = upgraded.db.prepare(
73
+ "SELECT from_version, to_version, notes FROM migration_history WHERE to_version = '10.0' ORDER BY id DESC LIMIT 1",
74
+ ).get();
75
+ assert.equal(contextMigration.from_version, '9.1');
76
+ assert.equal(contextMigration.to_version, '10.0');
77
+ assert.match(contextMigration.notes, /Context Spine/);
78
+ assert.ok(upgraded.db.prepare(
79
+ "SELECT 1 FROM migration_history WHERE from_version = '8A.1' AND to_version = '9.1' AND notes LIKE '%Kimi%'",
80
+ ).get());
64
81
  stateDb.closeStateDb(upgraded.db);
65
82
  } finally {
66
83
  fs.rmSync(dir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra-builder-pro-cli",
3
- "version": "0.8.1",
4
- "description": "Multi-runtime installer for the Ultra Builder Pro agent engineering system — deploys commands, agents, skills, hooks, and MCP server to Claude Code, OpenCode, and Codex CLI.",
3
+ "version": "0.10.0",
4
+ "description": "Multi-runtime installer for the Ultra Builder Pro agent engineering system — deploys commands, agents, skills, hooks, and MCP server to Claude Code, OpenCode, Codex CLI, and Kimi Code.",
5
5
  "bin": {
6
6
  "ultra-builder-pro-cli": "bin/install.js",
7
7
  "ubp": "bin/install.js",
@@ -60,6 +60,7 @@
60
60
  "claude-code",
61
61
  "opencode",
62
62
  "codex",
63
+ "kimi-code",
63
64
  "agent",
64
65
  "cli",
65
66
  "multi-runtime",
@@ -92,6 +93,7 @@
92
93
  "verify:release": "npm run test:all && npm run test:hooks && npm audit --omit=dev --audit-level=high",
93
94
  "prepublishOnly": "npm run verify:release",
94
95
  "install:claude": "node bin/install.js --claude --local",
96
+ "install:kimi": "node bin/install.js --kimi --local",
95
97
  "install:all": "node bin/install.js --all --local"
96
98
  },
97
99
  "dependencies": {
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: learn
3
3
  description: "Extract one reusable pattern from the current session into a valid user skill, with explicit user approval before writing."
4
+ user-invocable: true
4
5
  ---
5
6
 
6
7
  # learn — Phase 3.6