ultra-builder-pro-cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -1
- package/README.md +11 -6
- package/adapters/_shared/codex-assets.cjs +23 -331
- package/adapters/_shared/handbook.cjs +2 -0
- package/adapters/_shared/runtime-assets.cjs +25 -0
- package/adapters/_shared/tests/handbook.test.cjs +3 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +12 -4
- package/adapters/claude.js +28 -5
- package/adapters/kimi.js +15 -138
- package/adapters/opencode.js +59 -63
- package/adapters/tests/claude.test.cjs +26 -9
- package/adapters/tests/codex.test.cjs +23 -9
- package/adapters/tests/kimi.test.cjs +15 -11
- package/adapters/tests/opencode.test.cjs +57 -10
- package/agents/code-reviewer.md +24 -85
- package/agents/debugger.md +30 -132
- package/agents/review-code.md +24 -104
- package/agents/review-comments.md +20 -83
- package/agents/review-coordinator.md +40 -137
- package/agents/review-design.md +21 -118
- package/agents/review-errors.md +22 -70
- package/agents/review-spec.md +36 -0
- package/agents/review-tests.md +20 -76
- package/agents/tdd-runner.md +26 -84
- package/commands/learn.md +5 -36
- package/commands/ultra-change.md +5 -43
- package/commands/ultra-deliver.md +4 -46
- package/commands/ultra-dev.md +4 -48
- package/commands/ultra-doctor.md +4 -30
- package/commands/ultra-init.md +5 -47
- package/commands/ultra-plan.md +5 -48
- package/commands/ultra-research.md +5 -54
- package/commands/ultra-status.md +5 -44
- package/commands/ultra-test.md +5 -45
- package/commands/ultra-think.md +5 -40
- package/docs/AGENT-CONTEXT.md +34 -12
- package/docs/ARCHITECTURE.md +31 -5
- package/docs/PLAN.zh-CN.md +3 -2
- package/docs/ROADMAP.md +5 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +7 -5
- package/docs/SKILL-AUTHORING.md +75 -0
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -2
- package/hooks/README.md +6 -4
- package/hooks/active_task_context.py +14 -0
- package/hooks/context_spine.py +218 -0
- package/hooks/health_check.py +1 -1
- package/hooks/workflow_context.py +16 -123
- package/hooks/workflow_resume.py +21 -0
- package/mcp-server/lib/change-workflow.cjs +89 -11
- package/mcp-server/lib/change-workflow.test.cjs +202 -7
- package/mcp-server/lib/context-spine.cjs +331 -0
- package/mcp-server/lib/context-spine.test.cjs +73 -0
- package/mcp-server/lib/spec-learning.cjs +191 -0
- package/mcp-server/lib/spec-learning.test.cjs +62 -0
- package/mcp-server/lib/state-db.cjs +72 -10
- package/mcp-server/lib/state-db.test.cjs +7 -2
- package/mcp-server/server.cjs +31 -1
- package/mcp-server/tests/db-ops.test.cjs +3 -3
- package/mcp-server/tests/server-contract.test.cjs +74 -0
- package/mcp-server/tests/state-db.test.cjs +20 -3
- package/package.json +3 -2
- package/skills/cc-collab/SKILL.md +16 -18
- package/skills/code-review-expert/SKILL.md +39 -114
- package/skills/code-review-expert/references/correctness-reliability.md +30 -0
- package/skills/code-review-expert/references/design-boundaries.md +26 -0
- package/skills/code-review-expert/references/removal-plan.md +17 -45
- package/skills/codex-collab/SKILL.md +23 -28
- package/skills/codex-collab/evals/evals.json +18 -23
- package/skills/integration-rules/SKILL.md +28 -67
- package/skills/learn/SKILL.md +46 -105
- package/skills/security-rules/SKILL.md +34 -56
- package/skills/testing-rules/SKILL.md +28 -52
- package/skills/ultra-change/SKILL.md +61 -145
- package/skills/ultra-deliver/SKILL.md +63 -229
- package/skills/ultra-dev/SKILL.md +46 -338
- package/skills/ultra-doctor/SKILL.md +39 -71
- package/skills/ultra-init/SKILL.md +59 -139
- package/skills/ultra-plan/SKILL.md +50 -310
- package/skills/ultra-research/SKILL.md +59 -152
- package/skills/ultra-research/references/architecture.md +38 -0
- package/skills/ultra-research/references/problem-product.md +39 -0
- package/skills/ultra-research/references/quality-delivery.md +34 -0
- package/skills/ultra-research/references/synthesis.md +38 -0
- package/skills/ultra-research/references/users-scope.md +40 -0
- package/skills/ultra-review/SKILL.md +55 -427
- package/skills/ultra-review/references/unified-schema.md +76 -101
- package/skills/ultra-review/scripts/review_wait.py +242 -77
- package/skills/ultra-status/SKILL.md +38 -173
- package/skills/ultra-test/SKILL.md +41 -195
- package/skills/ultra-think/SKILL.md +43 -101
- package/skills/ultra-verify/SKILL.md +44 -84
- package/skills/ultra-verify/evals/evals.json +24 -27
- package/spec/cli-protocol.md +3 -0
- package/spec/mcp-tools.yaml +133 -4
- package/spec/schemas/command-manifest.schema.json +7 -25
- package/spec/schemas/skill-manifest.schema.json +9 -79
- package/spec/schemas/state-db.sql +31 -0
- package/skills/code-review-expert/references/code-quality-checklist.md +0 -112
- package/skills/code-review-expert/references/integration-checklist.md +0 -46
- package/skills/code-review-expert/references/security-checklist.md +0 -100
- package/skills/code-review-expert/references/solid-checklist.md +0 -65
- package/skills/ultra-research/steps/step-00-problem-validation.md +0 -154
- package/skills/ultra-research/steps/step-01-opportunity-discovery.md +0 -172
- package/skills/ultra-research/steps/step-02-market-assessment.md +0 -195
- package/skills/ultra-research/steps/step-03-competitive-landscape.md +0 -197
- package/skills/ultra-research/steps/step-04-product-strategy.md +0 -196
- package/skills/ultra-research/steps/step-05-assumptions-validation.md +0 -234
- package/skills/ultra-research/steps/step-10-user-personas.md +0 -169
- package/skills/ultra-research/steps/step-11-user-scenarios.md +0 -181
- package/skills/ultra-research/steps/step-20-user-stories.md +0 -156
- package/skills/ultra-research/steps/step-21-features-scope.md +0 -157
- package/skills/ultra-research/steps/step-22-success-metrics.md +0 -195
- package/skills/ultra-research/steps/step-30-architecture-context.md +0 -154
- package/skills/ultra-research/steps/step-31-solution-strategy.md +0 -172
- package/skills/ultra-research/steps/step-32-building-blocks.md +0 -188
- package/skills/ultra-research/steps/step-40-deployment.md +0 -123
- package/skills/ultra-research/steps/step-41-quality-risks.md +0 -135
- package/skills/ultra-research/steps/step-99-synthesis.md +0 -188
- package/skills/ultra-review/scripts/review_verdict_update.py +0 -99
|
@@ -31,10 +31,20 @@ function cleanup({ rootDir, db }) {
|
|
|
31
31
|
function standardEvidence() {
|
|
32
32
|
return [
|
|
33
33
|
{ category: 'diff', status: 'pass', evidence: 'git diff reviewed' },
|
|
34
|
-
{
|
|
34
|
+
{
|
|
35
|
+
category: 'tests', status: 'pass', evidence: 'node --test passed',
|
|
36
|
+
seam: 'public change workflow contract',
|
|
37
|
+
},
|
|
35
38
|
{ category: 'spec', status: 'pass', evidence: 'delta reconciled' },
|
|
36
39
|
{ category: 'docs', status: 'pass', evidence: 'docs/feature.md updated' },
|
|
37
|
-
{
|
|
40
|
+
{
|
|
41
|
+
category: 'review', axis: 'spec_fidelity', status: 'pass',
|
|
42
|
+
evidence: 'Acceptance behavior matches the approved delta.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
category: 'review', axis: 'engineering_standards', status: 'pass',
|
|
46
|
+
evidence: 'Engineering findings are resolved.',
|
|
47
|
+
},
|
|
38
48
|
];
|
|
39
49
|
}
|
|
40
50
|
|
|
@@ -42,10 +52,43 @@ function incidentEvidence() {
|
|
|
42
52
|
return [
|
|
43
53
|
{ category: 'diagnosis', status: 'pass', evidence: 'Structured diagnosis artifact reviewed.' },
|
|
44
54
|
{ category: 'diff', status: 'pass', evidence: 'Root-cause fix diff reviewed.' },
|
|
45
|
-
{
|
|
55
|
+
{
|
|
56
|
+
category: 'tests', status: 'pass', evidence: 'Regression test reproduces then verifies the fix.',
|
|
57
|
+
seam: 'projection recovery boundary',
|
|
58
|
+
signal: {
|
|
59
|
+
command: 'node --test recovery.test.cjs',
|
|
60
|
+
expected_red: 'stale projection remains running',
|
|
61
|
+
observed_red: true,
|
|
62
|
+
observed_green: true,
|
|
63
|
+
deterministic: true,
|
|
64
|
+
duration_ms: 25,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
46
67
|
];
|
|
47
68
|
}
|
|
48
69
|
|
|
70
|
+
function executionContext(taskId, overrides = {}) {
|
|
71
|
+
return {
|
|
72
|
+
task_id: taskId,
|
|
73
|
+
role: 'implement',
|
|
74
|
+
gate: 'implementation',
|
|
75
|
+
next_action: `Implement and verify ${taskId}.`,
|
|
76
|
+
context_refs: [
|
|
77
|
+
{
|
|
78
|
+
ref: 'README.md', kind: 'spec', reason: 'Public behavior contract', required: true,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
budget: { max_tokens: 2_000, max_files: 4 },
|
|
82
|
+
execution_contract: {
|
|
83
|
+
slice_kind: 'tracer_bullet',
|
|
84
|
+
public_seam: `public seam for ${taskId}`,
|
|
85
|
+
verification_command: `node --test ${taskId}.test.cjs`,
|
|
86
|
+
context_budget_percent: 40,
|
|
87
|
+
},
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
test('createChange persists a change and an inspectable external-provider context manifest', () => {
|
|
50
93
|
const fx = fixture();
|
|
51
94
|
try {
|
|
@@ -120,7 +163,9 @@ test('convergeChange blocks incomplete work and marks a fully evidenced standard
|
|
|
120
163
|
});
|
|
121
164
|
ops.updateTaskStatus(fx.db, 'change-task', 'in_progress');
|
|
122
165
|
ops.updateTaskStatus(fx.db, 'change-task', 'completed');
|
|
123
|
-
changes.compileContext(fx.db, {
|
|
166
|
+
changes.compileContext(fx.db, {
|
|
167
|
+
id: 'chg-converge', ...executionContext('change-task'),
|
|
168
|
+
}, { rootDir: fx.rootDir });
|
|
124
169
|
|
|
125
170
|
const ready = changes.convergeChange(fx.db, {
|
|
126
171
|
id: 'chg-converge', evidence: standardEvidence(),
|
|
@@ -148,12 +193,20 @@ test('archiveChange moves a ready change into immutable history and records base
|
|
|
148
193
|
});
|
|
149
194
|
ops.updateTaskStatus(fx.db, 'archive-task', 'in_progress');
|
|
150
195
|
ops.updateTaskStatus(fx.db, 'archive-task', 'completed');
|
|
151
|
-
changes.compileContext(fx.db, {
|
|
196
|
+
changes.compileContext(fx.db, {
|
|
197
|
+
id: 'chg-archive', ...executionContext('archive-task'),
|
|
198
|
+
}, { rootDir: fx.rootDir });
|
|
152
199
|
const converged = changes.convergeChange(fx.db, {
|
|
153
200
|
id: 'chg-archive',
|
|
154
201
|
evidence: [
|
|
155
202
|
{ category: 'diff', status: 'pass', evidence: 'diff reviewed' },
|
|
156
|
-
{
|
|
203
|
+
{
|
|
204
|
+
category: 'tests', status: 'pass', evidence: 'tests passed', seam: 'archive command',
|
|
205
|
+
signal: {
|
|
206
|
+
command: 'node --test archive-task.test.cjs', expected_red: 'archive accepts stale context',
|
|
207
|
+
observed_red: true, observed_green: true, deterministic: true, duration_ms: 10,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
157
210
|
{ category: 'spec', status: 'not_applicable', evidence: 'No behavior contract changed.' },
|
|
158
211
|
],
|
|
159
212
|
}, { rootDir: fx.rootDir });
|
|
@@ -306,7 +359,9 @@ test('incident convergence requires every structured diagnosis section and refre
|
|
|
306
359
|
});
|
|
307
360
|
ops.updateTaskStatus(fx.db, 'incident-task', 'in_progress');
|
|
308
361
|
ops.updateTaskStatus(fx.db, 'incident-task', 'completed');
|
|
309
|
-
changes.compileContext(fx.db, {
|
|
362
|
+
changes.compileContext(fx.db, {
|
|
363
|
+
id: 'chg-incident-converge', ...executionContext('incident-task'),
|
|
364
|
+
}, { rootDir: fx.rootDir });
|
|
310
365
|
|
|
311
366
|
const blocked = changes.convergeChange(fx.db, {
|
|
312
367
|
id: 'chg-incident-converge', evidence: incidentEvidence(),
|
|
@@ -344,3 +399,143 @@ test('incident convergence requires every structured diagnosis section and refre
|
|
|
344
399
|
cleanup(fx);
|
|
345
400
|
}
|
|
346
401
|
});
|
|
402
|
+
|
|
403
|
+
test('compileContext v2 persists role-scoped context, budget, execution contract, and breadcrumb in state.db', () => {
|
|
404
|
+
const fx = fixture();
|
|
405
|
+
try {
|
|
406
|
+
changes.createChange(fx.db, {
|
|
407
|
+
id: 'chg-context-v2', title: 'Compile role context', kind: 'standard',
|
|
408
|
+
intent: 'Give each execution role only the context it needs.',
|
|
409
|
+
}, { rootDir: fx.rootDir });
|
|
410
|
+
ops.createTask(fx.db, {
|
|
411
|
+
id: 'context-v2-task', title: 'Build role-scoped manifest', type: 'feature', priority: 'P0',
|
|
412
|
+
change_id: 'chg-context-v2',
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const compiled = changes.compileContext(fx.db, {
|
|
416
|
+
id: 'chg-context-v2',
|
|
417
|
+
...executionContext('context-v2-task'),
|
|
418
|
+
}, { rootDir: fx.rootDir });
|
|
419
|
+
|
|
420
|
+
assert.equal(compiled.manifest.schema_version, '2.0');
|
|
421
|
+
assert.equal(compiled.manifest.role, 'implement');
|
|
422
|
+
assert.equal(compiled.manifest.gate, 'implementation');
|
|
423
|
+
assert.equal(compiled.manifest.readiness.status, 'ready');
|
|
424
|
+
assert.equal(compiled.manifest.context.items[0].ref, 'README.md');
|
|
425
|
+
assert.match(compiled.manifest.context.items[0].digest, /^[0-9a-f]{64}$/);
|
|
426
|
+
assert.equal(compiled.manifest.execution_contract.slice_kind, 'tracer_bullet');
|
|
427
|
+
assert.equal(compiled.manifest.resume.task_id, 'context-v2-task');
|
|
428
|
+
assert.match(compiled.manifest.next_action, /Implement and verify/);
|
|
429
|
+
|
|
430
|
+
const snapshot = fx.db.prepare(
|
|
431
|
+
'SELECT role, gate, next_action, readiness, context_json, token_budget FROM context_snapshots ORDER BY created_at DESC LIMIT 1',
|
|
432
|
+
).get();
|
|
433
|
+
assert.equal(snapshot.role, 'implement');
|
|
434
|
+
assert.equal(snapshot.gate, 'implementation');
|
|
435
|
+
assert.equal(snapshot.readiness, 'ready');
|
|
436
|
+
assert.equal(snapshot.token_budget, 2_000);
|
|
437
|
+
assert.equal(JSON.parse(snapshot.context_json).execution_contract.public_seam, 'public seam for context-v2-task');
|
|
438
|
+
|
|
439
|
+
const breadcrumb = changes.readBreadcrumb(fx.db, { id: 'chg-context-v2' }, { rootDir: fx.rootDir });
|
|
440
|
+
assert.equal(breadcrumb.change_id, 'chg-context-v2');
|
|
441
|
+
assert.equal(breadcrumb.task_id, 'context-v2-task');
|
|
442
|
+
assert.equal(breadcrumb.role, 'implement');
|
|
443
|
+
assert.equal(breadcrumb.readiness, 'ready');
|
|
444
|
+
assert.match(breadcrumb.context_manifest_hash, /^[0-9a-f]{64}$/);
|
|
445
|
+
} finally {
|
|
446
|
+
cleanup(fx);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('compileContext blocks a required missing ref or an over-budget role packet', () => {
|
|
451
|
+
const fx = fixture();
|
|
452
|
+
try {
|
|
453
|
+
changes.createChange(fx.db, {
|
|
454
|
+
id: 'chg-context-budget', title: 'Bound context packet', kind: 'quick',
|
|
455
|
+
intent: 'Fail closed when required context is missing or too large.',
|
|
456
|
+
}, { rootDir: fx.rootDir });
|
|
457
|
+
ops.createTask(fx.db, {
|
|
458
|
+
id: 'budget-task', title: 'Compile bounded packet', type: 'bugfix', priority: 'P0',
|
|
459
|
+
change_id: 'chg-context-budget',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const compiled = changes.compileContext(fx.db, {
|
|
463
|
+
id: 'chg-context-budget',
|
|
464
|
+
...executionContext('budget-task', {
|
|
465
|
+
context_refs: [
|
|
466
|
+
{ ref: 'missing-contract.md', kind: 'spec', reason: 'Required contract', required: true },
|
|
467
|
+
{ ref: 'README.md', kind: 'source', reason: 'Current implementation', required: true },
|
|
468
|
+
],
|
|
469
|
+
budget: { max_tokens: 1, max_files: 1 },
|
|
470
|
+
}),
|
|
471
|
+
}, { rootDir: fx.rootDir });
|
|
472
|
+
|
|
473
|
+
assert.equal(compiled.manifest.readiness.status, 'blocked');
|
|
474
|
+
assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_REQUIRED_REF_MISSING:missing-contract.md'));
|
|
475
|
+
assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_FILE_BUDGET_EXCEEDED'));
|
|
476
|
+
assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_TOKEN_BUDGET_EXCEEDED'));
|
|
477
|
+
} finally {
|
|
478
|
+
cleanup(fx);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('spec-learning candidates are approval-gated and unresolved candidates block convergence', () => {
|
|
483
|
+
const fx = fixture();
|
|
484
|
+
try {
|
|
485
|
+
changes.createChange(fx.db, {
|
|
486
|
+
id: 'chg-learning', title: 'Converge learned contract', kind: 'quick',
|
|
487
|
+
intent: 'Promote stable implementation discoveries into an approved baseline update.',
|
|
488
|
+
docs_impact: { status: 'none', files: [], rationale: 'Fixture exercises the approval state machine.' },
|
|
489
|
+
}, { rootDir: fx.rootDir });
|
|
490
|
+
ops.createTask(fx.db, {
|
|
491
|
+
id: 'learning-task', title: 'Capture stable contract', type: 'bugfix', priority: 'P0',
|
|
492
|
+
change_id: 'chg-learning',
|
|
493
|
+
});
|
|
494
|
+
ops.updateTaskStatus(fx.db, 'learning-task', 'in_progress');
|
|
495
|
+
ops.updateTaskStatus(fx.db, 'learning-task', 'completed');
|
|
496
|
+
changes.compileContext(fx.db, {
|
|
497
|
+
id: 'chg-learning', ...executionContext('learning-task'),
|
|
498
|
+
}, { rootDir: fx.rootDir });
|
|
499
|
+
|
|
500
|
+
const proposed = changes.proposeSpecLearning(fx.db, {
|
|
501
|
+
id: 'learning-1', change_id: 'chg-learning', task_id: 'learning-task',
|
|
502
|
+
target_ref: 'README.md#contract', summary: 'The public command fails closed on stale context.',
|
|
503
|
+
evidence: ['node --test learning-task.test.cjs'],
|
|
504
|
+
}, { rootDir: fx.rootDir });
|
|
505
|
+
assert.equal(proposed.status, 'proposed');
|
|
506
|
+
|
|
507
|
+
const blocked = changes.convergeChange(fx.db, {
|
|
508
|
+
id: 'chg-learning',
|
|
509
|
+
evidence: [
|
|
510
|
+
{ category: 'diff', status: 'pass', evidence: 'diff reviewed' },
|
|
511
|
+
{
|
|
512
|
+
category: 'tests', status: 'pass', evidence: 'regression passed', seam: 'public command',
|
|
513
|
+
signal: {
|
|
514
|
+
command: 'node --test learning-task.test.cjs', expected_red: 'stale context accepted',
|
|
515
|
+
observed_red: true, observed_green: true, deterministic: true, duration_ms: 20,
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
{ category: 'spec', status: 'pass', evidence: 'candidate recorded' },
|
|
519
|
+
],
|
|
520
|
+
}, { rootDir: fx.rootDir });
|
|
521
|
+
assert.ok(blocked.blockers.includes('SPEC_LEARNING_UNRESOLVED:learning-1'));
|
|
522
|
+
|
|
523
|
+
assert.equal(changes.resolveSpecLearning(fx.db, {
|
|
524
|
+
change_id: 'chg-learning', candidate_id: 'learning-1', decision: 'approve',
|
|
525
|
+
resolution: 'Stable public contract.',
|
|
526
|
+
}, { rootDir: fx.rootDir }).status, 'approved');
|
|
527
|
+
assert.equal(changes.resolveSpecLearning(fx.db, {
|
|
528
|
+
change_id: 'chg-learning', candidate_id: 'learning-1', decision: 'apply',
|
|
529
|
+
resolution: 'Applied to README.md#contract.',
|
|
530
|
+
}, { rootDir: fx.rootDir }).status, 'applied');
|
|
531
|
+
|
|
532
|
+
const projection = JSON.parse(fs.readFileSync(
|
|
533
|
+
path.join(fx.rootDir, '.ultra', 'changes', 'active', 'chg-learning', 'spec-learning.json'),
|
|
534
|
+
'utf8',
|
|
535
|
+
));
|
|
536
|
+
assert.equal(projection.source, '.ultra/state.db');
|
|
537
|
+
assert.equal(projection.candidates[0].status, 'applied');
|
|
538
|
+
} finally {
|
|
539
|
+
cleanup(fx);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const ROLES = new Set(['plan', 'implement', 'check', 'review']);
|
|
9
|
+
const GATES = new Set([
|
|
10
|
+
'alignment', 'planning', 'implementation', 'verification', 'review', 'convergence', 'recovery',
|
|
11
|
+
]);
|
|
12
|
+
const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
|
|
13
|
+
const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
|
|
14
|
+
const DEFAULT_BUDGET = Object.freeze({ max_tokens: 12_000, max_files: 12 });
|
|
15
|
+
|
|
16
|
+
class ContextSpineError extends Error {
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'ContextSpineError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function currentHead(rootDir) {
|
|
25
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
26
|
+
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseJson(value, fallback = {}) {
|
|
32
|
+
if (typeof value !== 'string' || !value.trim()) return fallback;
|
|
33
|
+
try { return JSON.parse(value); } catch { return fallback; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nonEmpty(value, field) {
|
|
37
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
38
|
+
if (!text) throw new ContextSpineError('VALIDATION_ERROR', `${field} must be a non-empty string`);
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function localPath(rootDir, ref) {
|
|
43
|
+
const candidate = ref.split('#', 1)[0];
|
|
44
|
+
if (!candidate || path.isAbsolute(candidate)) {
|
|
45
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context ref must be project-relative: ${ref}`);
|
|
46
|
+
}
|
|
47
|
+
const root = path.resolve(rootDir);
|
|
48
|
+
const resolved = path.resolve(root, candidate);
|
|
49
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
50
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context ref escapes project root: ${ref}`);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeBudget(value, inherited) {
|
|
56
|
+
const source = value || inherited || DEFAULT_BUDGET;
|
|
57
|
+
const maxTokens = Number(source.max_tokens ?? DEFAULT_BUDGET.max_tokens);
|
|
58
|
+
const maxFiles = Number(source.max_files ?? DEFAULT_BUDGET.max_files);
|
|
59
|
+
if (!Number.isInteger(maxTokens) || maxTokens < 1 || !Number.isInteger(maxFiles) || maxFiles < 1) {
|
|
60
|
+
throw new ContextSpineError('VALIDATION_ERROR', 'context budget must use positive integer max_tokens/max_files');
|
|
61
|
+
}
|
|
62
|
+
return { max_tokens: maxTokens, max_files: maxFiles };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function specRefToContextRef(value, role) {
|
|
66
|
+
const ref = typeof value === 'string'
|
|
67
|
+
? value
|
|
68
|
+
: value && (value.ref || value.path || value.reference);
|
|
69
|
+
if (!ref) throw new ContextSpineError('VALIDATION_ERROR', 'spec_refs entries require path/ref/reference');
|
|
70
|
+
return {
|
|
71
|
+
ref,
|
|
72
|
+
kind: 'spec',
|
|
73
|
+
reason: (value && value.reason) || `Specification evidence for ${role}`,
|
|
74
|
+
required: value?.required !== false,
|
|
75
|
+
digest: value && value.digest,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeRefs(input, role, inherited) {
|
|
80
|
+
let refs;
|
|
81
|
+
if (input.context_refs !== undefined) refs = input.context_refs;
|
|
82
|
+
else if (input.spec_refs !== undefined) refs = input.spec_refs.map((value) => specRefToContextRef(value, role));
|
|
83
|
+
else refs = inherited || [];
|
|
84
|
+
if (!Array.isArray(refs)) {
|
|
85
|
+
throw new ContextSpineError('VALIDATION_ERROR', 'context_refs must be an array');
|
|
86
|
+
}
|
|
87
|
+
return refs.map((value, index) => {
|
|
88
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
89
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}] must be an object`);
|
|
90
|
+
}
|
|
91
|
+
const ref = nonEmpty(value.ref || value.path || value.reference, `context_refs[${index}].ref`);
|
|
92
|
+
const kind = value.kind || 'source';
|
|
93
|
+
if (!CONTEXT_KINDS.has(kind)) {
|
|
94
|
+
throw new ContextSpineError('VALIDATION_ERROR', `unsupported context kind: ${kind}`);
|
|
95
|
+
}
|
|
96
|
+
const reason = nonEmpty(value.reason, `context_refs[${index}].reason`);
|
|
97
|
+
if (value.digest !== undefined && !/^[0-9a-f]{64}$/.test(value.digest)) {
|
|
98
|
+
throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}].digest must be sha256`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ref, kind, reason, required: value.required !== false,
|
|
102
|
+
expected_digest: value.digest || null,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function inspectRef(rootDir, role, value) {
|
|
108
|
+
if (value.kind === 'external' || /^[a-z][a-z0-9+.-]*:\/\//i.test(value.ref)) {
|
|
109
|
+
return { ...value, role, status: 'external', digest: value.expected_digest, estimated_tokens: 0 };
|
|
110
|
+
}
|
|
111
|
+
const file = localPath(rootDir, value.ref);
|
|
112
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
113
|
+
return { ...value, role, status: 'missing', digest: null, estimated_tokens: 0 };
|
|
114
|
+
}
|
|
115
|
+
const content = fs.readFileSync(file);
|
|
116
|
+
const digest = crypto.createHash('sha256').update(content).digest('hex');
|
|
117
|
+
const status = value.expected_digest && value.expected_digest !== digest ? 'stale' : 'current';
|
|
118
|
+
return {
|
|
119
|
+
...value, role, status, digest,
|
|
120
|
+
estimated_tokens: Math.max(1, Math.ceil(content.byteLength / 4)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeExecutionContract(input, inherited, { taskId }) {
|
|
125
|
+
const source = input.execution_contract || inherited || null;
|
|
126
|
+
if (!taskId) return source;
|
|
127
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
|
|
128
|
+
const sliceKind = source.slice_kind || 'tracer_bullet';
|
|
129
|
+
if (!SLICE_KINDS.has(sliceKind)) {
|
|
130
|
+
throw new ContextSpineError('VALIDATION_ERROR', `unsupported slice kind: ${sliceKind}`);
|
|
131
|
+
}
|
|
132
|
+
const percent = Number(source.context_budget_percent ?? 40);
|
|
133
|
+
if (!Number.isInteger(percent) || percent < 1 || percent > 100) {
|
|
134
|
+
throw new ContextSpineError('VALIDATION_ERROR', 'context_budget_percent must be an integer from 1 to 100');
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
slice_kind: sliceKind,
|
|
138
|
+
public_seam: typeof source.public_seam === 'string' ? source.public_seam.trim() : '',
|
|
139
|
+
verification_command: typeof source.verification_command === 'string'
|
|
140
|
+
? source.verification_command.trim()
|
|
141
|
+
: '',
|
|
142
|
+
context_budget_percent: percent,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function defaultGate(role) {
|
|
147
|
+
return {
|
|
148
|
+
plan: 'planning', implement: 'implementation', check: 'verification', review: 'review',
|
|
149
|
+
}[role];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function deriveNextAction({ change, tasks, task, role, readiness, explicit }) {
|
|
153
|
+
if (readiness === 'blocked') return 'Resolve the context readiness blockers, then recompile change.context.';
|
|
154
|
+
if (explicit && String(explicit).trim()) return String(explicit).trim();
|
|
155
|
+
if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
|
|
156
|
+
if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
|
|
157
|
+
if (!task && tasks.length === 0) return `Create the first fresh-context tracer-bullet task for change ${change.id}.`;
|
|
158
|
+
if (task?.status === 'pending') return `Start task ${task.id} through ultra-dev.`;
|
|
159
|
+
if (task?.status === 'in_progress') {
|
|
160
|
+
if (role === 'check') return `Run the exact verification command for task ${task.id}.`;
|
|
161
|
+
if (role === 'review') return `Complete independent spec-fidelity and engineering-standards review for task ${task.id}.`;
|
|
162
|
+
return `Continue the approved vertical slice for task ${task.id}.`;
|
|
163
|
+
}
|
|
164
|
+
if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
|
|
165
|
+
return `Run ultra-test for change ${change.id}.`;
|
|
166
|
+
}
|
|
167
|
+
const pending = tasks.find((row) => row.status === 'pending');
|
|
168
|
+
return pending ? `Compile implement context for task ${pending.id}.` : `Inspect blockers for change ${change.id}.`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function latestContext(db, changeId, taskId) {
|
|
172
|
+
const row = taskId
|
|
173
|
+
? db.prepare(
|
|
174
|
+
'SELECT context_json FROM context_snapshots WHERE change_id = ? AND task_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
175
|
+
).get(changeId, taskId)
|
|
176
|
+
: db.prepare(
|
|
177
|
+
'SELECT context_json FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
178
|
+
).get(changeId);
|
|
179
|
+
return parseJson(row?.context_json, {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function compileRoleContext(db, { input, change, tasks, rootDir }) {
|
|
183
|
+
const task = input.task_id ? tasks.find((row) => row.id === input.task_id) : null;
|
|
184
|
+
if (input.task_id && !task) {
|
|
185
|
+
throw new ContextSpineError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
|
|
186
|
+
}
|
|
187
|
+
const inherited = latestContext(db, change.id, input.task_id || null);
|
|
188
|
+
const role = input.role || (input.task_id ? 'implement' : 'plan');
|
|
189
|
+
if (!ROLES.has(role)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported context role: ${role}`);
|
|
190
|
+
const gate = input.gate || defaultGate(role);
|
|
191
|
+
if (!GATES.has(gate)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported workflow gate: ${gate}`);
|
|
192
|
+
|
|
193
|
+
const refs = normalizeRefs(input, role, inherited.context?.items);
|
|
194
|
+
const items = refs.map((value) => inspectRef(rootDir, role, value));
|
|
195
|
+
const budget = normalizeBudget(input.budget, inherited.context?.budget);
|
|
196
|
+
const tokenEstimate = items.reduce((sum, item) => sum + item.estimated_tokens, 0);
|
|
197
|
+
const fileCount = items.filter((item) => item.status !== 'external').length;
|
|
198
|
+
const executionContract = normalizeExecutionContract(
|
|
199
|
+
input, inherited.execution_contract, { taskId: input.task_id },
|
|
200
|
+
);
|
|
201
|
+
const blockers = [];
|
|
202
|
+
for (const item of items) {
|
|
203
|
+
if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
|
|
204
|
+
if (item.required && item.status === 'stale') blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
|
|
205
|
+
}
|
|
206
|
+
if (fileCount > budget.max_files) blockers.push('CONTEXT_FILE_BUDGET_EXCEEDED');
|
|
207
|
+
if (tokenEstimate > budget.max_tokens) blockers.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
|
|
208
|
+
if (input.task_id) {
|
|
209
|
+
if (!executionContract) blockers.push('EXECUTION_CONTRACT_MISSING');
|
|
210
|
+
else {
|
|
211
|
+
if (!executionContract.public_seam) blockers.push('EXECUTION_PUBLIC_SEAM_MISSING');
|
|
212
|
+
if (!executionContract.verification_command) blockers.push('EXECUTION_VERIFICATION_COMMAND_MISSING');
|
|
213
|
+
if (executionContract.slice_kind !== 'expand_contract'
|
|
214
|
+
&& executionContract.context_budget_percent > 40) {
|
|
215
|
+
blockers.push('EXECUTION_CONTEXT_BUDGET_EXCEEDED');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const readiness = blockers.length === 0 ? 'ready' : 'blocked';
|
|
220
|
+
const nextAction = deriveNextAction({
|
|
221
|
+
change, tasks, task, role, readiness, explicit: input.next_action,
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
role,
|
|
225
|
+
gate,
|
|
226
|
+
next_action: nextAction,
|
|
227
|
+
readiness: { status: readiness, blockers },
|
|
228
|
+
context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
|
|
229
|
+
execution_contract: executionContract,
|
|
230
|
+
selected_task: task,
|
|
231
|
+
resume: {
|
|
232
|
+
change_id: change.id,
|
|
233
|
+
task_id: task?.id || null,
|
|
234
|
+
task_status: task?.status || null,
|
|
235
|
+
session_id: task?.session_id || null,
|
|
236
|
+
role,
|
|
237
|
+
gate,
|
|
238
|
+
git_head: currentHead(rootDir),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function recommendedWorkflow(change, task, tasks, readiness) {
|
|
244
|
+
if (!change) return 'ultra-change';
|
|
245
|
+
if (readiness !== 'ready' || change.status === 'blocked') return 'ultra-change';
|
|
246
|
+
if (change.status === 'ready') return 'ultra-deliver';
|
|
247
|
+
if (task && ['pending', 'in_progress'].includes(task.status)) return 'ultra-dev';
|
|
248
|
+
if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) return 'ultra-test';
|
|
249
|
+
return 'ultra-change';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
|
|
253
|
+
const change = id
|
|
254
|
+
? db.prepare('SELECT * FROM changes WHERE id = ?').get(id)
|
|
255
|
+
: db.prepare(
|
|
256
|
+
"SELECT * FROM changes WHERE status IN ('active', 'blocked', 'ready') ORDER BY updated_at DESC LIMIT 1",
|
|
257
|
+
).get();
|
|
258
|
+
if (id && !change) {
|
|
259
|
+
throw new ContextSpineError('CHANGE_NOT_FOUND', `change ${id} not found`);
|
|
260
|
+
}
|
|
261
|
+
if (!change) {
|
|
262
|
+
return {
|
|
263
|
+
change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
|
|
264
|
+
next_action: 'Start daily work with ultra-change.', recommended_workflow: 'ultra-change',
|
|
265
|
+
context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const tasks = db.prepare('SELECT * FROM tasks WHERE change_id = ? ORDER BY created_at ASC').all(change.id);
|
|
269
|
+
const snapshot = db.prepare(
|
|
270
|
+
'SELECT * FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
|
|
271
|
+
).get(change.id);
|
|
272
|
+
const context = parseJson(snapshot?.context_json, {});
|
|
273
|
+
const taskId = snapshot?.task_id || tasks.find((row) => row.status === 'in_progress')?.id
|
|
274
|
+
|| tasks.find((row) => row.status === 'pending')?.id || null;
|
|
275
|
+
const task = tasks.find((row) => row.id === taskId) || null;
|
|
276
|
+
const head = currentHead(rootDir);
|
|
277
|
+
const snapshotMissing = !snapshot;
|
|
278
|
+
const legacyContext = Boolean(snapshot && (!context.resume || !context.context || !context.readiness));
|
|
279
|
+
const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
|
|
280
|
+
const stateChanged = context.resume?.task_status !== undefined
|
|
281
|
+
&& context.resume.task_status !== (task?.status || null);
|
|
282
|
+
const blockers = [];
|
|
283
|
+
if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
|
|
284
|
+
else {
|
|
285
|
+
if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
|
|
286
|
+
if (headStale) blockers.push('CONTEXT_HEAD_STALE');
|
|
287
|
+
if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
|
|
288
|
+
for (const blocker of parseJson(snapshot.blockers_json, [])) {
|
|
289
|
+
if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
|
|
293
|
+
let nextAction;
|
|
294
|
+
if (snapshotMissing) {
|
|
295
|
+
nextAction = `Compile change.context for change ${change.id} before continuing.`;
|
|
296
|
+
} else if (legacyContext) {
|
|
297
|
+
nextAction = 'Recompile change.context to upgrade this legacy context snapshot.';
|
|
298
|
+
} else if (headStale) {
|
|
299
|
+
nextAction = 'Git HEAD changed after context compilation; recompile change.context.';
|
|
300
|
+
} else if (stateChanged) {
|
|
301
|
+
nextAction = 'Task state changed after context compilation; recompile change.context.';
|
|
302
|
+
} else {
|
|
303
|
+
nextAction = deriveNextAction({
|
|
304
|
+
change, tasks, task, role: snapshot?.role || 'plan', readiness,
|
|
305
|
+
explicit: snapshot?.next_action,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
change_id: change.id,
|
|
310
|
+
change_status: change.status,
|
|
311
|
+
task_id: task?.id || null,
|
|
312
|
+
task_status: task?.status || null,
|
|
313
|
+
session_id: task?.session_id || null,
|
|
314
|
+
role: snapshot?.role || 'plan',
|
|
315
|
+
gate: snapshot?.gate || 'alignment',
|
|
316
|
+
readiness,
|
|
317
|
+
blockers,
|
|
318
|
+
next_action: nextAction,
|
|
319
|
+
recommended_workflow: recommendedWorkflow(change, task, tasks, readiness),
|
|
320
|
+
context_manifest_path: snapshot?.manifest_path || null,
|
|
321
|
+
context_manifest_hash: snapshot?.manifest_hash || null,
|
|
322
|
+
git_head: head,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
ContextSpineError,
|
|
328
|
+
compileRoleContext,
|
|
329
|
+
deriveNextAction,
|
|
330
|
+
readBreadcrumb,
|
|
331
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const { compileRoleContext, deriveNextAction, readBreadcrumb } = require('./context-spine.cjs');
|
|
10
|
+
const { initStateDb } = require('./state-db.cjs');
|
|
11
|
+
const { createChange } = require('./change-workflow.cjs');
|
|
12
|
+
const { createTask } = require('./state-ops.cjs');
|
|
13
|
+
|
|
14
|
+
test('deriveNextAction gives one state-specific action instead of a workflow dump', () => {
|
|
15
|
+
const change = { id: 'context-spine', status: 'active' };
|
|
16
|
+
const task = { id: 'task-1', status: 'in_progress' };
|
|
17
|
+
const action = deriveNextAction({
|
|
18
|
+
change, tasks: [task], task, role: 'review', readiness: 'ready', explicit: null,
|
|
19
|
+
});
|
|
20
|
+
assert.equal(
|
|
21
|
+
action,
|
|
22
|
+
'Complete independent spec-fidelity and engineering-standards review for task task-1.',
|
|
23
|
+
);
|
|
24
|
+
assert.doesNotMatch(action, /plan.*implement.*check.*review/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('deriveNextAction fails closed on blocked context readiness', () => {
|
|
28
|
+
const action = deriveNextAction({
|
|
29
|
+
change: { id: 'context-spine', status: 'active' },
|
|
30
|
+
tasks: [], task: null, role: 'plan', readiness: 'blocked', explicit: 'Ignore blockers.',
|
|
31
|
+
});
|
|
32
|
+
assert.match(action, /Resolve the context readiness blockers/);
|
|
33
|
+
assert.doesNotMatch(action, /Ignore blockers/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a task is not plan-ready before its execution contract is compiled', () => {
|
|
37
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-plan-'));
|
|
38
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
39
|
+
try {
|
|
40
|
+
const { change } = createChange(db, {
|
|
41
|
+
id: 'planning-contract', title: 'Planning contract', kind: 'standard',
|
|
42
|
+
intent: 'Require an executable fresh-context slice before implementation.',
|
|
43
|
+
docs_impact: { status: 'none', rationale: 'test fixture only' },
|
|
44
|
+
}, { rootDir });
|
|
45
|
+
const task = createTask(db, {
|
|
46
|
+
id: 'task-plan', title: 'Plan one slice', type: 'feature', priority: 'P0',
|
|
47
|
+
change_id: change.id,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const context = compileRoleContext(db, {
|
|
51
|
+
input: { task_id: task.id, role: 'plan', gate: 'planning' },
|
|
52
|
+
change, tasks: [task], rootDir,
|
|
53
|
+
});
|
|
54
|
+
assert.equal(context.readiness.status, 'blocked');
|
|
55
|
+
assert.ok(context.readiness.blockers.includes('EXECUTION_CONTRACT_MISSING'));
|
|
56
|
+
|
|
57
|
+
const breadcrumb = readBreadcrumb(db, { id: change.id }, { rootDir });
|
|
58
|
+
assert.equal(breadcrumb.readiness, 'blocked');
|
|
59
|
+
assert.ok(breadcrumb.blockers.includes('CONTEXT_TASK_STATE_STALE'));
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => readBreadcrumb(db, { id: 'missing-change' }, { rootDir }),
|
|
62
|
+
(error) => error.code === 'CHANGE_NOT_FOUND',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
db.prepare("UPDATE context_snapshots SET context_json = '{}', readiness = 'ready'").run();
|
|
66
|
+
const migratedLegacy = readBreadcrumb(db, { id: change.id }, { rootDir });
|
|
67
|
+
assert.equal(migratedLegacy.readiness, 'blocked');
|
|
68
|
+
assert.ok(migratedLegacy.blockers.includes('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED'));
|
|
69
|
+
} finally {
|
|
70
|
+
db.close();
|
|
71
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|