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.
- package/CHANGELOG.md +79 -1
- package/README.md +45 -26
- package/adapters/_shared/codex-assets.cjs +38 -0
- package/adapters/_shared/handbook.cjs +10 -1
- package/adapters/_shared/path-rewrite.cjs +3 -0
- package/adapters/_shared/runtime-assets.cjs +3 -1
- package/adapters/_shared/tests/handbook.test.cjs +9 -1
- package/adapters/_shared/tests/path-rewrite.test.cjs +3 -1
- package/adapters/_shared/tests/runtime-assets.test.cjs +5 -1
- package/adapters/kimi.js +642 -0
- package/adapters/opencode.js +46 -17
- package/adapters/tests/claude.test.cjs +4 -1
- package/adapters/tests/codex.test.cjs +4 -3
- package/adapters/tests/kimi-hook.test.cjs +122 -0
- package/adapters/tests/kimi.test.cjs +259 -0
- package/adapters/tests/opencode.test.cjs +46 -3
- package/bin/handbook.js +2 -2
- package/bin/install.js +9 -4
- package/bin/orchestrator.js +1 -1
- package/docs/AGENT-CONTEXT.md +39 -15
- package/docs/ARCHITECTURE.md +35 -9
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +8 -7
- package/docs/RUNTIME-COMPAT-MATRIX.md +110 -64
- package/docs/USER-HANDBOOK-CONTRACT.md +13 -3
- package/hooks/README.md +6 -4
- package/hooks/active_task_context.py +14 -0
- package/hooks/adapters/kimi.py +162 -0
- package/hooks/context_spine.py +218 -0
- package/hooks/health_check.py +1 -1
- package/hooks/workflow_context.py +16 -123
- package/hooks/workflow_resume.py +21 -0
- package/mcp-server/lib/change-workflow.cjs +89 -11
- package/mcp-server/lib/change-workflow.test.cjs +202 -7
- package/mcp-server/lib/context-spine.cjs +331 -0
- package/mcp-server/lib/context-spine.test.cjs +73 -0
- package/mcp-server/lib/spec-learning.cjs +191 -0
- package/mcp-server/lib/spec-learning.test.cjs +62 -0
- package/mcp-server/lib/state-db.cjs +149 -4
- package/mcp-server/lib/state-db.test.cjs +61 -0
- package/mcp-server/server.cjs +31 -1
- package/mcp-server/tests/db-ops.test.cjs +3 -3
- package/mcp-server/tests/init-project.test.cjs +2 -2
- package/mcp-server/tests/server-contract.test.cjs +74 -0
- package/mcp-server/tests/state-db.test.cjs +20 -3
- package/package.json +4 -2
- package/skills/learn/SKILL.md +1 -0
- package/skills/ultra-change/SKILL.md +91 -118
- package/skills/ultra-deliver/SKILL.md +93 -221
- package/skills/ultra-dev/SKILL.md +81 -310
- package/skills/ultra-doctor/SKILL.md +1 -0
- package/skills/ultra-init/SKILL.md +1 -0
- package/skills/ultra-plan/SKILL.md +72 -292
- package/skills/ultra-review/SKILL.md +79 -407
- package/skills/ultra-status/SKILL.md +55 -168
- package/skills/ultra-test/SKILL.md +64 -183
- package/skills/ultra-think/SKILL.md +1 -0
- package/spec/cli-protocol.md +3 -0
- package/spec/mcp-tools.yaml +136 -7
- package/spec/schemas/skill-manifest.schema.json +2 -2
- package/spec/schemas/state-db.sql +35 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const ROLES = new Set(['plan', 'implement', 'check', 'review']);
|
|
9
|
+
const GATES = new Set([
|
|
10
|
+
'alignment', 'planning', 'implementation', 'verification', 'review', 'convergence', 'recovery',
|
|
11
|
+
]);
|
|
12
|
+
const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
|
|
13
|
+
const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
|
|
14
|
+
const DEFAULT_BUDGET = Object.freeze({ max_tokens: 12_000, max_files: 12 });
|
|
15
|
+
|
|
16
|
+
class ContextSpineError extends Error {
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'ContextSpineError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function currentHead(rootDir) {
|
|
25
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
26
|
+
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseJson(value, fallback = {}) {
|
|
32
|
+
if (typeof value !== 'string' || !value.trim()) return fallback;
|
|
33
|
+
try { return JSON.parse(value); } catch { return fallback; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nonEmpty(value, field) {
|
|
37
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
38
|
+
if (!text) throw new ContextSpineError('VALIDATION_ERROR', `${field} must be a non-empty string`);
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function localPath(rootDir, ref) {
|
|
43
|
+
const candidate = ref.split('#', 1)[0];
|
|
44
|
+
if (!candidate || path.isAbsolute(candidate)) {
|
|
45
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context ref must be project-relative: ${ref}`);
|
|
46
|
+
}
|
|
47
|
+
const root = path.resolve(rootDir);
|
|
48
|
+
const resolved = path.resolve(root, candidate);
|
|
49
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
50
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context ref escapes project root: ${ref}`);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeBudget(value, inherited) {
|
|
56
|
+
const source = value || inherited || DEFAULT_BUDGET;
|
|
57
|
+
const maxTokens = Number(source.max_tokens ?? DEFAULT_BUDGET.max_tokens);
|
|
58
|
+
const maxFiles = Number(source.max_files ?? DEFAULT_BUDGET.max_files);
|
|
59
|
+
if (!Number.isInteger(maxTokens) || maxTokens < 1 || !Number.isInteger(maxFiles) || maxFiles < 1) {
|
|
60
|
+
throw new ContextSpineError('VALIDATION_ERROR', 'context budget must use positive integer max_tokens/max_files');
|
|
61
|
+
}
|
|
62
|
+
return { max_tokens: maxTokens, max_files: maxFiles };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function specRefToContextRef(value, role) {
|
|
66
|
+
const ref = typeof value === 'string'
|
|
67
|
+
? value
|
|
68
|
+
: value && (value.ref || value.path || value.reference);
|
|
69
|
+
if (!ref) throw new ContextSpineError('VALIDATION_ERROR', 'spec_refs entries require path/ref/reference');
|
|
70
|
+
return {
|
|
71
|
+
ref,
|
|
72
|
+
kind: 'spec',
|
|
73
|
+
reason: (value && value.reason) || `Specification evidence for ${role}`,
|
|
74
|
+
required: value?.required !== false,
|
|
75
|
+
digest: value && value.digest,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeRefs(input, role, inherited) {
|
|
80
|
+
let refs;
|
|
81
|
+
if (input.context_refs !== undefined) refs = input.context_refs;
|
|
82
|
+
else if (input.spec_refs !== undefined) refs = input.spec_refs.map((value) => specRefToContextRef(value, role));
|
|
83
|
+
else refs = inherited || [];
|
|
84
|
+
if (!Array.isArray(refs)) {
|
|
85
|
+
throw new ContextSpineError('VALIDATION_ERROR', 'context_refs must be an array');
|
|
86
|
+
}
|
|
87
|
+
return refs.map((value, index) => {
|
|
88
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
89
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}] must be an object`);
|
|
90
|
+
}
|
|
91
|
+
const ref = nonEmpty(value.ref || value.path || value.reference, `context_refs[${index}].ref`);
|
|
92
|
+
const kind = value.kind || 'source';
|
|
93
|
+
if (!CONTEXT_KINDS.has(kind)) {
|
|
94
|
+
throw new ContextSpineError('VALIDATION_ERROR', `unsupported context kind: ${kind}`);
|
|
95
|
+
}
|
|
96
|
+
const reason = nonEmpty(value.reason, `context_refs[${index}].reason`);
|
|
97
|
+
if (value.digest !== undefined && !/^[0-9a-f]{64}$/.test(value.digest)) {
|
|
98
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}].digest must be sha256`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ref, kind, reason, required: value.required !== false,
|
|
102
|
+
expected_digest: value.digest || null,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function inspectRef(rootDir, role, value) {
|
|
108
|
+
if (value.kind === 'external' || /^[a-z][a-z0-9+.-]*:\/\//i.test(value.ref)) {
|
|
109
|
+
return { ...value, role, status: 'external', digest: value.expected_digest, estimated_tokens: 0 };
|
|
110
|
+
}
|
|
111
|
+
const file = localPath(rootDir, value.ref);
|
|
112
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
113
|
+
return { ...value, role, status: 'missing', digest: null, estimated_tokens: 0 };
|
|
114
|
+
}
|
|
115
|
+
const content = fs.readFileSync(file);
|
|
116
|
+
const digest = crypto.createHash('sha256').update(content).digest('hex');
|
|
117
|
+
const status = value.expected_digest && value.expected_digest !== digest ? 'stale' : 'current';
|
|
118
|
+
return {
|
|
119
|
+
...value, role, status, digest,
|
|
120
|
+
estimated_tokens: Math.max(1, Math.ceil(content.byteLength / 4)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeExecutionContract(input, inherited, { taskId }) {
|
|
125
|
+
const source = input.execution_contract || inherited || null;
|
|
126
|
+
if (!taskId) return source;
|
|
127
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
|
|
128
|
+
const sliceKind = source.slice_kind || 'tracer_bullet';
|
|
129
|
+
if (!SLICE_KINDS.has(sliceKind)) {
|
|
130
|
+
throw new ContextSpineError('VALIDATION_ERROR', `unsupported slice kind: ${sliceKind}`);
|
|
131
|
+
}
|
|
132
|
+
const percent = Number(source.context_budget_percent ?? 40);
|
|
133
|
+
if (!Number.isInteger(percent) || percent < 1 || percent > 100) {
|
|
134
|
+
throw new ContextSpineError('VALIDATION_ERROR', 'context_budget_percent must be an integer from 1 to 100');
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
slice_kind: sliceKind,
|
|
138
|
+
public_seam: typeof source.public_seam === 'string' ? source.public_seam.trim() : '',
|
|
139
|
+
verification_command: typeof source.verification_command === 'string'
|
|
140
|
+
? source.verification_command.trim()
|
|
141
|
+
: '',
|
|
142
|
+
context_budget_percent: percent,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function defaultGate(role) {
|
|
147
|
+
return {
|
|
148
|
+
plan: 'planning', implement: 'implementation', check: 'verification', review: 'review',
|
|
149
|
+
}[role];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function deriveNextAction({ change, tasks, task, role, readiness, explicit }) {
|
|
153
|
+
if (readiness === 'blocked') return 'Resolve the context readiness blockers, then recompile change.context.';
|
|
154
|
+
if (explicit && String(explicit).trim()) return String(explicit).trim();
|
|
155
|
+
if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
|
|
156
|
+
if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
|
|
157
|
+
if (!task && tasks.length === 0) return `Create the first fresh-context tracer-bullet task for change ${change.id}.`;
|
|
158
|
+
if (task?.status === 'pending') return `Start task ${task.id} through ultra-dev.`;
|
|
159
|
+
if (task?.status === 'in_progress') {
|
|
160
|
+
if (role === 'check') return `Run the exact verification command for task ${task.id}.`;
|
|
161
|
+
if (role === 'review') return `Complete independent spec-fidelity and engineering-standards review for task ${task.id}.`;
|
|
162
|
+
return `Continue the approved vertical slice for task ${task.id}.`;
|
|
163
|
+
}
|
|
164
|
+
if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
|
|
165
|
+
return `Run ultra-test for change ${change.id}.`;
|
|
166
|
+
}
|
|
167
|
+
const pending = tasks.find((row) => row.status === 'pending');
|
|
168
|
+
return pending ? `Compile implement context for task ${pending.id}.` : `Inspect blockers for change ${change.id}.`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function latestContext(db, changeId, taskId) {
|
|
172
|
+
const row = taskId
|
|
173
|
+
? db.prepare(
|
|
174
|
+
'SELECT context_json FROM context_snapshots WHERE change_id = ? AND task_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
175
|
+
).get(changeId, taskId)
|
|
176
|
+
: db.prepare(
|
|
177
|
+
'SELECT context_json FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
178
|
+
).get(changeId);
|
|
179
|
+
return parseJson(row?.context_json, {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function compileRoleContext(db, { input, change, tasks, rootDir }) {
|
|
183
|
+
const task = input.task_id ? tasks.find((row) => row.id === input.task_id) : null;
|
|
184
|
+
if (input.task_id && !task) {
|
|
185
|
+
throw new ContextSpineError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
|
|
186
|
+
}
|
|
187
|
+
const inherited = latestContext(db, change.id, input.task_id || null);
|
|
188
|
+
const role = input.role || (input.task_id ? 'implement' : 'plan');
|
|
189
|
+
if (!ROLES.has(role)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported context role: ${role}`);
|
|
190
|
+
const gate = input.gate || defaultGate(role);
|
|
191
|
+
if (!GATES.has(gate)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported workflow gate: ${gate}`);
|
|
192
|
+
|
|
193
|
+
const refs = normalizeRefs(input, role, inherited.context?.items);
|
|
194
|
+
const items = refs.map((value) => inspectRef(rootDir, role, value));
|
|
195
|
+
const budget = normalizeBudget(input.budget, inherited.context?.budget);
|
|
196
|
+
const tokenEstimate = items.reduce((sum, item) => sum + item.estimated_tokens, 0);
|
|
197
|
+
const fileCount = items.filter((item) => item.status !== 'external').length;
|
|
198
|
+
const executionContract = normalizeExecutionContract(
|
|
199
|
+
input, inherited.execution_contract, { taskId: input.task_id },
|
|
200
|
+
);
|
|
201
|
+
const blockers = [];
|
|
202
|
+
for (const item of items) {
|
|
203
|
+
if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
|
|
204
|
+
if (item.required && item.status === 'stale') blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
|
|
205
|
+
}
|
|
206
|
+
if (fileCount > budget.max_files) blockers.push('CONTEXT_FILE_BUDGET_EXCEEDED');
|
|
207
|
+
if (tokenEstimate > budget.max_tokens) blockers.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
|
|
208
|
+
if (input.task_id) {
|
|
209
|
+
if (!executionContract) blockers.push('EXECUTION_CONTRACT_MISSING');
|
|
210
|
+
else {
|
|
211
|
+
if (!executionContract.public_seam) blockers.push('EXECUTION_PUBLIC_SEAM_MISSING');
|
|
212
|
+
if (!executionContract.verification_command) blockers.push('EXECUTION_VERIFICATION_COMMAND_MISSING');
|
|
213
|
+
if (executionContract.slice_kind !== 'expand_contract'
|
|
214
|
+
&& executionContract.context_budget_percent > 40) {
|
|
215
|
+
blockers.push('EXECUTION_CONTEXT_BUDGET_EXCEEDED');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const readiness = blockers.length === 0 ? 'ready' : 'blocked';
|
|
220
|
+
const nextAction = deriveNextAction({
|
|
221
|
+
change, tasks, task, role, readiness, explicit: input.next_action,
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
role,
|
|
225
|
+
gate,
|
|
226
|
+
next_action: nextAction,
|
|
227
|
+
readiness: { status: readiness, blockers },
|
|
228
|
+
context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
|
|
229
|
+
execution_contract: executionContract,
|
|
230
|
+
selected_task: task,
|
|
231
|
+
resume: {
|
|
232
|
+
change_id: change.id,
|
|
233
|
+
task_id: task?.id || null,
|
|
234
|
+
task_status: task?.status || null,
|
|
235
|
+
session_id: task?.session_id || null,
|
|
236
|
+
role,
|
|
237
|
+
gate,
|
|
238
|
+
git_head: currentHead(rootDir),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function recommendedWorkflow(change, task, tasks, readiness) {
|
|
244
|
+
if (!change) return 'ultra-change';
|
|
245
|
+
if (readiness !== 'ready' || change.status === 'blocked') return 'ultra-change';
|
|
246
|
+
if (change.status === 'ready') return 'ultra-deliver';
|
|
247
|
+
if (task && ['pending', 'in_progress'].includes(task.status)) return 'ultra-dev';
|
|
248
|
+
if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) return 'ultra-test';
|
|
249
|
+
return 'ultra-change';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
253
|
+
const change = id
|
|
254
|
+
? db.prepare('SELECT * FROM changes WHERE id = ?').get(id)
|
|
255
|
+
: db.prepare(
|
|
256
|
+
"SELECT * FROM changes WHERE status IN ('active', 'blocked', 'ready') ORDER BY updated_at DESC LIMIT 1",
|
|
257
|
+
).get();
|
|
258
|
+
if (id && !change) {
|
|
259
|
+
throw new ContextSpineError('CHANGE_NOT_FOUND', `change ${id} not found`);
|
|
260
|
+
}
|
|
261
|
+
if (!change) {
|
|
262
|
+
return {
|
|
263
|
+
change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
|
|
264
|
+
next_action: 'Start daily work with ultra-change.', recommended_workflow: 'ultra-change',
|
|
265
|
+
context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const tasks = db.prepare('SELECT * FROM tasks WHERE change_id = ? ORDER BY created_at ASC').all(change.id);
|
|
269
|
+
const snapshot = db.prepare(
|
|
270
|
+
'SELECT * FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
271
|
+
).get(change.id);
|
|
272
|
+
const context = parseJson(snapshot?.context_json, {});
|
|
273
|
+
const taskId = snapshot?.task_id || tasks.find((row) => row.status === 'in_progress')?.id
|
|
274
|
+
|| tasks.find((row) => row.status === 'pending')?.id || null;
|
|
275
|
+
const task = tasks.find((row) => row.id === taskId) || null;
|
|
276
|
+
const head = currentHead(rootDir);
|
|
277
|
+
const snapshotMissing = !snapshot;
|
|
278
|
+
const legacyContext = Boolean(snapshot && (!context.resume || !context.context || !context.readiness));
|
|
279
|
+
const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
|
|
280
|
+
const stateChanged = context.resume?.task_status !== undefined
|
|
281
|
+
&& context.resume.task_status !== (task?.status || null);
|
|
282
|
+
const blockers = [];
|
|
283
|
+
if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
|
|
284
|
+
else {
|
|
285
|
+
if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
|
|
286
|
+
if (headStale) blockers.push('CONTEXT_HEAD_STALE');
|
|
287
|
+
if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
|
|
288
|
+
for (const blocker of parseJson(snapshot.blockers_json, [])) {
|
|
289
|
+
if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
|
|
293
|
+
let nextAction;
|
|
294
|
+
if (snapshotMissing) {
|
|
295
|
+
nextAction = `Compile change.context for change ${change.id} before continuing.`;
|
|
296
|
+
} else if (legacyContext) {
|
|
297
|
+
nextAction = 'Recompile change.context to upgrade this legacy context snapshot.';
|
|
298
|
+
} else if (headStale) {
|
|
299
|
+
nextAction = 'Git HEAD changed after context compilation; recompile change.context.';
|
|
300
|
+
} else if (stateChanged) {
|
|
301
|
+
nextAction = 'Task state changed after context compilation; recompile change.context.';
|
|
302
|
+
} else {
|
|
303
|
+
nextAction = deriveNextAction({
|
|
304
|
+
change, tasks, task, role: snapshot?.role || 'plan', readiness,
|
|
305
|
+
explicit: snapshot?.next_action,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
change_id: change.id,
|
|
310
|
+
change_status: change.status,
|
|
311
|
+
task_id: task?.id || null,
|
|
312
|
+
task_status: task?.status || null,
|
|
313
|
+
session_id: task?.session_id || null,
|
|
314
|
+
role: snapshot?.role || 'plan',
|
|
315
|
+
gate: snapshot?.gate || 'alignment',
|
|
316
|
+
readiness,
|
|
317
|
+
blockers,
|
|
318
|
+
next_action: nextAction,
|
|
319
|
+
recommended_workflow: recommendedWorkflow(change, task, tasks, readiness),
|
|
320
|
+
context_manifest_path: snapshot?.manifest_path || null,
|
|
321
|
+
context_manifest_hash: snapshot?.manifest_hash || null,
|
|
322
|
+
git_head: head,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
ContextSpineError,
|
|
328
|
+
compileRoleContext,
|
|
329
|
+
deriveNextAction,
|
|
330
|
+
readBreadcrumb,
|
|
331
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
9
|
+
const { compileRoleContext, deriveNextAction, readBreadcrumb } = require('./context-spine.cjs');
|
|
10
|
+
const { initStateDb } = require('./state-db.cjs');
|
|
11
|
+
const { createChange } = require('./change-workflow.cjs');
|
|
12
|
+
const { createTask } = require('./state-ops.cjs');
|
|
13
|
+
|
|
14
|
+
test('deriveNextAction gives one state-specific action instead of a workflow dump', () => {
|
|
15
|
+
const change = { id: 'context-spine', status: 'active' };
|
|
16
|
+
const task = { id: 'task-1', status: 'in_progress' };
|
|
17
|
+
const action = deriveNextAction({
|
|
18
|
+
change, tasks: [task], task, role: 'review', readiness: 'ready', explicit: null,
|
|
19
|
+
});
|
|
20
|
+
assert.equal(
|
|
21
|
+
action,
|
|
22
|
+
'Complete independent spec-fidelity and engineering-standards review for task task-1.',
|
|
23
|
+
);
|
|
24
|
+
assert.doesNotMatch(action, /plan.*implement.*check.*review/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('deriveNextAction fails closed on blocked context readiness', () => {
|
|
28
|
+
const action = deriveNextAction({
|
|
29
|
+
change: { id: 'context-spine', status: 'active' },
|
|
30
|
+
tasks: [], task: null, role: 'plan', readiness: 'blocked', explicit: 'Ignore blockers.',
|
|
31
|
+
});
|
|
32
|
+
assert.match(action, /Resolve the context readiness blockers/);
|
|
33
|
+
assert.doesNotMatch(action, /Ignore blockers/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a task is not plan-ready before its execution contract is compiled', () => {
|
|
37
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-plan-'));
|
|
38
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
39
|
+
try {
|
|
40
|
+
const { change } = createChange(db, {
|
|
41
|
+
id: 'planning-contract', title: 'Planning contract', kind: 'standard',
|
|
42
|
+
intent: 'Require an executable fresh-context slice before implementation.',
|
|
43
|
+
docs_impact: { status: 'none', rationale: 'test fixture only' },
|
|
44
|
+
}, { rootDir });
|
|
45
|
+
const task = createTask(db, {
|
|
46
|
+
id: 'task-plan', title: 'Plan one slice', type: 'feature', priority: 'P0',
|
|
47
|
+
change_id: change.id,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const context = compileRoleContext(db, {
|
|
51
|
+
input: { task_id: task.id, role: 'plan', gate: 'planning' },
|
|
52
|
+
change, tasks: [task], rootDir,
|
|
53
|
+
});
|
|
54
|
+
assert.equal(context.readiness.status, 'blocked');
|
|
55
|
+
assert.ok(context.readiness.blockers.includes('EXECUTION_CONTRACT_MISSING'));
|
|
56
|
+
|
|
57
|
+
const breadcrumb = readBreadcrumb(db, { id: change.id }, { rootDir });
|
|
58
|
+
assert.equal(breadcrumb.readiness, 'blocked');
|
|
59
|
+
assert.ok(breadcrumb.blockers.includes('CONTEXT_TASK_STATE_STALE'));
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => readBreadcrumb(db, { id: 'missing-change' }, { rootDir }),
|
|
62
|
+
(error) => error.code === 'CHANGE_NOT_FOUND',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
db.prepare("UPDATE context_snapshots SET context_json = '{}', readiness = 'ready'").run();
|
|
66
|
+
const migratedLegacy = readBreadcrumb(db, { id: change.id }, { rootDir });
|
|
67
|
+
assert.equal(migratedLegacy.readiness, 'blocked');
|
|
68
|
+
assert.ok(migratedLegacy.blockers.includes('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED'));
|
|
69
|
+
} finally {
|
|
70
|
+
db.close();
|
|
71
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
@@ -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
|
+
});
|