ultra-builder-pro-cli 0.19.1 → 0.21.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 (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -16,6 +16,13 @@ const GATES = new Set([
16
16
  const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
17
17
  const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
18
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
+ ]);
19
26
  const ADVISORY_CONTEXT_CODES = new Set([
20
27
  'CONTEXT_FILE_BUDGET_EXCEEDED',
21
28
  'CONTEXT_TOKEN_BUDGET_EXCEEDED',
@@ -132,6 +139,26 @@ function taskStateDigest(task) {
132
139
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
133
140
  }
134
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
+
135
162
  function changeStateDigest(change) {
136
163
  if (!change) return null;
137
164
  const payload = {
@@ -154,9 +181,20 @@ function nonEmpty(value, field) {
154
181
  return text;
155
182
  }
156
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
+
157
194
  function localPath(rootDir, ref) {
158
195
  const candidate = ref.split('#', 1)[0];
159
- if (!candidate || path.isAbsolute(candidate)) {
196
+ if (!candidate || path.isAbsolute(candidate)
197
+ || /^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)) {
160
198
  throw new ContextSpineError('VALIDATION_ERROR', `context ref must be project-relative: ${ref}`);
161
199
  }
162
200
  const root = path.resolve(rootDir);
@@ -167,6 +205,102 @@ function localPath(rootDir, ref) {
167
205
  return resolved;
168
206
  }
169
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
+
170
304
  function normalizeBudget(value, inherited) {
171
305
  const source = value || inherited || DEFAULT_BUDGET;
172
306
  const maxTokens = Number(source.max_tokens ?? DEFAULT_BUDGET.max_tokens);
@@ -178,44 +312,110 @@ function normalizeBudget(value, inherited) {
178
312
  }
179
313
 
180
314
  function specRefToContextRef(value, role) {
181
- const ref = typeof value === 'string'
182
- ? value
183
- : value && (value.ref || value.path || value.reference);
184
- if (!ref) throw new ContextSpineError('VALIDATION_ERROR', 'spec_refs entries require path/ref/reference');
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);
185
330
  return {
186
331
  ref,
187
332
  kind: 'spec',
188
- reason: (value && value.reason) || `Specification evidence for ${role}`,
189
- required: value?.required !== false,
190
- digest: value && value.digest,
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,
191
341
  };
192
342
  }
193
343
 
194
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
+ }
195
351
  let refs;
196
352
  if (input.context_refs !== undefined) refs = input.context_refs;
197
- else if (input.spec_refs !== undefined) refs = input.spec_refs.map((value) => specRefToContextRef(value, role));
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
+ }
198
364
  else refs = inherited || [];
199
365
  if (!Array.isArray(refs)) {
200
366
  throw new ContextSpineError('VALIDATION_ERROR', 'context_refs must be an array');
201
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
+ }
202
373
  return refs.map((value, index) => {
203
374
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
204
375
  throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}] must be an object`);
205
376
  }
206
- const ref = nonEmpty(value.ref || value.path || value.reference, `context_refs[${index}].ref`);
377
+ const ref = boundedText(
378
+ value.ref || value.path || value.reference,
379
+ `context_refs[${index}].ref`,
380
+ MAX_REF_LENGTH,
381
+ );
207
382
  const kind = value.kind || 'source';
208
383
  if (!CONTEXT_KINDS.has(kind)) {
209
384
  throw new ContextSpineError('VALIDATION_ERROR', `unsupported context kind: ${kind}`);
210
385
  }
211
- const reason = nonEmpty(value.reason, `context_refs[${index}].reason`);
212
- if (value.digest !== undefined && !/^[0-9a-f]{64}$/.test(value.digest)) {
213
- throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}].digest must be sha256`);
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
+ );
214
394
  }
215
- return {
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 = {
216
404
  ref, kind, reason, required: value.required !== false,
217
- expected_digest: value.digest || null,
405
+ expected_digest: expectedDigest,
406
+ freshness_policy: freshnessPolicy,
218
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;
219
419
  });
220
420
  }
221
421
 
@@ -230,6 +430,9 @@ function authoritativeTaskRefs(input, role, task) {
230
430
  reason: item.reason,
231
431
  required: item.required,
232
432
  expected_digest: item.expected_digest,
433
+ anchor: item.anchor,
434
+ scope: item.scope,
435
+ freshness_policy: item.freshness_policy,
233
436
  }));
