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,1208 +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
- const { spawnSync } = require('node:child_process');
7
- const baselines = require('./baseline-workflow.cjs');
8
- const workflows = require('./workflow-state.cjs');
9
- const decisions = require('./decision-dialogue.cjs');
10
- const ops = require('./state-ops.cjs');
11
-
12
- const ROLES = new Set(['plan', 'implement', 'check', 'review']);
13
- const GATES = new Set([
14
- 'alignment', 'planning', 'implementation', 'verification', 'review', 'convergence', 'recovery',
15
- ]);
16
- const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
17
- const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
18
- const DEFAULT_BUDGET = Object.freeze({ max_tokens: 12_000, max_files: 12 });
19
- const MAX_CONTEXT_REFS = 64;
20
- const MAX_REF_LENGTH = 2_048;
21
- const MAX_REASON_LENGTH = 2_048;
22
- const MAX_LOCATOR_LENGTH = 512;
23
- const SPEC_REF_FIELDS = new Set([
24
- 'ref', 'reason', 'required', 'expected_digest', 'anchor', 'scope', 'freshness_policy',
25
- ]);
26
- const ADVISORY_CONTEXT_CODES = new Set([
27
- 'CONTEXT_FILE_BUDGET_EXCEEDED',
28
- 'CONTEXT_TOKEN_BUDGET_EXCEEDED',
29
- 'EXECUTION_CONTEXT_BUDGET_EXCEEDED',
30
- 'EXECUTION_CONTEXT_BUDGET_ADVISORY',
31
- ]);
32
- const ACTIVE_CHANGE_BASELINE_DRIFT = Object.freeze([
33
- /^BASELINE_HEAD_STALE$/,
34
- /^BASELINE_BRANCH_STALE$/,
35
- /^BASELINE_WORKSPACE_STALE$/,
36
- /^BASELINE_WORKTREE_STALE$/,
37
- /^BASELINE_WORKTREE_DIRTY$/,
38
- /^BASELINE_SPEC_STALE:/,
39
- /^BASELINE_EVIDENCE_STALE:/,
40
- ]);
41
-
42
- class ContextSpineError extends Error {
43
- constructor(code, message) {
44
- super(message);
45
- this.name = 'ContextSpineError';
46
- this.code = code;
47
- }
48
- }
49
-
50
- function currentHead(rootDir) {
51
- const result = spawnSync('git', ['rev-parse', 'HEAD'], {
52
- cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
53
- });
54
- return result.status === 0 ? result.stdout.trim() : null;
55
- }
56
-
57
- function parseJson(value, fallback = {}) {
58
- if (typeof value !== 'string' || !value.trim()) return fallback;
59
- try { return JSON.parse(value); } catch { return fallback; }
60
- }
61
-
62
- function uniqueStrings(values) {
63
- return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
64
- }
65
-
66
- function hasIncidentBreakGlass(change) {
67
- const bypass = change?.baseline_bypass
68
- || parseJson(change?.baseline_bypass_json, null);
69
- return change?.kind === 'incident'
70
- && bypass?.mode === 'incident_break_glass'
71
- && Boolean(String(bypass.reason || '').trim())
72
- && Boolean(String(bypass.approved_by || '').trim());
73
- }
74
-
75
- function expectedActiveChangeDrift(code) {
76
- return ACTIVE_CHANGE_BASELINE_DRIFT.some((pattern) => pattern.test(code));
77
- }
78
-
79
- function baselineGateForChange(db, change, baselineHealth) {
80
- const baseWarnings = Array.isArray(baselineHealth.warnings) ? baselineHealth.warnings : [];
81
- if (baselineHealth.status === 'pass') {
82
- return { blockers: [], warnings: [...new Set(baseWarnings)], mode: 'healthy' };
83
- }
84
- const healthBlockers = Array.isArray(baselineHealth.blockers)
85
- ? baselineHealth.blockers : ['BASELINE_STATE_UNAVAILABLE'];
86
- if (hasIncidentBreakGlass(change)) {
87
- return {
88
- blockers: [], warnings: [...new Set([...healthBlockers, ...baseWarnings])],
89
- mode: 'incident_break_glass',
90
- };
91
- }
92
- const baseline = baselineHealth.baseline;
93
- if (!baseline || baseline.status !== 'ready' || baseline.mode === 'migrated') {
94
- return { blockers: healthBlockers, warnings: baseWarnings, mode: 'baseline_required' };
95
- }
96
- const binding = db.prepare(
97
- `SELECT wr.baseline_id, ws.status AS binding_status, ws.evidence_json
98
- FROM workflow_runs wr
99
- LEFT JOIN workflow_steps ws
100
- ON ws.run_id = wr.id AND ws.step_id = 'bind-baseline'
101
- WHERE wr.kind = 'change' AND wr.change_id = ?
102
- ORDER BY wr.started_at DESC, wr.rowid DESC LIMIT 1`,
103
- ).get(change.id);
104
- let evidence = [];
105
- try { evidence = JSON.parse(binding?.evidence_json || '[]'); } catch { evidence = []; }
106
- const boundFromReadyAuthority = binding?.baseline_id === baseline.id
107
- && binding.binding_status === 'completed'
108
- && evidence.some((item) => item?.ref === baseline.id);
109
- if (!boundFromReadyAuthority) {
110
- return { blockers: healthBlockers, warnings: baseWarnings, mode: 'baseline_binding_required' };
111
- }
112
- const blockers = healthBlockers.filter((code) => !expectedActiveChangeDrift(code));
113
- const driftWarnings = healthBlockers.filter(expectedActiveChangeDrift);
114
- return {
115
- blockers,
116
- warnings: [...new Set([...driftWarnings, ...baseWarnings])],
117
- mode: blockers.length === 0 ? 'active_change_drift' : 'baseline_invalid',
118
- };
119
- }
120
-
121
- function taskStateDigest(task) {
122
- if (!task) return null;
123
- const payload = {
124
- id: task.id,
125
- change_id: task.change_id,
126
- status: task.status,
127
- stale: Boolean(task.stale),
128
- deps: task.deps || [],
129
- outcome: task.outcome,
130
- slice_kind: task.slice_kind,
131
- public_seam: task.public_seam,
132
- verification_command: task.verification_command,
133
- acceptance: task.acceptance || [],
134
- context_refs: task.context_refs || [],
135
- docs_impact: task.docs_impact || {},
136
- ownership: task.ownership || {},
137
- trace_to: task.trace_to,
138
- };
139
- return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
140
- }
141
-
142
- function taskContractDigest(task) {
143
- if (!task) return null;
144
- const payload = {
145
- id: task.id,
146
- change_id: task.change_id,
147
- deps: task.deps || [],
148
- files_modified: task.files_modified || [],
149
- outcome: task.outcome,
150
- slice_kind: task.slice_kind,
151
- public_seam: task.public_seam,
152
- verification_command: task.verification_command,
153
- acceptance: task.acceptance || [],
154
- context_refs: task.context_refs || [],
155
- docs_impact: task.docs_impact || {},
156
- ownership: task.ownership || {},
157
- trace_to: task.trace_to,
158
- };
159
- return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
160
- }
161
-
162
- function changeStateDigest(change) {
163
- if (!change) return null;
164
- const payload = {
165
- id: change.id,
166
- kind: change.kind,
167
- intent: change.intent,
168
- docs_impact: change.docs_impact || parseJson(change.docs_impact_json, {}),
169
- provider_refs: change.provider_refs || parseJson(change.provider_refs_json, {}),
170
- contract: change.contract || parseJson(change.contract_json, {}),
171
- classification: change.classification || parseJson(change.classification_json, {}),
172
- research_disposition: change.research_disposition
173
- || parseJson(change.research_disposition_json, {}),
174
- };
175
- return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
176
- }
177
-
178
- function nonEmpty(value, field) {
179
- const text = typeof value === 'string' ? value.trim() : '';
180
- if (!text) throw new ContextSpineError('VALIDATION_ERROR', `${field} must be a non-empty string`);
181
- return text;
182
- }
183
-
184
- function boundedText(value, field, maxLength) {
185
- const text = nonEmpty(value, field);
186
- if (text.length > maxLength) {
187
- throw new ContextSpineError(
188
- 'VALIDATION_ERROR', `${field} must be at most ${maxLength} characters`,
189
- );
190
- }
191
- return text;
192
- }
193
-
194
- function localPath(rootDir, ref) {
195
- const candidate = ref.split('#', 1)[0];
196
- if (!candidate || path.isAbsolute(candidate)
197
- || /^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) {
198
- throw new ContextSpineError('VALIDATION_ERROR', `context ref must be project-relative: ${ref}`);
199
- }
200
- const root = path.resolve(rootDir);
201
- const resolved = path.resolve(root, candidate);
202
- if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
203
- throw new ContextSpineError('VALIDATION_ERROR', `context ref escapes project root: ${ref}`);
204
- }
205
- return resolved;
206
- }
207
-
208
- function contextPathUnsafe(ref, message) {
209
- return new ContextSpineError(
210
- 'CONTEXT_REF_UNSAFE',
211
- `${message}: ${ref}`,
212
- );
213
- }
214
-
215
- function lstatOrNull(candidate) {
216
- try { return fs.lstatSync(candidate); } catch (error) {
217
- if (error?.code === 'ENOENT') return null;
218
- throw error;
219
- }
220
- }
221
-
222
- function sameEntry(left, right) {
223
- return Boolean(left && right
224
- && String(left.dev) === String(right.dev)
225
- && String(left.ino) === String(right.ino));
226
- }
227
-
228
- function physicallyContained(root, candidate) {
229
- return candidate === root || candidate.startsWith(`${root}${path.sep}`);
230
- }
231
-
232
- function readProjectFile(rootDir, ref) {
233
- const file = localPath(rootDir, ref);
234
- const root = path.resolve(rootDir);
235
- const rootStat = lstatOrNull(root);
236
- if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink()) {
237
- throw contextPathUnsafe(ref, 'project root must be a real directory');
238
- }
239
- const physicalRoot = fs.realpathSync(root);
240
- const identities = [{ file: root, stat: rootStat, directory: true }];
241
- let current = root;
242
- const segments = path.relative(root, file).split(path.sep).filter(Boolean);
243
- for (const [index, segment] of segments.entries()) {
244
- current = path.join(current, segment);
245
- const stat = lstatOrNull(current);
246
- if (!stat) return null;
247
- const final = index === segments.length - 1;
248
- if (stat.isSymbolicLink()) {
249
- throw contextPathUnsafe(ref, 'local Context refs cannot traverse symbolic links');
250
- }
251
- if ((!final && !stat.isDirectory()) || (final && !stat.isFile())) {
252
- throw contextPathUnsafe(
253
- ref,
254
- final
255
- ? 'local Context ref must be a regular file'
256
- : 'local Context ref ancestor must be a directory',
257
- );
258
- }
259
- identities.push({ file: current, stat, directory: !final });
260
- }
261
- let physicalFile;
262
- try { physicalFile = fs.realpathSync(file); } catch (error) {
263
- if (error?.code === 'ENOENT') return null;
264
- throw error;
265
- }
266
- if (!physicallyContained(physicalRoot, physicalFile)) {
267
- throw contextPathUnsafe(ref, 'local Context ref escapes the physical project root');
268
- }
269
- const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
270
- let fd;
271
- try {
272
- try { fd = fs.openSync(file, flags); } catch (error) {
273
- if (error?.code === 'ENOENT') return null;
274
- if (['ELOOP', 'EMLINK'].includes(error?.code)) {
275
- throw contextPathUnsafe(ref, 'local Context ref changed to a symbolic link');
276
- }
277
- throw error;
278
- }
279
- const opened = fs.fstatSync(fd);
280
- const finalIdentity = identities[identities.length - 1]?.stat;
281
- if (!opened.isFile() || !sameEntry(opened, finalIdentity)) {
282
- throw contextPathUnsafe(ref, 'local Context ref changed while opening');
283
- }
284
- for (const identity of identities) {
285
- const currentStat = lstatOrNull(identity.file);
286
- if (!currentStat || currentStat.isSymbolicLink()
287
- || !sameEntry(currentStat, identity.stat)
288
- || (identity.directory ? !currentStat.isDirectory() : !currentStat.isFile())) {
289
- throw contextPathUnsafe(ref, 'local Context ref path changed while opening');
290
- }
291
- }
292
- const currentPhysicalRoot = fs.realpathSync(root);
293
- const currentPhysicalFile = fs.realpathSync(file);
294
- if (currentPhysicalRoot !== physicalRoot
295
- || !physicallyContained(physicalRoot, currentPhysicalFile)) {
296
- throw contextPathUnsafe(ref, 'local Context ref physical path changed while opening');
297
- }
298
- return { file, bytes: fs.readFileSync(fd) };
299
- } finally {
300
- if (fd !== undefined) fs.closeSync(fd);
301
- }
302
- }
303
-
304
- function normalizeBudget(value, inherited) {
305
- const source = value || inherited || DEFAULT_BUDGET;
306
- const maxTokens = Number(source.max_tokens ?? DEFAULT_BUDGET.max_tokens);
307
- const maxFiles = Number(source.max_files ?? DEFAULT_BUDGET.max_files);
308
- if (!Number.isInteger(maxTokens) || maxTokens < 1 || !Number.isInteger(maxFiles) || maxFiles < 1) {
309
- throw new ContextSpineError('VALIDATION_ERROR', 'context budget must use positive integer max_tokens/max_files');
310
- }
311
- return { max_tokens: maxTokens, max_files: maxFiles };
312
- }
313
-
314
- function specRefToContextRef(value, role) {
315
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
316
- throw new ContextSpineError('VALIDATION_ERROR', 'spec_refs entries must be objects');
317
- }
318
- const unknown = Object.keys(value).filter((field) => !SPEC_REF_FIELDS.has(field));
319
- if (unknown.length > 0) {
320
- throw new ContextSpineError(
321
- 'VALIDATION_ERROR', `spec_refs entry has unsupported fields: ${unknown.join(', ')}`,
322
- );
323
- }
324
- if (value.required !== undefined && typeof value.required !== 'boolean') {
325
- throw new ContextSpineError(
326
- 'VALIDATION_ERROR', 'spec_refs[].required must be a boolean',
327
- );
328
- }
329
- const ref = boundedText(value.ref, 'spec_refs[].ref', MAX_REF_LENGTH);
330
- return {
331
- ref,
332
- kind: 'spec',
333
- reason: value.reason === undefined
334
- ? `Specification evidence for ${role}`
335
- : boundedText(value.reason, 'spec_refs[].reason', MAX_REASON_LENGTH),
336
- required: value.required !== false,
337
- expected_digest: value.expected_digest,
338
- anchor: value.anchor,
339
- scope: value.scope,
340
- freshness_policy: value.freshness_policy,
341
- };
342
- }
343
-
344
- function normalizeRefs(input, role, inherited) {
345
- if (input.context_refs !== undefined && input.spec_refs !== undefined) {
346
- throw new ContextSpineError(
347
- 'VALIDATION_ERROR',
348
- 'context_refs and spec_refs are mutually exclusive; provide exactly one reference format',
349
- );
350
- }
351
- let refs;
352
- if (input.context_refs !== undefined) refs = input.context_refs;
353
- else if (input.spec_refs !== undefined) {
354
- if (!Array.isArray(input.spec_refs)) {
355
- throw new ContextSpineError('VALIDATION_ERROR', 'spec_refs must be an array');
356
- }
357
- if (input.spec_refs.length > MAX_CONTEXT_REFS) {
358
- throw new ContextSpineError(
359
- 'VALIDATION_ERROR', `spec_refs must contain at most ${MAX_CONTEXT_REFS} entries`,
360
- );
361
- }
362
- refs = input.spec_refs.map((value) => specRefToContextRef(value, role));
363
- }
364
- else refs = inherited || [];
365
- if (!Array.isArray(refs)) {
366
- throw new ContextSpineError('VALIDATION_ERROR', 'context_refs must be an array');
367
- }
368
- if (refs.length > MAX_CONTEXT_REFS) {
369
- throw new ContextSpineError(
370
- 'VALIDATION_ERROR', `context_refs must contain at most ${MAX_CONTEXT_REFS} entries`,
371
- );
372
- }
373
- return refs.map((value, index) => {
374
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
375
- throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}] must be an object`);
376
- }
377
- const ref = boundedText(
378
- value.ref || value.path || value.reference,
379
- `context_refs[${index}].ref`,
380
- MAX_REF_LENGTH,
381
- );
382
- const kind = value.kind || 'source';
383
- if (!CONTEXT_KINDS.has(kind)) {
384
- throw new ContextSpineError('VALIDATION_ERROR', `unsupported context kind: ${kind}`);
385
- }
386
- const reason = boundedText(
387
- value.reason, `context_refs[${index}].reason`, MAX_REASON_LENGTH,
388
- );
389
- const expectedDigest = value.expected_digest ?? value.digest ?? null;
390
- if (expectedDigest !== null && !/^[0-9a-f]{64}$/.test(expectedDigest)) {
391
- throw new ContextSpineError(
392
- 'VALIDATION_ERROR', `context_refs[${index}].expected_digest must be sha256`,
393
- );
394
- }
395
- const freshnessPolicy = value.freshness_policy
396
- || (expectedDigest ? 'digest' : 'existence');
397
- if (!['digest', 'existence', 'advisory'].includes(freshnessPolicy)) {
398
- throw new ContextSpineError(
399
- 'VALIDATION_ERROR',
400
- `context_refs[${index}].freshness_policy must be digest, existence, or advisory`,
401
- );
402
- }
403
- const normalized = {
404
- ref, kind, reason, required: value.required !== false,
405
- expected_digest: expectedDigest,
406
- freshness_policy: freshnessPolicy,
407
- };
408
- if (value.anchor !== undefined) {
409
- normalized.anchor = boundedText(
410
- value.anchor, `context_refs[${index}].anchor`, MAX_LOCATOR_LENGTH,
411
- );
412
- }
413
- if (value.scope !== undefined) {
414
- normalized.scope = boundedText(
415
- value.scope, `context_refs[${index}].scope`, MAX_LOCATOR_LENGTH,
416
- );
417
- }
418
- return normalized;
419
- });
420
- }
421
-
422
- function authoritativeTaskRefs(input, role, task) {
423
- if (!task) return null;
424
- const dbRefs = normalizeRefs({ context_refs: task.context_refs || [] }, role, []);
425
- if (input.context_refs === undefined && input.spec_refs === undefined) return dbRefs;
426
- const supplied = normalizeRefs(input, role, []);
427
- const comparable = (items) => items.map((item) => ({
428
- ref: item.ref,
429
- kind: item.kind,
430
- reason: item.reason,
431
- required: item.required,
432
- expected_digest: item.expected_digest,
433
- anchor: item.anchor,
434
- scope: item.scope,
435
- freshness_policy: item.freshness_policy,
436
- }));
437
- if (JSON.stringify(comparable(supplied)) !== JSON.stringify(comparable(dbRefs))) {
438
- throw new ContextSpineError(
439
- 'EXECUTION_CONTEXT_REFS_CONFLICT',
440
- `context_refs conflict with task ${task.id} authority; update the task contract first`,
441
- );
442
- }
443
- return dbRefs;
444
- }
445
-
446
- function inspectRef(rootDir, role, value) {
447
- if (value.kind === 'external') {
448
- return { ...value, role, status: 'external', digest: value.expected_digest, estimated_tokens: 0 };
449
- }
450
- const read = readProjectFile(rootDir, value.ref);
451
- if (!read) {
452
- return { ...value, role, status: 'missing', digest: null, estimated_tokens: 0 };
453
- }
454
- const content = read.bytes;
455
- const digest = crypto.createHash('sha256').update(content).digest('hex');
456
- const status = value.freshness_policy !== 'existence'
457
- && value.expected_digest && value.expected_digest !== digest ? 'stale' : 'current';
458
- return {
459
- ...value, role, status, digest,
460
- estimated_tokens: Math.max(1, Math.ceil(content.byteLength / 4)),
461
- };
462
- }
463
-
464
- function taskNeighborhood(tasks, task, role) {
465
- if (!task || role !== 'implement') return tasks;
466
- const selected = new Set([task.id, ...(task.deps || [])]);
467
- for (const candidate of tasks) {
468
- const dependencies = candidate.deps || [];
469
- if (dependencies.includes(task.id)) selected.add(candidate.id);
470
- if (candidate.slice_kind === 'integration_checkpoint'
471
- && dependencies.some((dependency) => selected.has(dependency))) {
472
- selected.add(candidate.id);
473
- }
474
- }
475
- return tasks.filter((candidate) => selected.has(candidate.id));
476
- }
477
-
478
- function taskContextContract(change, task) {
479
- if (!task) return null;
480
- const contract = change.contract || parseJson(change.contract_json, {});
481
- return {
482
- purpose: task.outcome,
483
- why: task.trace_to,
484
- constraints: contract.constraints || [],
485
- non_goals: contract.non_goals || [],
486
- target: {
487
- public_seams: [task.public_seam].filter(Boolean),
488
- files: task.files_modified || [],
489
- },
490
- pattern_refs: (task.context_refs || []).map((item) => ({
491
- ref: item.ref,
492
- reason: item.reason,
493
- anchor: item.anchor || null,
494
- scope: item.scope || null,
495
- })),
496
- acceptance: task.acceptance || [],
497
- documentation_impact: task.docs_impact || {},
498
- recovery: contract.recovery || null,
499
- definition_of_drift: {
500
- change_state_digest: changeStateDigest(change),
501
- task_contract_digest: taskContractDigest(task),
502
- required_context_freshness: (task.context_refs || []).map((item) => ({
503
- ref: item.ref,
504
- expected_digest: item.expected_digest || item.digest || null,
505
- freshness_policy: item.freshness_policy
506
- || ((item.expected_digest || item.digest) ? 'digest' : 'existence'),
507
- })),
508
- },
509
- };
510
- }
511
-
512
- function estimateInlineTokens(value) {
513
- return Math.max(1, Math.ceil(Buffer.byteLength(JSON.stringify(value), 'utf8') / 4));
514
- }
515
-
516
- function normalizeExecutionContract(input, inherited, { task }) {
517
- const taskContract = task
518
- && task.slice_kind && task.public_seam && task.verification_command ? {
519
- slice_kind: task.slice_kind,
520
- public_seam: task.public_seam,
521
- verification_command: task.verification_command,
522
- context_budget_percent: input.execution_contract?.context_budget_percent
523
- ?? inherited?.context_budget_percent
524
- ?? 40,
525
- } : null;
526
- if (input.execution_contract) {
527
- for (const field of ['slice_kind', 'public_seam', 'verification_command']) {
528
- if (input.execution_contract[field] !== undefined) {
529
- throw new ContextSpineError(
530
- 'EXECUTION_CONTRACT_CONFLICT',
531
- `execution_contract.${field} is owned by the task row and cannot be supplied by Prompt input`,
532
- );
533
- }
534
- }
535
- }
536
- if (!task && input.execution_contract !== undefined) {
537
- throw new ContextSpineError(
538
- 'EXECUTION_CONTRACT_CONFLICT',
539
- 'execution_contract is valid only for a DB-backed task context',
540
- );
541
- }
542
- const source = taskContract || input.execution_contract || inherited || null;
543
- const taskId = task?.id || null;
544
- if (!taskId) return null;
545
- if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
546
- const sliceKind = source.slice_kind || 'tracer_bullet';
547
- if (!SLICE_KINDS.has(sliceKind)) {
548
- throw new ContextSpineError('VALIDATION_ERROR', `unsupported slice kind: ${sliceKind}`);
549
- }
550
- const percent = Number(source.context_budget_percent ?? 40);
551
- if (!Number.isInteger(percent) || percent < 1 || percent > 100) {
552
- throw new ContextSpineError('VALIDATION_ERROR', 'context_budget_percent must be an integer from 1 to 100');
553
- }
554
- return {
555
- slice_kind: sliceKind,
556
- public_seam: typeof source.public_seam === 'string' ? source.public_seam.trim() : '',
557
- verification_command: typeof source.verification_command === 'string'
558
- ? source.verification_command.trim()
559
- : '',
560
- context_budget_percent: percent,
561
- };
562
- }
563
-
564
- function defaultGate(role) {
565
- return {
566
- plan: 'planning', implement: 'implementation', check: 'verification', review: 'review',
567
- }[role];
568
- }
569
-
570
- function deriveTransitions({
571
- change,
572
- tasks = [],
573
- task,
574
- role,
575
- readiness,
576
- blockers = [],
577
- activeWorkflow = null,
578
- deliveryReady = false,
579
- }) {
580
- const unique = (values) => [...new Set(values.filter(Boolean))];
581
- const has = (prefix) => blockers.some((blocker) => blocker === prefix || blocker.startsWith(`${prefix}:`));
582
- if (has('STATE_DB_UNREADABLE') || has('STATE_SCHEMA_INCOMPLETE') || has('STATE_AUTHORITY_CONFLICT')) {
583
- return {
584
- allowed_transitions: ['ultra-doctor', 'ultra-status'],
585
- required_transition: 'ultra-doctor',
586
- };
587
- }
588
- if (has('STATE_DB_MISSING') || has('BASELINE_MISSING')) {
589
- return {
590
- allowed_transitions: ['ultra-init', 'ultra-status'],
591
- required_transition: 'ultra-init',
592
- };
593
- }
594
- if (blockers.some((blocker) => blocker.startsWith('DECISION_'))) {
595
- return {
596
- allowed_transitions: ['ultra-think', 'ultra-status'],
597
- required_transition: 'ultra-think',
598
- };
599
- }
600
- if (blockers.some((blocker) => blocker.startsWith('BASELINE_SCHEMA_')
601
- || blocker.startsWith('STATE_SCHEMA_MIGRATION_'))) {
602
- return {
603
- allowed_transitions: ['ultra-doctor', 'ultra-init', 'ultra-status'],
604
- required_transition: 'ultra-doctor',
605
- };
606
- }
607
-
608
- const transitions = [];
609
- if (activeWorkflow) transitions.push(`ultra-${activeWorkflow.kind}`);
610
- if (!change) {
611
- transitions.push('ultra-research', 'ultra-change', 'ultra-think', 'ultra-status', 'ultra-doctor');
612
- return { allowed_transitions: unique(transitions), required_transition: null };
613
- }
614
- if (readiness === 'blocked') {
615
- if (blockers.some((blocker) => blocker.startsWith('CONTEXT_')
616
- || blocker.startsWith('EXECUTION_'))) {
617
- transitions.push('change.context');
618
- }
619
- if (blockers.some((blocker) => blocker.startsWith('BASELINE_'))) {
620
- transitions.push('ultra-research', 'ultra-doctor');
621
- }
622
- transitions.push('ultra-change', 'ultra-think', 'ultra-status');
623
- return { allowed_transitions: unique(transitions), required_transition: null };
624
- }
625
- if (change.status === 'ready') {
626
- transitions.push('ultra-deliver', 'ultra-review', 'ultra-test');
627
- } else if (change.status === 'blocked') {
628
- transitions.push('ultra-think', 'ultra-change');
629
- } else if (!task && tasks.length === 0) {
630
- transitions.push('ultra-plan', 'ultra-research', 'ultra-change');
631
- } else if (task?.status === 'pending' || task?.status === 'in_progress') {
632
- transitions.push('ultra-dev', 'ultra-plan');
633
- if (role === 'review') transitions.push('ultra-review');
634
- } else if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
635
- transitions.push('ultra-test', 'ultra-review');
636
- if (deliveryReady) transitions.push('ultra-deliver');
637
- } else {
638
- transitions.push('ultra-plan', 'ultra-change');
639
- }
640
- transitions.push('ultra-think', 'ultra-status');
641
- return { allowed_transitions: unique(transitions), required_transition: null };
642
- }
643
-
644
- function normalizeRecommendation(value) {
645
- if (value === undefined || value === null) return null;
646
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
647
- throw new ContextSpineError('VALIDATION_ERROR', 'recommendation must be an object');
648
- }
649
- const workflow = String(value.workflow || '').trim();
650
- const rationale = String(value.rationale || '').trim();
651
- if (!workflow || rationale.length < 3) {
652
- throw new ContextSpineError(
653
- 'VALIDATION_ERROR', 'recommendation requires workflow and rationale',
654
- );
655
- }
656
- return { workflow, rationale, authoritative: false };
657
- }
658
-
659
- function deliveryCapabilityReady(db, change, tasks, rootDir) {
660
- if (!change || !Array.isArray(tasks) || tasks.length === 0
661
- || !tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
662
- return false;
663
- }
664
- try {
665
- workflows.validateDeliveryPrerequisites(db, { change_id: change.id }, rootDir);
666
- return true;
667
- } catch {
668
- return false;
669
- }
670
- }
671
-
672
- function latestContextSnapshot(db, {
673
- change_id: changeId,
674
- task_id: taskId = null,
675
- role,
676
- gate,
677
- } = {}) {
678
- if (!changeId || !role || !gate) return null;
679
- return db.prepare(
680
- `SELECT * FROM context_snapshots
681
- WHERE change_id = ? AND task_id IS ? AND role = ? AND gate = ?
682
- ORDER BY created_at DESC, rowid DESC LIMIT 1`,
683
- ).get(changeId, taskId, role, gate) || null;
684
- }
685
-
686
- function latestContext(db, binding) {
687
- const row = latestContextSnapshot(db, binding);
688
- return parseJson(row?.context_json, {});
689
- }
690
-
691
- function validateContextSnapshot(db, binding, {
692
- rootDir = process.cwd(),
693
- require_current_checkout = false,
694
- } = {}) {
695
- const snapshot = latestContextSnapshot(db, binding);
696
- if (!snapshot) {
697
- return {
698
- snapshot: null,
699
- manifest: null,
700
- blockers: ['CONTEXT_SNAPSHOT_MISSING'],
701
- warnings: [],
702
- };
703
- }
704
- const blockers = [];
705
- const warnings = [];
706
- let manifest = null;
707
- let manifestFile = null;
708
- try {
709
- const read = readProjectFile(rootDir, snapshot.manifest_path);
710
- manifestFile = read?.file || localPath(rootDir, snapshot.manifest_path);
711
- if (!read) {
712
- blockers.push('CONTEXT_MANIFEST_MISSING');
713
- } else {
714
- const bytes = read.bytes;
715
- const digest = crypto.createHash('sha256').update(bytes).digest('hex');
716
- if (digest !== snapshot.manifest_hash) blockers.push('CONTEXT_MANIFEST_STALE');
717
- manifest = JSON.parse(bytes.toString('utf8'));
718
- }
719
- } catch {
720
- blockers.push('CONTEXT_MANIFEST_INVALID');
721
- }
722
- if (!manifest) {
723
- return {
724
- snapshot,
725
- manifest: null,
726
- blockers: [...new Set(blockers)],
727
- warnings: [],
728
- };
729
- }
730
- if (manifest.snapshot_id !== snapshot.id
731
- || manifest.change?.id !== binding.change_id
732
- || (manifest.resume?.task_id || null) !== (binding.task_id || null)
733
- || manifest.role !== binding.role
734
- || manifest.gate !== binding.gate
735
- || snapshot.role !== binding.role
736
- || snapshot.gate !== binding.gate) {
737
- blockers.push('CONTEXT_BINDING_MISMATCH');
738
- }
739
- if (snapshot.readiness !== 'ready' || manifest.readiness?.status !== 'ready') {
740
- blockers.push('CONTEXT_NOT_READY');
741
- }
742
- const change = db.prepare('SELECT * FROM changes WHERE id = ?').get(binding.change_id);
743
- if (!change || manifest.resume?.change_state_digest !== changeStateDigest(change)) {
744
- blockers.push('CONTEXT_CHANGE_CONTRACT_STALE');
745
- }
746
- if (binding.task_id) {
747
- const task = ops.readTask(db, binding.task_id);
748
- if (!task || manifest.resume?.task_contract_digest !== taskContractDigest(task)) {
749
- blockers.push('CONTEXT_TASK_CONTRACT_STALE');
750
- }
751
- }
752
- for (const item of manifest.context?.items || []) {
753
- if (item.kind === 'external') continue;
754
- let read;
755
- try { read = readProjectFile(rootDir, item.ref); }
756
- catch (error) {
757
- blockers.push(
758
- error?.code === 'CONTEXT_REF_UNSAFE'
759
- ? `CONTEXT_REF_UNSAFE:${item.ref}`
760
- : `CONTEXT_REF_INVALID:${item.ref}`,
761
- );
762
- continue;
763
- }
764
- if (!read) {
765
- item.freshness_status = 'missing';
766
- item.current_digest = null;
767
- if (item.required) blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
768
- else warnings.push(`CONTEXT_OPTIONAL_REF_MISSING:${item.ref}`);
769
- continue;
770
- }
771
- if (['digest', 'advisory'].includes(item.freshness_policy)) {
772
- const currentDigest = crypto.createHash('sha256').update(read.bytes).digest('hex');
773
- const expectedDigest = item.expected_digest || item.digest;
774
- item.current_digest = currentDigest;
775
- item.freshness_status = currentDigest === expectedDigest ? 'current' : 'stale';
776
- if (currentDigest !== expectedDigest) {
777
- if (item.freshness_policy === 'advisory') {
778
- warnings.push(`CONTEXT_REF_STALE_ADVISORY:${item.ref}`);
779
- } else if (item.required) {
780
- blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
781
- } else {
782
- warnings.push(`CONTEXT_OPTIONAL_REF_STALE:${item.ref}`);
783
- }
784
- }
785
- } else {
786
- item.current_digest = item.digest || null;
787
- item.freshness_status = 'current';
788
- }
789
- }
790
- if (require_current_checkout) {
791
- const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
792
- if (checkout.head && manifest.git?.head !== checkout.head) blockers.push('CONTEXT_HEAD_STALE');
793
- if (checkout.digest && manifest.git?.worktree_digest !== checkout.digest) {
794
- blockers.push('CONTEXT_WORKTREE_STALE');
795
- }
796
- }
797
- return {
798
- snapshot,
799
- manifest,
800
- blockers: [...new Set(blockers)],
801
- warnings: [...new Set(warnings)],
802
- };
803
- }
804
-
805
- function compileRoleContext(db, { input, change, tasks, rootDir }) {
806
- if (input.next_action !== undefined) {
807
- throw new ContextSpineError(
808
- 'CONTEXT_LEGACY_NEXT_ACTION_UNSUPPORTED',
809
- 'next_action is not an authority field; provide a non-authoritative recommendation instead',
810
- );
811
- }
812
- const task = input.task_id ? tasks.find((row) => row.id === input.task_id) : null;
813
- if (input.task_id && !task) {
814
- throw new ContextSpineError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
815
- }
816
- const role = input.role || (input.task_id ? 'implement' : 'plan');
817
- if (!ROLES.has(role)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported context role: ${role}`);
818
- const gate = input.gate || defaultGate(role);
819
- if (!GATES.has(gate)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported workflow gate: ${gate}`);
820
- const inherited = latestContext(db, {
821
- change_id: change.id,
822
- task_id: input.task_id || null,
823
- role,
824
- gate,
825
- });
826
-
827
- const refs = authoritativeTaskRefs(input, role, task)
828
- || normalizeRefs(input, role, inherited.context?.items || []);
829
- const items = refs.map((value) => inspectRef(rootDir, role, value));
830
- const budget = normalizeBudget(input.budget, inherited.context?.budget);
831
- const scopedTasks = taskNeighborhood(tasks, task, role);
832
- const executionContract = normalizeExecutionContract(
833
- input, inherited.execution_contract, { task },
834
- );
835
- const taskContract = taskContextContract(change, task);
836
- const fileTokenEstimate = items.reduce((sum, item) => sum + item.estimated_tokens, 0);
837
- const inlineTokenEstimate = estimateInlineTokens({
838
- change: {
839
- id: change.id,
840
- kind: change.kind,
841
- intent: change.intent,
842
- contract: change.contract || parseJson(change.contract_json, {}),
843
- docs_impact: change.docs_impact || parseJson(change.docs_impact_json, {}),
844
- },
845
- tasks: scopedTasks,
846
- execution_contract: executionContract,
847
- task_context_contract: taskContract,
848
- context_refs: items.map((item) => ({
849
- ref: item.ref,
850
- kind: item.kind,
851
- reason: item.reason,
852
- required: item.required,
853
- expected_digest: item.expected_digest,
854
- anchor: item.anchor,
855
- scope: item.scope,
856
- freshness_policy: item.freshness_policy,
857
- })),
858
- });
859
- const tokenEstimate = fileTokenEstimate + inlineTokenEstimate;
860
- const fileCount = items.filter((item) => item.status !== 'external').length;
861
- const blockers = [];
862
- const warnings = [];
863
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
864
- const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
865
- const baselineGate = baselineGateForChange(db, change, baselineHealth);
866
- blockers.push(...baselineGate.blockers);
867
- warnings.push(...baselineGate.warnings);
868
- for (const item of items) {
869
- if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
870
- if (!item.required && item.status === 'missing') {
871
- warnings.push(`CONTEXT_OPTIONAL_REF_MISSING:${item.ref}`);
872
- }
873
- if (item.status === 'stale') {
874
- if (item.freshness_policy === 'advisory') {
875
- warnings.push(`CONTEXT_REF_STALE_ADVISORY:${item.ref}`);
876
- } else if (item.required) {
877
- blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
878
- } else {
879
- warnings.push(`CONTEXT_OPTIONAL_REF_STALE:${item.ref}`);
880
- }
881
- }
882
- }
883
- if (fileCount > budget.max_files) warnings.push('CONTEXT_FILE_BUDGET_EXCEEDED');
884
- if (tokenEstimate > budget.max_tokens) warnings.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
885
- if (input.task_id) {
886
- if (!executionContract) blockers.push('EXECUTION_CONTRACT_MISSING');
887
- else {
888
- if (!executionContract.public_seam) blockers.push('EXECUTION_PUBLIC_SEAM_MISSING');
889
- if (!executionContract.verification_command) blockers.push('EXECUTION_VERIFICATION_COMMAND_MISSING');
890
- if (executionContract.slice_kind !== 'expand_contract'
891
- && executionContract.context_budget_percent > 40) {
892
- warnings.push('EXECUTION_CONTEXT_BUDGET_ADVISORY');
893
- }
894
- }
895
- }
896
- const readiness = blockers.length === 0 ? 'ready' : 'blocked';
897
- const control = deriveTransitions({
898
- change,
899
- tasks,
900
- task,
901
- role,
902
- readiness,
903
- blockers,
904
- deliveryReady: deliveryCapabilityReady(db, change, tasks, rootDir),
905
- });
906
- return {
907
- role,
908
- gate,
909
- control,
910
- recommendation: normalizeRecommendation(input.recommendation),
911
- readiness: { status: readiness, blockers, warnings: [...new Set(warnings)] },
912
- context: {
913
- items,
914
- budget,
915
- token_estimate: tokenEstimate,
916
- file_token_estimate: fileTokenEstimate,
917
- inline_token_estimate: inlineTokenEstimate,
918
- file_count: fileCount,
919
- lazy_file_refs: true,
920
- },
921
- execution_contract: executionContract,
922
- task_context_contract: taskContract,
923
- task_scope: scopedTasks,
924
- baseline: baselineHealth.baseline ? {
925
- id: baselineHealth.baseline.id,
926
- mode: baselineHealth.baseline.mode,
927
- status: baselineHealth.baseline.status,
928
- repository_revision: baselineHealth.baseline.repository_revision,
929
- health: baselineHealth.status,
930
- warnings: [...new Set([...baselineHealth.blockers, ...baselineHealth.warnings])],
931
- } : null,
932
- selected_task: task,
933
- resume: {
934
- change_id: change.id,
935
- change_state_digest: changeStateDigest(change),
936
- task_id: task?.id || null,
937
- task_status: task?.status || null,
938
- task_state_digest: taskStateDigest(task),
939
- task_contract_digest: taskContractDigest(task),
940
- session_id: task?.session_id || null,
941
- role,
942
- gate,
943
- git_head: checkout.head,
944
- worktree_state: checkout.state,
945
- worktree_digest: checkout.digest,
946
- },
947
- };
948
- }
949
-
950
- function actionableBaselineWorkflow(db, baselineId, rootDir) {
951
- if (!baselineId) return null;
952
- const candidates = workflows.listWorkflows(db, { baseline_id: baselineId, limit: 100 }, { rootDir })
953
- .filter((run) => ['active', 'blocked', 'ready'].includes(run.status));
954
- return candidates.find((run) => run.kind === 'research')
955
- || candidates.find((run) => run.kind === 'init')
956
- || candidates[0]
957
- || null;
958
- }
959
-
960
- function actionableChangeWorkflow(db, changeId, rootDir) {
961
- const candidates = workflows.listWorkflows(db, { change_id: changeId, limit: 100 }, { rootDir })
962
- .filter((run) => ['active', 'blocked', 'ready'].includes(run.status));
963
- return candidates.find((run) => run.status === 'blocked') || candidates[0] || null;
964
- }
965
-
966
- function summarizeWorkflow(run) {
967
- if (!run) return null;
968
- return {
969
- id: run.id, kind: run.kind, mode: run.mode, status: run.status,
970
- current_step: run.current_step, blockers: run.blockers,
971
- };
972
- }
973
-
974
- function summarizeDecision(gate) {
975
- if (!gate?.thread) return null;
976
- const current = gate.current_decision;
977
- return {
978
- thread_id: gate.thread.id,
979
- status: gate.thread.status,
980
- mode: gate.thread.mode,
981
- current: current ? {
982
- id: current.id,
983
- phase: current.phase,
984
- question: current.question,
985
- why_now: current.why_now,
986
- recommendation: current.recommendation,
987
- options: current.options,
988
- effects: current.effects,
989
- } : null,
990
- };
991
- }
992
-
993
- function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
994
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
995
- const change = id
996
- ? db.prepare('SELECT * FROM changes WHERE id = ?').get(id)
997
- : db.prepare(
998
- "SELECT * FROM changes WHERE status IN ('active', 'blocked', 'ready') ORDER BY updated_at DESC LIMIT 1",
999
- ).get();
1000
- if (id && !change) {
1001
- throw new ContextSpineError('CHANGE_NOT_FOUND', `change ${id} not found`);
1002
- }
1003
- if (!change) {
1004
- const baseline = baselineHealth.baseline;
1005
- const activeWorkflow = actionableBaselineWorkflow(db, baseline?.id, rootDir);
1006
- const decisionGate = decisions.decisionGate(
1007
- db, {
1008
- baseline_id: baseline?.id || null,
1009
- workflow_run_id: activeWorkflow?.id || null,
1010
- }, { rootDir },
1011
- );
1012
- const acceptedIntent = decisions.acceptedIntent(db, {
1013
- baseline_id: baseline?.id || null,
1014
- workflow_run_id: activeWorkflow?.id || null,
1015
- });
1016
- if (!decisionGate.ready) {
1017
- return {
1018
- change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
1019
- blockers: [...new Set([...baselineHealth.blockers, ...decisionGate.blockers])],
1020
- warnings: baselineHealth.warnings,
1021
- allowed_transitions: ['ultra-think', 'ultra-status'], required_transition: 'ultra-think',
1022
- workflow: summarizeWorkflow(activeWorkflow),
1023
- decision: summarizeDecision(decisionGate),
1024
- accepted_intent: acceptedIntent,
1025
- context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
1026
- baseline: baseline ? {
1027
- id: baseline.id, mode: baseline.mode, status: baseline.status,
1028
- repository_revision: baseline.repository_revision,
1029
- } : null,
1030
- };
1031
- }
1032
- if (baselineHealth.status !== 'pass') {
1033
- const control = baseline
1034
- ? {
1035
- allowed_transitions: uniqueStrings([
1036
- activeWorkflow ? `ultra-${activeWorkflow.kind}` : 'ultra-research',
1037
- 'ultra-status',
1038
- 'ultra-doctor',
1039
- ]),
1040
- required_transition: null,
1041
- }
1042
- : {
1043
- allowed_transitions: ['ultra-init', 'ultra-status'],
1044
- required_transition: 'ultra-init',
1045
- };
1046
- return {
1047
- change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
1048
- blockers: baselineHealth.blockers, warnings: baselineHealth.warnings,
1049
- allowed_transitions: control.allowed_transitions,
1050
- required_transition: control.required_transition,
1051
- workflow: summarizeWorkflow(activeWorkflow),
1052
- decision: null,
1053
- accepted_intent: acceptedIntent,
1054
- context_manifest_path: null,
1055
- context_manifest_hash: null, git_head: currentHead(rootDir),
1056
- baseline: baseline ? {
1057
- id: baseline.id, mode: baseline.mode, status: baseline.status,
1058
- repository_revision: baseline.repository_revision,
1059
- } : null,
1060
- };
1061
- }
1062
- return {
1063
- change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
1064
- allowed_transitions: ['ultra-change', 'ultra-research', 'ultra-think', 'ultra-status'],
1065
- required_transition: null,
1066
- workflow: null,
1067
- decision: null,
1068
- accepted_intent: acceptedIntent,
1069
- context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
1070
- blockers: [], warnings: baselineHealth.warnings,
1071
- baseline: {
1072
- id: baselineHealth.baseline.id, mode: baselineHealth.baseline.mode,
1073
- status: baselineHealth.baseline.status,
1074
- repository_revision: baselineHealth.baseline.repository_revision,
1075
- },
1076
- };
1077
- }
1078
- const tasks = db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC')
1079
- .all(change.id).map((row) => ops.readTask(db, row.id));
1080
- const activeWorkflow = actionableChangeWorkflow(db, change.id, rootDir);
1081
- const taskId = activeWorkflow?.task_id || tasks.find((row) => row.status === 'in_progress')?.id
1082
- || tasks.find((row) => row.status === 'pending')?.id || null;
1083
- const task = tasks.find((row) => row.id === taskId) || null;
1084
- const expectedBinding = {
1085
- plan: ['plan', 'planning'],
1086
- dev: ['implement', 'implementation'],
1087
- test: ['check', 'verification'],
1088
- review: ['review', 'review'],
1089
- deliver: ['check', 'convergence'],
1090
- }[activeWorkflow?.kind] || (task ? ['implement', 'implementation'] : ['plan', 'planning']);
1091
- const snapshot = latestContextSnapshot(db, {
1092
- change_id: change.id,
1093
- task_id: expectedBinding[0] === 'implement' ? taskId : null,
1094
- role: expectedBinding[0],
1095
- gate: expectedBinding[1],
1096
- });
1097
- const context = parseJson(snapshot?.context_json, {});
1098
- const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
1099
- const head = checkout.head;
1100
- const snapshotMissing = !snapshot;
1101
- const legacyContext = Boolean(
1102
- snapshot && (
1103
- !context.resume || !context.context || !context.readiness || !('baseline' in context)
1104
- || !Object.prototype.hasOwnProperty.call(context.resume, 'worktree_digest')
1105
- || !Object.prototype.hasOwnProperty.call(context.resume, 'task_state_digest')
1106
- || !Object.prototype.hasOwnProperty.call(context.resume, 'change_state_digest')
1107
- ),
1108
- );
1109
- const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
1110
- const worktreeStale = Boolean(
1111
- context.resume && context.resume.worktree_digest && checkout.digest
1112
- && context.resume.worktree_digest !== checkout.digest,
1113
- );
1114
- const stateChanged = context.resume?.task_status !== undefined
1115
- && context.resume.task_status !== (task?.status || null);
1116
- const taskContractChanged = Boolean(
1117
- context.resume && (
1118
- context.resume.task_contract_digest
1119
- ? context.resume.task_contract_digest !== taskContractDigest(task)
1120
- : context.resume.task_state_digest !== taskStateDigest(task)
1121
- ),
1122
- );
1123
- const changeContractChanged = Boolean(
1124
- context.resume && context.resume.change_state_digest !== changeStateDigest(change),
1125
- );
1126
- const blockers = [];
1127
- const baselineGate = baselineGateForChange(db, change, baselineHealth);
1128
- const decisionGate = decisions.decisionGate(db, {
1129
- baseline_id: baselineHealth.baseline?.id || null,
1130
- change_id: change.id,
1131
- workflow_run_id: activeWorkflow?.id || null,
1132
- }, { rootDir });
1133
- const acceptedIntent = decisions.acceptedIntent(db, {
1134
- baseline_id: baselineHealth.baseline?.id || null,
1135
- change_id: change.id,
1136
- workflow_run_id: activeWorkflow?.id || null,
1137
- });
1138
- const warnings = [...baselineGate.warnings];
1139
- blockers.push(...baselineGate.blockers);
1140
- blockers.push(...decisionGate.blockers);
1141
- if (snapshotMissing && tasks.length > 0) blockers.push('CONTEXT_NOT_COMPILED');
1142
- else if (snapshotMissing) warnings.push('CONTEXT_NOT_COMPILED');
1143
- else {
1144
- if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
1145
- if (headStale) blockers.push('CONTEXT_HEAD_STALE');
1146
- if (worktreeStale) blockers.push('CONTEXT_WORKTREE_STALE');
1147
- if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
1148
- if (taskContractChanged) blockers.push('CONTEXT_TASK_CONTRACT_STALE');
1149
- if (changeContractChanged) blockers.push('CONTEXT_CHANGE_CONTRACT_STALE');
1150
- for (const blocker of parseJson(snapshot.blockers_json, [])) {
1151
- if (ADVISORY_CONTEXT_CODES.has(blocker)
1152
- || (blocker.startsWith('BASELINE_') && baselineGate.blockers.length === 0)) {
1153
- if (!warnings.includes(blocker)) warnings.push(blocker);
1154
- } else if (!blockers.includes(blocker)) blockers.push(blocker);
1155
- }
1156
- for (const warning of context.readiness?.warnings || []) {
1157
- if (!warnings.includes(warning)) warnings.push(warning);
1158
- }
1159
- }
1160
- const readiness = blockers.length > 0 ? 'blocked' : (snapshot?.readiness || 'ready');
1161
- const control = deriveTransitions({
1162
- change,
1163
- tasks,
1164
- task,
1165
- role: snapshot?.role || 'plan',
1166
- readiness,
1167
- blockers,
1168
- activeWorkflow,
1169
- deliveryReady: deliveryCapabilityReady(db, change, tasks, rootDir),
1170
- });
1171
- return {
1172
- change_id: change.id,
1173
- change_status: change.status,
1174
- task_id: task?.id || null,
1175
- task_status: task?.status || null,
1176
- session_id: task?.session_id || null,
1177
- role: snapshot?.role || 'plan',
1178
- gate: decisionGate.ready ? (snapshot?.gate || 'alignment') : 'alignment',
1179
- readiness,
1180
- blockers,
1181
- warnings: [...new Set(warnings)],
1182
- allowed_transitions: control.allowed_transitions,
1183
- required_transition: control.required_transition,
1184
- context_manifest_path: snapshot?.manifest_path || null,
1185
- context_manifest_hash: snapshot?.manifest_hash || null,
1186
- git_head: head,
1187
- baseline: baselineHealth.baseline ? {
1188
- id: baselineHealth.baseline.id, mode: baselineHealth.baseline.mode,
1189
- status: baselineHealth.baseline.status,
1190
- repository_revision: baselineHealth.baseline.repository_revision,
1191
- } : null,
1192
- workflow: summarizeWorkflow(activeWorkflow),
1193
- decision: summarizeDecision(decisionGate),
1194
- accepted_intent: acceptedIntent,
1195
- };
1196
- }
1197
-
1198
- module.exports = {
1199
- ContextSpineError,
1200
- baselineGateForChange,
1201
- changeStateDigest,
1202
- compileRoleContext,
1203
- deriveTransitions,
1204
- latestContextSnapshot,
1205
- readBreadcrumb,
1206
- taskContractDigest,
1207
- validateContextSnapshot,
1208
- };