ultra-builder-pro-cli 0.23.0 → 0.24.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.
Files changed (95) hide show
  1. package/.ultra-template/docs/research/README.md +4 -4
  2. package/.ultra-template/tasks/tasks.json +4 -2
  3. package/CHANGELOG.md +72 -0
  4. package/README.md +55 -39
  5. package/adapters/_shared/codex-assets.cjs +121 -65
  6. package/adapters/_shared/interaction-contract.cjs +7 -1
  7. package/adapters/_shared/provenance.cjs +7 -2
  8. package/adapters/_shared/runtime-assets.cjs +3 -2
  9. package/adapters/_shared/runtime-native.cjs +132 -0
  10. package/adapters/claude.js +60 -30
  11. package/adapters/codex.js +178 -81
  12. package/adapters/grok.js +372 -0
  13. package/adapters/kimi.js +7 -3
  14. package/adapters/opencode.js +210 -32
  15. package/agents/code-reviewer.md +4 -2
  16. package/agents/debugger.md +4 -1
  17. package/agents/review-code.md +7 -3
  18. package/agents/review-comments.md +7 -4
  19. package/agents/review-coordinator.md +11 -7
  20. package/agents/review-design.md +8 -4
  21. package/agents/review-errors.md +8 -4
  22. package/agents/review-spec.md +8 -5
  23. package/agents/review-tests.md +7 -4
  24. package/agents/tdd-runner.md +5 -2
  25. package/bin/install.js +9 -4
  26. package/bin/orchestrator.js +4 -13
  27. package/docs/AGENT-CONTEXT.md +143 -229
  28. package/docs/ARCHITECTURE.md +205 -494
  29. package/docs/ARTIFACT-AUTHORITY.md +35 -28
  30. package/docs/LEGACY-CLI-CROSSWALK.md +1 -1
  31. package/docs/PLUGIN-ISOLATION-CONTRACT.md +12 -9
  32. package/docs/ROADMAP.md +85 -110
  33. package/docs/RUNTIME-COMPAT-MATRIX.md +139 -175
  34. package/docs/SKILL-AUTHORING.md +3 -3
  35. package/docs/STATE-DB-ACCESS-POLICY.md +13 -8
  36. package/docs/WORKFLOW-LIFECYCLE.md +189 -448
  37. package/hooks/README.md +11 -8
  38. package/hooks/active_task_context.py +16 -16
  39. package/hooks/adapters/grok.py +134 -0
  40. package/hooks/{context_spine.py → context_envelope.py} +26 -22
  41. package/hooks/health_check.py +29 -34
  42. package/hooks/pre_stop_check.py +22 -12
  43. package/hooks/subagent_tracker.py +1 -1
  44. package/hooks/workflow_checkpoint.py +14 -13
  45. package/hooks/workflow_context.py +11 -12
  46. package/hooks/workflow_resume.py +14 -13
  47. package/mcp-server/{breadcrumb.cjs → hook-context.cjs} +7 -9
  48. package/mcp-server/lib/baseline-workflow.cjs +108 -82
  49. package/mcp-server/lib/canonical-json.cjs +19 -0
  50. package/mcp-server/lib/change-authority.cjs +26 -0
  51. package/mcp-server/lib/change-workflow.cjs +630 -45
  52. package/mcp-server/lib/context-envelope.cjs +508 -0
  53. package/mcp-server/lib/decision-records.cjs +337 -0
  54. package/mcp-server/lib/doctor.cjs +91 -33
  55. package/mcp-server/lib/init-project.cjs +78 -39
  56. package/mcp-server/lib/managed-file-write.cjs +116 -0
  57. package/mcp-server/lib/plan-checkpoint.cjs +177 -0
  58. package/mcp-server/lib/project-context.cjs +178 -0
  59. package/mcp-server/lib/stage-checkpoints.cjs +309 -0
  60. package/mcp-server/lib/state-db.cjs +155 -24
  61. package/mcp-server/lib/state-ops.cjs +21 -3
  62. package/mcp-server/lib/task-ledger.cjs +522 -13
  63. package/mcp-server/lib/ultra-facade.cjs +757 -442
  64. package/mcp-server/lib/worker-packet.cjs +358 -0
  65. package/mcp-server/server.cjs +200 -1103
  66. package/orchestrator/daemon.cjs +27 -9
  67. package/orchestrator/parallel-orchestrator.cjs +111 -76
  68. package/orchestrator/session-runner.cjs +135 -34
  69. package/package.json +10 -3
  70. package/skills/ultra-change/SKILL.md +10 -8
  71. package/skills/ultra-deliver/SKILL.md +17 -19
  72. package/skills/ultra-dev/SKILL.md +20 -11
  73. package/skills/ultra-doctor/SKILL.md +4 -4
  74. package/skills/ultra-init/SKILL.md +6 -5
  75. package/skills/ultra-init/references/brownfield-adoption.md +20 -14
  76. package/skills/ultra-plan/SKILL.md +13 -9
  77. package/skills/ultra-research/SKILL.md +18 -9
  78. package/skills/ultra-review/SKILL.md +14 -9
  79. package/skills/ultra-review/references/unified-schema.md +2 -0
  80. package/skills/ultra-review/scripts/review_wait.py +8 -0
  81. package/skills/ultra-status/SKILL.md +2 -2
  82. package/skills/ultra-test/SKILL.md +10 -6
  83. package/skills/ultra-think/SKILL.md +8 -7
  84. package/skills/ultra-think/references/interaction-boundary.md +56 -0
  85. package/spec/cli-protocol.md +80 -199
  86. package/spec/mcp-tools.yaml +43 -2806
  87. package/spec/schemas/state-db.sql +107 -4
  88. package/spec/schemas/{task-ledger.v1.schema.json → task-ledger.v2.schema.json} +70 -6
  89. package/ultra-tools/commands/status.cjs +369 -243
  90. package/mcp-server/lib/context-spine.cjs +0 -1208
  91. package/mcp-server/lib/decision-dialogue.cjs +0 -1128
  92. package/mcp-server/lib/project-breadcrumb.cjs +0 -173
  93. package/mcp-server/lib/spec-learning.cjs +0 -284
  94. package/mcp-server/lib/workflow-state.cjs +0 -3129
  95. package/skills/ultra-think/references/decision-dialogue.md +0 -155