234
437
  if (JSON.stringify(comparable(supplied)) !== JSON.stringify(comparable(dbRefs))) {
235
438
  throw new ContextSpineError(
@@ -241,22 +444,75 @@ function authoritativeTaskRefs(input, role, task) {
241
444
  }
242
445
 
243
446
  function inspectRef(rootDir, role, value) {
244
- if (value.kind === 'external' || /^[a-z][a-z0-9+.-]*:\/\//i.test(value.ref)) {
447
+ if (value.kind === 'external') {
245
448
  return { ...value, role, status: 'external', digest: value.expected_digest, estimated_tokens: 0 };
246
449
  }
247
- const file = localPath(rootDir, value.ref);
248
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
450
+ const read = readProjectFile(rootDir, value.ref);
451
+ if (!read) {
249
452
  return { ...value, role, status: 'missing', digest: null, estimated_tokens: 0 };
250
453
  }
251
- const content = fs.readFileSync(file);
454
+ const content = read.bytes;
252
455
  const digest = crypto.createHash('sha256').update(content).digest('hex');
253
- const status = value.expected_digest && value.expected_digest !== digest ? 'stale' : 'current';
456
+ const status = value.freshness_policy !== 'existence'
457
+ && value.expected_digest && value.expected_digest !== digest ? 'stale' : 'current';
254
458
  return {
255
459
  ...value, role, status, digest,
256
460
  estimated_tokens: Math.max(1, Math.ceil(content.byteLength / 4)),
257
461
  };
258
462
  }
259
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
+
260
516
  function normalizeExecutionContract(input, inherited, { task }) {
261
517
  const taskContract = task
262
518
  && task.slice_kind && task.public_seam && task.verification_command ? {
@@ -413,17 +669,139 @@ function deliveryCapabilityReady(db, change, tasks, rootDir) {
413
669
  }
414
670
  }
415
671
 
416
- function latestContext(db, changeId, taskId) {
417
- const row = taskId
418
- ? db.prepare(
419
- 'SELECT context_json FROM context_snapshots WHERE change_id = ? AND task_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
420
- ).get(changeId, taskId)
421
- : db.prepare(
422
- 'SELECT context_json FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
423
- ).get(changeId);
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);
424
688
  return parseJson(row?.context_json, {});
425
689
  }
426
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
+
427
805
  function compileRoleContext(db, { input, change, tasks, rootDir }) {
428
806
  if (input.next_action !== undefined) {
429
807
  throw new ContextSpineError(
@@ -435,21 +813,51 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
435
813
  if (input.task_id && !task) {
436
814
  throw new ContextSpineError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
437
815
  }
438
- const inherited = latestContext(db, change.id, input.task_id || null);
439
816
  const role = input.role || (input.task_id ? 'implement' : 'plan');
440
817
  if (!ROLES.has(role)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported context role: ${role}`);
441
818
  const gate = input.gate || defaultGate(role);
442
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
+ });
443
826
 
444
827
  const refs = authoritativeTaskRefs(input, role, task)
445
828
  || normalizeRefs(input, role, inherited.context?.items || []);
446
829
  const items = refs.map((value) => inspectRef(rootDir, role, value));
447
830
  const budget = normalizeBudget(input.budget, inherited.context?.budget);
448
- const tokenEstimate = items.reduce((sum, item) => sum + item.estimated_tokens, 0);
449
- const fileCount = items.filter((item) => item.status !== 'external').length;
831
+ const scopedTasks = taskNeighborhood(tasks, task, role);
450
832
  const executionContract = normalizeExecutionContract(
451
833
  input, inherited.execution_contract, { task },
452
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;
453
861
  const blockers = [];
454
862
  const warnings = [];
455
863
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
@@ -459,7 +867,18 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
459
867
  warnings.push(...baselineGate.warnings);
460
868
  for (const item of items) {
461
869
  if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
462
- if (item.required && item.status === 'stale') blockers.push(`CONTEXT_REQUIRED_REF_STALE:${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
+ }
463
882
  }
464
883
  if (fileCount > budget.max_files) warnings.push('CONTEXT_FILE_BUDGET_EXCEEDED');
465
884
  if (tokenEstimate > budget.max_tokens) warnings.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
@@ -490,8 +909,18 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
490
909
  control,
491
910
  recommendation: normalizeRecommendation(input.recommendation),
492
911
  readiness: { status: readiness, blockers, warnings: [...new Set(warnings)] },
493
- context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
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
+ },
494
921
  execution_contract: executionContract,
922
+ task_context_contract: taskContract,
923
+ task_scope: scopedTasks,
495
924
  baseline: baselineHealth.baseline ? {
496
925
  id: baselineHealth.baseline.id,
497
926
  mode: baselineHealth.baseline.mode,
@@ -507,6 +936,7 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
507
936
  task_id: task?.id || null,
508
937
  task_status: task?.status || null,
509
938
  task_state_digest: taskStateDigest(task),
939
+ task_contract_digest: taskContractDigest(task),
510
940
  session_id: task?.session_id || null,
511
941
  role,
512
942
  gate,
@@ -572,16 +1002,26 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
572
1002
  }
573
1003
  if (!change) {
574
1004
  const baseline = baselineHealth.baseline;
1005
+ const activeWorkflow = actionableBaselineWorkflow(db, baseline?.id, rootDir);
575
1006
  const decisionGate = decisions.decisionGate(
576
- db, { baseline_id: baseline?.id || null }, { rootDir },
1007
+ db, {
1008
+ baseline_id: baseline?.id || null,
1009
+ workflow_run_id: activeWorkflow?.id || null,
1010
+ }, { rootDir },
577
1011
  );
1012
+ const acceptedIntent = decisions.acceptedIntent(db, {
1013
+ baseline_id: baseline?.id || null,
1014
+ workflow_run_id: activeWorkflow?.id || null,
1015
+ });
578
1016
  if (!decisionGate.ready) {
579
1017
  return {
580
1018
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
581
1019
  blockers: [...new Set([...baselineHealth.blockers, ...decisionGate.blockers])],
582
1020
  warnings: baselineHealth.warnings,
583
1021
  allowed_transitions: ['ultra-think', 'ultra-status'], required_transition: 'ultra-think',
584
- workflow: null, decision: summarizeDecision(decisionGate),
1022
+ workflow: summarizeWorkflow(activeWorkflow),
1023
+ decision: summarizeDecision(decisionGate),
1024
+ accepted_intent: acceptedIntent,
585
1025
  context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
586
1026
  baseline: baseline ? {
587
1027
  id: baseline.id, mode: baseline.mode, status: baseline.status,
@@ -590,7 +1030,6 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
590
1030
  };
591
1031
  }
592
1032
  if (baselineHealth.status !== 'pass') {
593
- const activeWorkflow = actionableBaselineWorkflow(db, baseline?.id, rootDir);
594
1033
  const control = baseline
595
1034
  ? {
596
1035
  allowed_transitions: uniqueStrings([
@@ -609,7 +1048,10 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
609
1048
  blockers: baselineHealth.blockers, warnings: baselineHealth.warnings,
610
1049
  allowed_transitions: control.allowed_transitions,
611
1050
  required_transition: control.required_transition,
612
- workflow: summarizeWorkflow(activeWorkflow), decision: null, context_manifest_path: null,
1051
+ workflow: summarizeWorkflow(activeWorkflow),
1052
+ decision: null,
1053
+ accepted_intent: acceptedIntent,
1054
+ context_manifest_path: null,
613
1055
  context_manifest_hash: null, git_head: currentHead(rootDir),
614
1056
  baseline: baseline ? {
615
1057
  id: baseline.id, mode: baseline.mode, status: baseline.status,
@@ -623,6 +1065,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
623
1065
  required_transition: null,
624
1066
  workflow: null,
625
1067
  decision: null,
1068
+ accepted_intent: acceptedIntent,
626
1069
  context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
627
1070
  blockers: [], warnings: baselineHealth.warnings,
628
1071
  baseline: {
@@ -634,13 +1077,24 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
634
1077
  }
635
1078
  const tasks = db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC')
636
1079
  .all(change.id).map((row) => ops.readTask(db, row.id));
637
- const snapshot = db.prepare(
638
- 'SELECT * FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
639
- ).get(change.id);
640
- const context = parseJson(snapshot?.context_json, {});
641
- const taskId = snapshot?.task_id || tasks.find((row) => row.status === 'in_progress')?.id
1080
+ const activeWorkflow = actionableChangeWorkflow(db, change.id, rootDir);
1081
+ const taskId = activeWorkflow?.task_id || tasks.find((row) => row.status === 'in_progress')?.id
642
1082
  || tasks.find((row) => row.status === 'pending')?.id || null;
643
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, {});
644
1098
  const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
645
1099
  const head = checkout.head;
646
1100
  const snapshotMissing = !snapshot;
@@ -660,7 +1114,11 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
660
1114
  const stateChanged = context.resume?.task_status !== undefined
661
1115
  && context.resume.task_status !== (task?.status || null);
662
1116
  const taskContractChanged = Boolean(
663
- context.resume && context.resume.task_state_digest !== taskStateDigest(task),
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
+ ),
664
1122
  );
665
1123
  const changeContractChanged = Boolean(
666
1124
  context.resume && context.resume.change_state_digest !== changeStateDigest(change),
@@ -670,8 +1128,13 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
670
1128
  const decisionGate = decisions.decisionGate(db, {
671
1129
  baseline_id: baselineHealth.baseline?.id || null,
672
1130
  change_id: change.id,
673
- workflow_run_id: actionableChangeWorkflow(db, change.id, rootDir)?.id || null,
1131
+ workflow_run_id: activeWorkflow?.id || null,
674
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
+ });
675
1138
  const warnings = [...baselineGate.warnings];
676
1139
  blockers.push(...baselineGate.blockers);
677
1140
  blockers.push(...decisionGate.blockers);
@@ -695,7 +1158,6 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
695
1158
  }
696
1159
  }
697
1160
  const readiness = blockers.length > 0 ? 'blocked' : (snapshot?.readiness || 'ready');
698
- const activeWorkflow = actionableChangeWorkflow(db, change.id, rootDir);
699
1161
  const control = deriveTransitions({
700
1162
  change,
701
1163
  tasks,
@@ -729,6 +1191,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
729
1191
  } : null,
730
1192
  workflow: summarizeWorkflow(activeWorkflow),
731
1193
  decision: summarizeDecision(decisionGate),
1194
+ accepted_intent: acceptedIntent,
732
1195
  };
733
1196
  }
734
1197
 
@@ -738,5 +1201,8 @@ module.exports = {
738
1201
  changeStateDigest,
739
1202
  compileRoleContext,
740
1203
  deriveTransitions,
1204
+ latestContextSnapshot,
741
1205
  readBreadcrumb,
1206
+ taskContractDigest,
1207
+ validateContextSnapshot,
742
1208
  };