ultra-builder-pro-cli 0.9.0 → 0.11.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 (119) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +11 -6
  3. package/adapters/_shared/codex-assets.cjs +23 -331
  4. package/adapters/_shared/handbook.cjs +2 -0
  5. package/adapters/_shared/runtime-assets.cjs +25 -0
  6. package/adapters/_shared/tests/handbook.test.cjs +3 -0
  7. package/adapters/_shared/tests/runtime-assets.test.cjs +12 -4
  8. package/adapters/claude.js +28 -5
  9. package/adapters/kimi.js +15 -138
  10. package/adapters/opencode.js +59 -63
  11. package/adapters/tests/claude.test.cjs +26 -9
  12. package/adapters/tests/codex.test.cjs +23 -9
  13. package/adapters/tests/kimi.test.cjs +15 -11
  14. package/adapters/tests/opencode.test.cjs +57 -10
  15. package/agents/code-reviewer.md +24 -85
  16. package/agents/debugger.md +30 -132
  17. package/agents/review-code.md +24 -104
  18. package/agents/review-comments.md +20 -83
  19. package/agents/review-coordinator.md +40 -137
  20. package/agents/review-design.md +21 -118
  21. package/agents/review-errors.md +22 -70
  22. package/agents/review-spec.md +36 -0
  23. package/agents/review-tests.md +20 -76
  24. package/agents/tdd-runner.md +26 -84
  25. package/commands/learn.md +5 -36
  26. package/commands/ultra-change.md +5 -43
  27. package/commands/ultra-deliver.md +4 -46
  28. package/commands/ultra-dev.md +4 -48
  29. package/commands/ultra-doctor.md +4 -30
  30. package/commands/ultra-init.md +5 -47
  31. package/commands/ultra-plan.md +5 -48
  32. package/commands/ultra-research.md +5 -54
  33. package/commands/ultra-status.md +5 -44
  34. package/commands/ultra-test.md +5 -45
  35. package/commands/ultra-think.md +5 -40
  36. package/docs/AGENT-CONTEXT.md +34 -12
  37. package/docs/ARCHITECTURE.md +31 -5
  38. package/docs/PLAN.zh-CN.md +3 -2
  39. package/docs/ROADMAP.md +5 -4
  40. package/docs/RUNTIME-COMPAT-MATRIX.md +7 -5
  41. package/docs/SKILL-AUTHORING.md +75 -0
  42. package/docs/USER-HANDBOOK-CONTRACT.md +9 -2
  43. package/hooks/README.md +6 -4
  44. package/hooks/active_task_context.py +14 -0
  45. package/hooks/context_spine.py +218 -0
  46. package/hooks/health_check.py +1 -1
  47. package/hooks/workflow_context.py +16 -123
  48. package/hooks/workflow_resume.py +21 -0
  49. package/mcp-server/lib/change-workflow.cjs +89 -11
  50. package/mcp-server/lib/change-workflow.test.cjs +202 -7
  51. package/mcp-server/lib/context-spine.cjs +331 -0
  52. package/mcp-server/lib/context-spine.test.cjs +73 -0
  53. package/mcp-server/lib/spec-learning.cjs +191 -0
  54. package/mcp-server/lib/spec-learning.test.cjs +62 -0
  55. package/mcp-server/lib/state-db.cjs +72 -10
  56. package/mcp-server/lib/state-db.test.cjs +7 -2
  57. package/mcp-server/server.cjs +31 -1
  58. package/mcp-server/tests/db-ops.test.cjs +3 -3
  59. package/mcp-server/tests/server-contract.test.cjs +74 -0
  60. package/mcp-server/tests/state-db.test.cjs +20 -3
  61. package/package.json +3 -2
  62. package/skills/cc-collab/SKILL.md +16 -18
  63. package/skills/code-review-expert/SKILL.md +39 -114
  64. package/skills/code-review-expert/references/correctness-reliability.md +30 -0
  65. package/skills/code-review-expert/references/design-boundaries.md +26 -0
  66. package/skills/code-review-expert/references/removal-plan.md +17 -45
  67. package/skills/codex-collab/SKILL.md +23 -28
  68. package/skills/codex-collab/evals/evals.json +18 -23
  69. package/skills/integration-rules/SKILL.md +28 -67
  70. package/skills/learn/SKILL.md +46 -105
  71. package/skills/security-rules/SKILL.md +34 -56
  72. package/skills/testing-rules/SKILL.md +28 -52
  73. package/skills/ultra-change/SKILL.md +61 -145
  74. package/skills/ultra-deliver/SKILL.md +63 -229
  75. package/skills/ultra-dev/SKILL.md +46 -338
  76. package/skills/ultra-doctor/SKILL.md +39 -71
  77. package/skills/ultra-init/SKILL.md +59 -139
  78. package/skills/ultra-plan/SKILL.md +50 -310
  79. package/skills/ultra-research/SKILL.md +59 -152
  80. package/skills/ultra-research/references/architecture.md +38 -0
  81. package/skills/ultra-research/references/problem-product.md +39 -0
  82. package/skills/ultra-research/references/quality-delivery.md +34 -0
  83. package/skills/ultra-research/references/synthesis.md +38 -0
  84. package/skills/ultra-research/references/users-scope.md +40 -0
  85. package/skills/ultra-review/SKILL.md +55 -427
  86. package/skills/ultra-review/references/unified-schema.md +76 -101
  87. package/skills/ultra-review/scripts/review_wait.py +242 -77
  88. package/skills/ultra-status/SKILL.md +38 -173
  89. package/skills/ultra-test/SKILL.md +41 -195
  90. package/skills/ultra-think/SKILL.md +43 -101
  91. package/skills/ultra-verify/SKILL.md +44 -84
  92. package/skills/ultra-verify/evals/evals.json +24 -27
  93. package/spec/cli-protocol.md +3 -0
  94. package/spec/mcp-tools.yaml +133 -4
  95. package/spec/schemas/command-manifest.schema.json +7 -25
  96. package/spec/schemas/skill-manifest.schema.json +9 -79
  97. package/spec/schemas/state-db.sql +31 -0
  98. package/skills/code-review-expert/references/code-quality-checklist.md +0 -112
  99. package/skills/code-review-expert/references/integration-checklist.md +0 -46
  100. package/skills/code-review-expert/references/security-checklist.md +0 -100
  101. package/skills/code-review-expert/references/solid-checklist.md +0 -65
  102. package/skills/ultra-research/steps/step-00-problem-validation.md +0 -154
  103. package/skills/ultra-research/steps/step-01-opportunity-discovery.md +0 -172
  104. package/skills/ultra-research/steps/step-02-market-assessment.md +0 -195
  105. package/skills/ultra-research/steps/step-03-competitive-landscape.md +0 -197
  106. package/skills/ultra-research/steps/step-04-product-strategy.md +0 -196
  107. package/skills/ultra-research/steps/step-05-assumptions-validation.md +0 -234
  108. package/skills/ultra-research/steps/step-10-user-personas.md +0 -169
  109. package/skills/ultra-research/steps/step-11-user-scenarios.md +0 -181
  110. package/skills/ultra-research/steps/step-20-user-stories.md +0 -156
  111. package/skills/ultra-research/steps/step-21-features-scope.md +0 -157
  112. package/skills/ultra-research/steps/step-22-success-metrics.md +0 -195
  113. package/skills/ultra-research/steps/step-30-architecture-context.md +0 -154
  114. package/skills/ultra-research/steps/step-31-solution-strategy.md +0 -172
  115. package/skills/ultra-research/steps/step-32-building-blocks.md +0 -188
  116. package/skills/ultra-research/steps/step-40-deployment.md +0 -123
  117. package/skills/ultra-research/steps/step-41-quality-risks.md +0 -135
  118. package/skills/ultra-research/steps/step-99-synthesis.md +0 -188
  119. package/skills/ultra-review/scripts/review_verdict_update.py +0 -99
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const ops = require('./state-ops.cjs');
8
+
9
+ const CANDIDATE_ID = /^[a-zA-Z0-9_-]+$/;
10
+ const TRANSITIONS = Object.freeze({
11
+ proposed: new Set(['approved', 'rejected']),
12
+ approved: new Set(['applied', 'rejected']),
13
+ rejected: new Set(),
14
+ applied: new Set(),
15
+ });
16
+
17
+ class SpecLearningError extends Error {
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.name = 'SpecLearningError';
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ function nowIso() {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ function rowToCandidate(row) {
30
+ if (!row) return null;
31
+ let evidence;
32
+ try { evidence = JSON.parse(row.evidence_json); }
33
+ catch (error) {
34
+ throw new SpecLearningError('STATE_CORRUPT', `invalid spec-learning evidence: ${error.message}`);
35
+ }
36
+ const candidate = { ...row, evidence };
37
+ delete candidate.evidence_json;
38
+ return candidate;
39
+ }
40
+
41
+ function listSpecLearning(db, changeId) {
42
+ return db.prepare(
43
+ 'SELECT * FROM spec_learning_candidates WHERE change_id = ? ORDER BY proposed_at ASC, id ASC',
44
+ ).all(changeId).map(rowToCandidate);
45
+ }
46
+
47
+ function writeAtomic(file, contents) {
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
50
+ fs.writeFileSync(temporary, contents);
51
+ fs.renameSync(temporary, file);
52
+ }
53
+
54
+ function upsertProjectionArtifact(db, change, rootDir, candidates) {
55
+ const file = path.resolve(rootDir, change.artifact_root, 'spec-learning.json');
56
+ const generatedAt = candidates.reduce((latest, candidate) => (
57
+ [candidate.applied_at, candidate.resolved_at, candidate.proposed_at]
58
+ .filter(Boolean)
59
+ .reduce((value, timestamp) => (timestamp > value ? timestamp : value), latest)
60
+ ), '1970-01-01T00:00:00.000Z');
61
+ const payload = {
62
+ schema_version: '1.0',
63
+ source: '.ultra/state.db',
64
+ change_id: change.id,
65
+ generated_at: generatedAt,
66
+ candidates,
67
+ };
68
+ const serialized = `${JSON.stringify(payload, null, 2)}\n`;
69
+ writeAtomic(file, serialized);
70
+ const relative = path.relative(rootDir, file);
71
+ const hash = crypto.createHash('sha256').update(serialized).digest('hex');
72
+ db.prepare(
73
+ `INSERT INTO artifacts (id, change_id, kind, path, content_hash, metadata_json)
74
+ VALUES (?, ?, 'spec_learning', ?, ?, ?)
75
+ ON CONFLICT(change_id, kind, path) DO UPDATE SET
76
+ content_hash = excluded.content_hash,
77
+ metadata_json = excluded.metadata_json,
78
+ updated_at = excluded.updated_at`,
79
+ ).run(
80
+ `art-${crypto.randomUUID().slice(0, 12)}`, change.id, relative, hash,
81
+ JSON.stringify({ candidates: candidates.length }),
82
+ );
83
+ return { path: file, hash };
84
+ }
85
+
86
+ function mutableChange(db, changeId) {
87
+ const change = db.prepare('SELECT * FROM changes WHERE id = ?').get(changeId);
88
+ if (!change) throw new SpecLearningError('CHANGE_NOT_FOUND', `change ${changeId} not found`);
89
+ if (!['active', 'blocked'].includes(change.status)) {
90
+ throw new SpecLearningError(
91
+ 'CHANGE_NOT_MUTABLE', `change ${changeId} is ${change.status}; learning must resolve before convergence`,
92
+ );
93
+ }
94
+ return change;
95
+ }
96
+
97
+ function normalizeEvidence(value) {
98
+ if (value === undefined) return [];
99
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
100
+ throw new SpecLearningError('VALIDATION_ERROR', 'spec-learning evidence must be non-empty strings');
101
+ }
102
+ return [...new Set(value.map((entry) => entry.trim()))];
103
+ }
104
+
105
+ function proposeSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
106
+ const change = mutableChange(db, input.change_id);
107
+ const id = String(input.id || `learning-${crypto.randomUUID().slice(0, 12)}`).trim();
108
+ if (!CANDIDATE_ID.test(id)) {
109
+ throw new SpecLearningError('VALIDATION_ERROR', `invalid spec-learning candidate id: ${id}`);
110
+ }
111
+ const targetRef = typeof input.target_ref === 'string' ? input.target_ref.trim() : '';
112
+ const summary = typeof input.summary === 'string' ? input.summary.trim() : '';
113
+ if (!targetRef || !summary) {
114
+ throw new SpecLearningError('VALIDATION_ERROR', 'target_ref and summary are required');
115
+ }
116
+ if (input.task_id) {
117
+ const task = db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(input.task_id);
118
+ if (!task || task.change_id !== change.id) {
119
+ throw new SpecLearningError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
120
+ }
121
+ }
122
+ const evidence = normalizeEvidence(input.evidence);
123
+ const proposedAt = nowIso();
124
+ return ops.tx(db, () => {
125
+ try {
126
+ db.prepare(
127
+ `INSERT INTO spec_learning_candidates
128
+ (id, change_id, task_id, target_ref, summary, evidence_json, proposed_at)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
130
+ ).run(id, change.id, input.task_id || null, targetRef, summary, JSON.stringify(evidence), proposedAt);
131
+ } catch (error) {
132
+ if (String(error.message).includes('UNIQUE')) {
133
+ throw new SpecLearningError('DUPLICATE_LEARNING_ID', `spec-learning candidate ${id} exists`);
134
+ }
135
+ throw error;
136
+ }
137
+ ops.appendEventInTx(db, {
138
+ type: 'spec_learning_proposed', change_id: change.id, task_id: input.task_id || null,
139
+ payload: { candidate_id: id, target_ref: targetRef },
140
+ });
141
+ const candidates = listSpecLearning(db, change.id);
142
+ upsertProjectionArtifact(db, change, rootDir, candidates);
143
+ return candidates.find((candidate) => candidate.id === id);
144
+ });
145
+ }
146
+
147
+ function resolveSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
148
+ const change = mutableChange(db, input.change_id);
149
+ const current = rowToCandidate(db.prepare(
150
+ 'SELECT * FROM spec_learning_candidates WHERE id = ? AND change_id = ?',
151
+ ).get(input.candidate_id, change.id));
152
+ if (!current) {
153
+ throw new SpecLearningError('LEARNING_NOT_FOUND', `candidate ${input.candidate_id} not found`);
154
+ }
155
+ const status = { approve: 'approved', reject: 'rejected', apply: 'applied' }[input.decision];
156
+ if (!status) throw new SpecLearningError('VALIDATION_ERROR', `invalid learning decision: ${input.decision}`);
157
+ if (!TRANSITIONS[current.status].has(status)) {
158
+ throw new SpecLearningError(
159
+ 'ILLEGAL_LEARNING_TRANSITION', `cannot move candidate ${current.id} from ${current.status} to ${status}`,
160
+ );
161
+ }
162
+ const resolution = typeof input.resolution === 'string' ? input.resolution.trim() : '';
163
+ if (['rejected', 'applied'].includes(status) && !resolution) {
164
+ throw new SpecLearningError('VALIDATION_ERROR', `${input.decision} requires a resolution`);
165
+ }
166
+ const timestamp = nowIso();
167
+ return ops.tx(db, () => {
168
+ db.prepare(
169
+ `UPDATE spec_learning_candidates
170
+ SET status = ?, resolution = ?, resolved_at = ?, applied_at = ?
171
+ WHERE id = ? AND change_id = ?`,
172
+ ).run(
173
+ status, resolution || null, timestamp, status === 'applied' ? timestamp : null,
174
+ current.id, change.id,
175
+ );
176
+ ops.appendEventInTx(db, {
177
+ type: 'spec_learning_resolved', change_id: change.id, task_id: current.task_id,
178
+ payload: { candidate_id: current.id, decision: input.decision, status },
179
+ });
180
+ const candidates = listSpecLearning(db, change.id);
181
+ upsertProjectionArtifact(db, change, rootDir, candidates);
182
+ return candidates.find((candidate) => candidate.id === current.id);
183
+ });
184
+ }
185
+
186
+ module.exports = {
187
+ SpecLearningError,
188
+ listSpecLearning,
189
+ proposeSpecLearning,
190
+ resolveSpecLearning,
191
+ };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { execFileSync } = require('node:child_process');
9
+
10
+ const { initStateDb, closeStateDb } = require('./state-db.cjs');
11
+ const changes = require('./change-workflow.cjs');
12
+ const learning = require('./spec-learning.cjs');
13
+
14
+ function fixture() {
15
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-learning-'));
16
+ execFileSync('git', ['init', '-q'], { cwd: rootDir });
17
+ execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: rootDir });
18
+ execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: rootDir });
19
+ fs.writeFileSync(path.join(rootDir, 'README.md'), '# Fixture\n');
20
+ execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
21
+ execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
22
+ const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
23
+ changes.createChange(db, {
24
+ id: 'learning-change', title: 'Learning change', kind: 'quick',
25
+ intent: 'Exercise guarded specification-learning transitions.',
26
+ }, { rootDir });
27
+ return { rootDir, db };
28
+ }
29
+
30
+ test('spec learning cannot skip approval or mutate after apply', () => {
31
+ const fx = fixture();
32
+ try {
33
+ learning.proposeSpecLearning(fx.db, {
34
+ id: 'candidate-1', change_id: 'learning-change', target_ref: 'README.md',
35
+ summary: 'A stable public contract was discovered.',
36
+ }, { rootDir: fx.rootDir });
37
+ assert.throws(
38
+ () => learning.resolveSpecLearning(fx.db, {
39
+ change_id: 'learning-change', candidate_id: 'candidate-1', decision: 'apply',
40
+ resolution: 'Applied.',
41
+ }, { rootDir: fx.rootDir }),
42
+ (error) => error.code === 'ILLEGAL_LEARNING_TRANSITION',
43
+ );
44
+ learning.resolveSpecLearning(fx.db, {
45
+ change_id: 'learning-change', candidate_id: 'candidate-1', decision: 'approve',
46
+ }, { rootDir: fx.rootDir });
47
+ learning.resolveSpecLearning(fx.db, {
48
+ change_id: 'learning-change', candidate_id: 'candidate-1', decision: 'apply',
49
+ resolution: 'Applied to README.md.',
50
+ }, { rootDir: fx.rootDir });
51
+ assert.throws(
52
+ () => learning.resolveSpecLearning(fx.db, {
53
+ change_id: 'learning-change', candidate_id: 'candidate-1', decision: 'reject',
54
+ resolution: 'Too late.',
55
+ }, { rootDir: fx.rootDir }),
56
+ (error) => error.code === 'ILLEGAL_LEARNING_TRANSITION',
57
+ );
58
+ } finally {
59
+ closeStateDb(fx.db);
60
+ fs.rmSync(fx.rootDir, { recursive: true, force: true });
61
+ }
62
+ });
@@ -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.1';
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,6 +96,48 @@ function applyCompatibleColumns(db) {
87
96
  }
88
97
  }
89
98
 
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
+
90
141
  function tableSupportsKimi(db, table) {
91
142
  const row = db.prepare(
92
143
  "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
@@ -94,11 +145,9 @@ function tableSupportsKimi(db, table) {
94
145
  return typeof row?.sql === 'string' && row.sql.includes("'kimi'");
95
146
  }
96
147
 
97
- function upgradeRuntimeConstraints(db) {
148
+ function upgradeRuntimeConstraints(db, fromVersion = latestSchemaVersion(db)) {
98
149
  if (tableSupportsKimi(db, 'events') && tableSupportsKimi(db, 'sessions')) return false;
99
- const previousVersion = db.prepare(
100
- 'SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1',
101
- ).get()?.version || 'unknown';
150
+ const previousVersion = fromVersion || 'unknown';
102
151
  const foreignKeys = db.pragma('foreign_keys', { simple: true });
103
152
  db.pragma('foreign_keys = OFF');
104
153
  try {
@@ -155,7 +204,7 @@ function upgradeRuntimeConstraints(db) {
155
204
  VALUES (?, ?, 'forward', 'success', ?)`,
156
205
  ).run(
157
206
  previousVersion,
158
- EXPECTED_VERSION,
207
+ KIMI_SCHEMA_VERSION,
159
208
  'Add Kimi to durable event and session runtime constraints',
160
209
  );
161
210
  const violations = db.pragma('foreign_key_check');
@@ -169,15 +218,27 @@ function upgradeRuntimeConstraints(db) {
169
218
  return true;
170
219
  }
171
220
 
172
- function applyCompatibleUpgrades(db) {
173
- upgradeRuntimeConstraints(db);
221
+ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
222
+ const runtimeChanged = upgradeRuntimeConstraints(db, fromVersion);
174
223
  db.transaction(() => {
175
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
+ }
176
237
  db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
177
238
  db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
178
239
  db.prepare(
179
240
  'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
180
- ).run(EXPECTED_VERSION, 'Kimi runtime support with durable event and session state');
241
+ ).run(EXPECTED_VERSION, 'Role-scoped context snapshots, deterministic breadcrumbs, and approval-gated specification learning');
181
242
  })();
182
243
  }
183
244
 
@@ -199,12 +260,13 @@ function ensureSchemaVersion(db) {
199
260
  function initStateDb(dbPath) {
200
261
  const db = openStateDb(dbPath);
201
262
  const existing = new Set(tableNames(db));
263
+ const fromVersion = latestSchemaVersion(db);
202
264
  applyCompatibleColumns(db);
203
265
  const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
204
266
  if (missing.length > 0) {
205
267
  applySchema(db);
206
268
  }
207
- applyCompatibleUpgrades(db);
269
+ applyCompatibleUpgrades(db, fromVersion);
208
270
  const version = ensureSchemaVersion(db);
209
271
  return {
210
272
  db,
@@ -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 = '9.1'").run();
126
+ legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.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,11 +146,16 @@ 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, '9.1');
149
+ assert.equal(upgraded.schema_version, '10.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
+ 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)));
154
159
 
155
160
  upgraded.db.prepare(
156
161
  "INSERT INTO tasks (id, title, type, priority) VALUES ('task-kimi', 'Kimi', 'feature', 'P1')",
@@ -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();
@@ -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.1 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.1 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.1');
63
- assert.ok(upgraded.db.prepare("SELECT 1 FROM schema_version WHERE version = '9.1'").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,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra-builder-pro-cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
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",
@@ -50,6 +50,7 @@
50
50
  "docs/ROADMAP.md",
51
51
  "docs/ARCHITECTURE.md",
52
52
  "docs/AGENT-CONTEXT.md",
53
+ "docs/SKILL-AUTHORING.md",
53
54
  "docs/COMMIT-HASH-BACKFILL.md",
54
55
  "docs/PLAN.zh-CN.md",
55
56
  "docs/STATE-DB-ACCESS-POLICY.md",
@@ -87,7 +88,7 @@
87
88
  "test:state": "node --test 'mcp-server/**/*.test.cjs'",
88
89
  "test:orch": "node --test 'orchestrator/**/*.test.cjs'",
89
90
  "test:spec": "node spec/scripts/test-all.cjs",
90
- "test:rest": "node --test adapters/_shared/tests/*.test.cjs adapters/tests/*.test.cjs ultra-tools/*.test.cjs tests/conformance/**/*.test.cjs tests/install.test.cjs tests/package-smoke.test.cjs tests/retired-runtime.test.cjs",
91
+ "test:rest": "node --test adapters/_shared/tests/*.test.cjs adapters/tests/*.test.cjs ultra-tools/*.test.cjs tests/conformance/**/*.test.cjs tests/*.test.cjs",
91
92
  "test:all": "npm run test:state && npm run test:orch && npm run test:spec && npm run test:rest",
92
93
  "test:hooks": "pytest hooks/tests -q",
93
94
  "verify:release": "npm run test:all && npm run test:hooks && npm audit --omit=dev --audit-level=high",