@@ -1,173 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
-
6
- const Database = require('better-sqlite3');
7
-
8
- const { EXPECTED_VERSION, REQUIRED_TABLES } = require('./state-db.cjs');
9
- const { readBreadcrumb } = require('./context-spine.cjs');
10
- const runtimePaths = require('./runtime-paths.cjs');
11
-
12
- function blockedBreadcrumb(code, {
13
- allowedTransitions = [],
14
- requiredTransition = null,
15
- } = {}) {
16
- return {
17
- change_id: null,
18
- task_id: null,
19
- role: 'plan',
20
- gate: 'alignment',
21
- readiness: 'blocked',
22
- blockers: [code],
23
- warnings: [],
24
- allowed_transitions: allowedTransitions,
25
- required_transition: requiredTransition,
26
- context_manifest_path: null,
27
- context_manifest_hash: null,
28
- git_head: null,
29
- baseline: null,
30
- accepted_intent: [],
31
- };
32
- }
33
-
34
- function migrationBreadcrumb(code) {
35
- return blockedBreadcrumb(code, {
36
- allowedTransitions: ['ultra-init', 'ultra-status'],
37
- requiredTransition: 'ultra-init',
38
- });
39
- }
40
-
41
- function doctorBreadcrumb(code) {
42
- return blockedBreadcrumb(code, {
43
- allowedTransitions: ['ultra-doctor', 'ultra-status'],
44
- requiredTransition: 'ultra-doctor',
45
- });
46
- }
47
-
48
- function compactText(value, maximum = 240) {
49
- const normalized = String(value || '').replace(/\s+/g, ' ').trim();
50
- if (normalized.length <= maximum) return normalized;
51
- return `${normalized.slice(0, maximum - 3).trimEnd()}...`;
52
- }
53
-
54
- function latestSchemaVersion(db) {
55
- return db.prepare(
56
- 'SELECT version FROM schema_version ORDER BY applied_at DESC, rowid DESC LIMIT 1',
57
- ).get()?.version || null;
58
- }
59
-
60
- function tableNames(db) {
61
- return new Set(db.prepare(
62
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
63
- ).all().map((row) => row.name));
64
- }
65
-
66
- function readProjectBreadcrumb(rootDir) {
67
- const root = path.resolve(rootDir || process.cwd());
68
- const ultraDir = path.join(root, '.ultra');
69
- if (!fs.existsSync(ultraDir)) return null;
70
- const dbPath = runtimePaths.locateStateDb(root);
71
- if (!fs.existsSync(dbPath)) {
72
- return blockedBreadcrumb('STATE_DB_MISSING', {
73
- allowedTransitions: ['ultra-init', 'ultra-status'],
74
- requiredTransition: 'ultra-init',
75
- });
76
- }
77
-
78
- let db;
79
- try {
80
- db = new Database(dbPath, { readonly: true, fileMustExist: true });
81
- db.pragma('query_only = ON');
82
- db.pragma('busy_timeout = 1000');
83
- const tables = tableNames(db);
84
- if (!tables.has('schema_version')) {
85
- return migrationBreadcrumb('STATE_SCHEMA_MIGRATION_REQUIRED:unknown');
86
- }
87
- const version = latestSchemaVersion(db);
88
- if (version !== EXPECTED_VERSION) {
89
- return migrationBreadcrumb(`STATE_SCHEMA_MIGRATION_REQUIRED:${version || 'unknown'}`);
90
- }
91
- const missing = REQUIRED_TABLES.filter((name) => !tables.has(name));
92
- if (missing.length > 0) {
93
- return doctorBreadcrumb(`STATE_SCHEMA_INCOMPLETE:${missing.join(',')}`);
94
- }
95
- return readBreadcrumb(db, {}, { rootDir: root });
96
- } catch (error) {
97
- const code = error && typeof error.code === 'string' ? error.code : 'unknown';
98
- return doctorBreadcrumb(`STATE_DB_UNREADABLE:${code}`);
99
- } finally {
100
- if (db) db.close();
101
- }
102
- }
103
-
104
- function renderProjectBreadcrumb(rootDir, breadcrumb) {
105
- if (!breadcrumb) return '';
106
- const root = path.resolve(rootDir || process.cwd());
107
- const baseline = breadcrumb.baseline || {};
108
- const task = breadcrumb.task_id || 'none';
109
- const taskStatus = breadcrumb.task_status || 'none';
110
- const lines = breadcrumb.change_id ? [
111
- '[Ultra context spine]',
112
- `Project: ${root}`,
113
- `Baseline: ${baseline.id || 'unknown'} (${baseline.mode || 'unknown'}/${baseline.status || 'unknown'})`,
114
- `Change: ${breadcrumb.change_id} (${breadcrumb.change_status || 'active'})`,
115
- `Task: ${task} (${taskStatus})`,
116
- `Role: ${breadcrumb.role || 'plan'}`,
117
- `Gate: ${breadcrumb.gate || 'alignment'}`,
118
- `Readiness: ${breadcrumb.readiness || 'blocked'}`,
119
- ] : [
120
- '[Ultra baseline]',
121
- `Project: ${root}`,
122
- `Baseline: ${baseline.id || 'missing'} (${baseline.mode || 'unknown'}/${baseline.status || 'missing'})`,
123
- `Readiness: ${breadcrumb.readiness || 'blocked'}`,
124
- ];
125
- if (Array.isArray(breadcrumb.blockers) && breadcrumb.blockers.length > 0) {
126
- lines.push(`Blockers: ${breadcrumb.blockers.join(', ')}`);
127
- }
128
- if (Array.isArray(breadcrumb.warnings) && breadcrumb.warnings.length > 0) {
129
- lines.push(`Warnings: ${breadcrumb.warnings.join(', ')}`);
130
- }
131
- if (breadcrumb.workflow) {
132
- lines.push(
133
- `Workflow: ${breadcrumb.workflow.id} (${breadcrumb.workflow.kind}/${breadcrumb.workflow.status})`,
134
- `Step: ${breadcrumb.workflow.current_step || 'finalize'}`,
135
- );
136
- }
137
- if (breadcrumb.decision) {
138
- const current = breadcrumb.decision.current;
139
- lines.push(
140
- `Decision thread: ${breadcrumb.decision.thread_id} (${breadcrumb.decision.status}/${breadcrumb.decision.mode})`,
141
- current
142
- ? `Decision: ${current.id} — ${compactText(current.question)}`
143
- : (breadcrumb.decision.status === 'checkpoint_ready'
144
- ? 'Decision: checkpoint confirmation required'
145
- : 'Decision: normalized intent recorded; application and read-back remain'),
146
- );
147
- }
148
- for (const accepted of (breadcrumb.accepted_intent || []).slice(0, 3)) {
149
- const value = accepted.decision
150
- || (accepted.consequences ? `Deferred: ${accepted.consequences}` : 'Normalized state recorded');
151
- lines.push(
152
- `Accepted intent: ${accepted.decision_id} [${accepted.authority || accepted.status}] ${compactText(value)}`,
153
- );
154
- }
155
- lines.push(`Allowed transitions: ${(breadcrumb.allowed_transitions || []).join(', ') || 'none'}`);
156
- if (breadcrumb.required_transition) {
157
- lines.push(`Required transition: ${breadcrumb.required_transition}`);
158
- }
159
- if (breadcrumb.context_manifest_path) {
160
- lines.push(
161
- `Context: ${breadcrumb.context_manifest_path} sha256=${breadcrumb.context_manifest_hash || 'unknown'}`,
162
- );
163
- }
164
- lines.push(
165
- 'Lifecycle authority: .ultra/.runtime/state.db; digest-bound files carry semantic and evidence content; projections remain non-authoritative.',
166
- );
167
- return lines.join('\n');
168
- }
169
-
170
- module.exports = {
171
- readProjectBreadcrumb,
172
- renderProjectBreadcrumb,
173
- };
@@ -1,284 +0,0 @@
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
- const artifacts = require('./artifact-registry.cjs');
9
-
10
- const CANDIDATE_ID = /^[a-zA-Z0-9_-]+$/;
11
- const TRANSITIONS = Object.freeze({
12
- proposed: new Set(['approved', 'rejected']),
13
- approved: new Set(['applied', 'rejected']),
14
- rejected: new Set(),
15
- applied: new Set(),
16
- });
17
-
18
- class SpecLearningError extends Error {
19
- constructor(code, message) {
20
- super(message);
21
- this.name = 'SpecLearningError';
22
- this.code = code;
23
- }
24
- }
25
-
26
- function nowIso() {
27
- return new Date().toISOString();
28
- }
29
-
30
- function rowToCandidate(row) {
31
- if (!row) return null;
32
- let evidence;
33
- try { evidence = JSON.parse(row.evidence_json); }
34
- catch (error) {
35
- throw new SpecLearningError('STATE_CORRUPT', `invalid spec-learning evidence: ${error.message}`);
36
- }
37
- let applyEvidence;
38
- try { applyEvidence = JSON.parse(row.apply_evidence_json || '[]'); }
39
- catch (error) {
40
- throw new SpecLearningError('STATE_CORRUPT', `invalid spec-learning apply evidence: ${error.message}`);
41
- }
42
- const candidate = { ...row, evidence, apply_evidence: applyEvidence };
43
- delete candidate.evidence_json;
44
- delete candidate.apply_evidence_json;
45
- return candidate;
46
- }
47
-
48
- function listSpecLearning(db, changeId) {
49
- return db.prepare(
50
- 'SELECT * FROM spec_learning_candidates WHERE change_id = ? ORDER BY proposed_at ASC, id ASC',
51
- ).all(changeId).map(rowToCandidate);
52
- }
53
-
54
- function writeAtomic(file, contents) {
55
- fs.mkdirSync(path.dirname(file), { recursive: true });
56
- const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
57
- fs.writeFileSync(temporary, contents);
58
- fs.renameSync(temporary, file);
59
- }
60
-
61
- function upsertProjectionArtifact(db, change, rootDir, candidates) {
62
- const file = path.resolve(rootDir, change.artifact_root, 'spec-learning.json');
63
- const generatedAt = candidates.reduce((latest, candidate) => (
64
- [candidate.applied_at, candidate.resolved_at, candidate.proposed_at]
65
- .filter(Boolean)
66
- .reduce((value, timestamp) => (timestamp > value ? timestamp : value), latest)
67
- ), '1970-01-01T00:00:00.000Z');
68
- const payload = {
69
- schema_version: '1.0',
70
- source: '.ultra/.runtime/state.db',
71
- change_id: change.id,
72
- generated_at: generatedAt,
73
- candidates,
74
- };
75
- const serialized = `${JSON.stringify(payload, null, 2)}\n`;
76
- writeAtomic(file, serialized);
77
- const relative = path.relative(rootDir, file);
78
- const hash = crypto.createHash('sha256').update(serialized).digest('hex');
79
- const workflow = db.prepare(
80
- `SELECT id FROM workflow_runs WHERE change_id = ?
81
- ORDER BY started_at DESC, rowid DESC LIMIT 1`,
82
- ).get(change.id);
83
- if (!workflow) {
84
- throw new SpecLearningError(
85
- 'ARTIFACT_CONSUMER_MISSING',
86
- `change ${change.id} has no workflow consumer for spec learning`,
87
- );
88
- }
89
- const taskConsumers = [...new Set(candidates.map((candidate) => candidate.task_id).filter(Boolean))]
90
- .map((taskId) => ({ type: 'task', id: taskId, relation: 'consumed_by' }));
91
- artifacts.recordArtifactInTx(db, {
92
- owner_type: 'change',
93
- owner_id: change.id,
94
- kind: 'spec_learning',
95
- path: relative,
96
- content_digest: hash,
97
- source_refs: [{ type: 'change', id: change.id, relation: 'produced_for' }],
98
- consumer_refs: [
99
- { type: 'workflow', id: workflow.id, relation: 'consumed_by' },
100
- ...taskConsumers,
101
- ],
102
- provenance: { writer: 'spec-learning', workflow_run_id: workflow.id },
103
- metadata: { candidates: candidates.length },
104
- }, { rootDir });
105
- return { path: file, hash };
106
- }
107
-
108
- function mutableChange(db, changeId) {
109
- const change = db.prepare('SELECT * FROM changes WHERE id = ?').get(changeId);
110
- if (!change) throw new SpecLearningError('CHANGE_NOT_FOUND', `change ${changeId} not found`);
111
- if (!['active', 'blocked'].includes(change.status)) {
112
- throw new SpecLearningError(
113
- 'CHANGE_NOT_MUTABLE', `change ${changeId} is ${change.status}; learning must resolve before convergence`,
114
- );
115
- }
116
- return change;
117
- }
118
-
119
- function normalizeEvidence(value) {
120
- if (value === undefined) return [];
121
- if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
122
- throw new SpecLearningError('VALIDATION_ERROR', 'spec-learning evidence must be non-empty strings');
123
- }
124
- return [...new Set(value.map((entry) => entry.trim()))];
125
- }
126
-
127
- function targetFile(rootDir, targetRef) {
128
- const separator = String(targetRef || '').lastIndexOf('#');
129
- if (separator <= 0 || separator === targetRef.length - 1) {
130
- throw new SpecLearningError('VALIDATION_ERROR', 'target_ref must use project-relative path#anchor');
131
- }
132
- const relative = targetRef.slice(0, separator);
133
- if (path.isAbsolute(relative)) {
134
- throw new SpecLearningError('VALIDATION_ERROR', 'target_ref must be project-relative');
135
- }
136
- const root = path.resolve(rootDir);
137
- const file = path.resolve(root, relative);
138
- if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
139
- throw new SpecLearningError('VALIDATION_ERROR', 'target_ref escapes the project root');
140
- }
141
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
142
- throw new SpecLearningError('LEARNING_TARGET_MISSING', `target file does not exist: ${relative}`);
143
- }
144
- return { file, relative };
145
- }
146
-
147
- function digestFile(file) {
148
- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
149
- }
150
-
151
- function verifyApplyEvidence(input, current, rootDir) {
152
- const appliedRef = typeof input.applied_ref === 'string' ? input.applied_ref.trim() : '';
153
- const beforeDigest = typeof input.before_digest === 'string' ? input.before_digest.trim() : '';
154
- const afterDigest = typeof input.after_digest === 'string' ? input.after_digest.trim() : '';
155
- const evidence = normalizeEvidence(input.apply_evidence);
156
- if (appliedRef !== current.target_ref || !/^[0-9a-f]{64}$/.test(beforeDigest)
157
- || !/^[0-9a-f]{64}$/.test(afterDigest) || beforeDigest === afterDigest
158
- || evidence.length === 0) {
159
- throw new SpecLearningError(
160
- 'LEARNING_APPLY_EVIDENCE_REQUIRED',
161
- 'apply requires the exact target ref, distinct before/after digests, and durable evidence refs',
162
- );
163
- }
164
- if (current.before_digest && current.before_digest !== beforeDigest) {
165
- throw new SpecLearningError(
166
- 'LEARNING_APPLY_EVIDENCE_MISMATCH', 'before_digest does not match the proposed target state',
167
- );
168
- }
169
- const target = targetFile(rootDir, appliedRef);
170
- if (digestFile(target.file) !== afterDigest) {
171
- throw new SpecLearningError(
172
- 'LEARNING_APPLY_EVIDENCE_MISMATCH', 'after_digest does not match the current target file',
173
- );
174
- }
175
- const workflows = require('./workflow-state.cjs');
176
- try {
177
- workflows.resolveProjectSourceRef(rootDir, appliedRef, 'applied_ref');
178
- for (const ref of evidence) workflows.resolveProjectSourceRef(rootDir, ref, 'apply_evidence');
179
- } catch (error) {
180
- throw new SpecLearningError(
181
- 'LEARNING_APPLY_EVIDENCE_MISMATCH', `applied target or evidence anchor is invalid: ${error.message}`,
182
- );
183
- }
184
- return { appliedRef, beforeDigest, afterDigest, evidence };
185
- }
186
-
187
- function proposeSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
188
- const change = mutableChange(db, input.change_id);
189
- const id = String(input.id || `learning-${crypto.randomUUID().slice(0, 12)}`).trim();
190
- if (!CANDIDATE_ID.test(id)) {
191
- throw new SpecLearningError('VALIDATION_ERROR', `invalid spec-learning candidate id: ${id}`);
192
- }
193
- const targetRef = typeof input.target_ref === 'string' ? input.target_ref.trim() : '';
194
- const summary = typeof input.summary === 'string' ? input.summary.trim() : '';
195
- if (!targetRef || !summary) {
196
- throw new SpecLearningError('VALIDATION_ERROR', 'target_ref and summary are required');
197
- }
198
- if (input.task_id) {
199
- const task = db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(input.task_id);
200
- if (!task || task.change_id !== change.id) {
201
- throw new SpecLearningError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
202
- }
203
- }
204
- const evidence = normalizeEvidence(input.evidence);
205
- const proposedTarget = targetFile(rootDir, targetRef);
206
- const proposedDigest = digestFile(proposedTarget.file);
207
- const proposedAt = nowIso();
208
- return ops.tx(db, () => {
209
- try {
210
- db.prepare(
211
- `INSERT INTO spec_learning_candidates
212
- (id, change_id, task_id, target_ref, summary, evidence_json, proposed_at)
213
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
214
- ).run(id, change.id, input.task_id || null, targetRef, summary, JSON.stringify(evidence), proposedAt);
215
- db.prepare(
216
- 'UPDATE spec_learning_candidates SET before_digest = ? WHERE id = ? AND change_id = ?',
217
- ).run(proposedDigest, id, change.id);
218
- } catch (error) {
219
- if (String(error.message).includes('UNIQUE')) {
220
- throw new SpecLearningError('DUPLICATE_LEARNING_ID', `spec-learning candidate ${id} exists`);
221
- }
222
- throw error;
223
- }
224
- ops.appendEventInTx(db, {
225
- type: 'spec_learning_proposed', change_id: change.id, task_id: input.task_id || null,
226
- payload: { candidate_id: id, target_ref: targetRef },
227
- });
228
- const candidates = listSpecLearning(db, change.id);
229
- upsertProjectionArtifact(db, change, rootDir, candidates);
230
- return candidates.find((candidate) => candidate.id === id);
231
- });
232
- }
233
-
234
- function resolveSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
235
- const change = mutableChange(db, input.change_id);
236
- const current = rowToCandidate(db.prepare(
237
- 'SELECT * FROM spec_learning_candidates WHERE id = ? AND change_id = ?',
238
- ).get(input.candidate_id, change.id));
239
- if (!current) {
240
- throw new SpecLearningError('LEARNING_NOT_FOUND', `candidate ${input.candidate_id} not found`);
241
- }
242
- const status = { approve: 'approved', reject: 'rejected', apply: 'applied' }[input.decision];
243
- if (!status) throw new SpecLearningError('VALIDATION_ERROR', `invalid learning decision: ${input.decision}`);
244
- if (!TRANSITIONS[current.status].has(status)) {
245
- throw new SpecLearningError(
246
- 'ILLEGAL_LEARNING_TRANSITION', `cannot move candidate ${current.id} from ${current.status} to ${status}`,
247
- );
248
- }
249
- const resolution = typeof input.resolution === 'string' ? input.resolution.trim() : '';
250
- if (['rejected', 'applied'].includes(status) && !resolution) {
251
- throw new SpecLearningError('VALIDATION_ERROR', `${input.decision} requires a resolution`);
252
- }
253
- const timestamp = nowIso();
254
- const application = status === 'applied'
255
- ? verifyApplyEvidence(input, current, rootDir)
256
- : null;
257
- return ops.tx(db, () => {
258
- db.prepare(
259
- `UPDATE spec_learning_candidates
260
- SET status = ?, resolution = ?, resolved_at = ?, applied_at = ?, applied_ref = ?,
261
- before_digest = COALESCE(?, before_digest), after_digest = ?, apply_evidence_json = ?
262
- WHERE id = ? AND change_id = ?`,
263
- ).run(
264
- status, resolution || null, timestamp, status === 'applied' ? timestamp : null,
265
- application?.appliedRef || null, application?.beforeDigest || null,
266
- application?.afterDigest || null, JSON.stringify(application?.evidence || []),
267
- current.id, change.id,
268
- );
269
- ops.appendEventInTx(db, {
270
- type: 'spec_learning_resolved', change_id: change.id, task_id: current.task_id,
271
- payload: { candidate_id: current.id, decision: input.decision, status },
272
- });
273
- const candidates = listSpecLearning(db, change.id);
274
- upsertProjectionArtifact(db, change, rootDir, candidates);
275
- return candidates.find((candidate) => candidate.id === current.id);
276
- });
277
- }
278
-
279
- module.exports = {
280
- SpecLearningError,
281
- listSpecLearning,
282
- proposeSpecLearning,
283
- resolveSpecLearning,
284
- };