ultra-builder-pro-cli 0.14.0 → 0.15.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.
Files changed (154) hide show
  1. package/.ultra-template/docs/research/README.md +11 -7
  2. package/.ultra-template/specs/architecture.md +36 -36
  3. package/.ultra-template/specs/discovery.md +46 -33
  4. package/.ultra-template/specs/product.md +38 -29
  5. package/.ultra-template/specs/research-distillate.md +48 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
  7. package/CHANGELOG.md +44 -1
  8. package/README.md +34 -19
  9. package/adapters/_shared/codex-assets.cjs +6 -2
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/claude.js +3 -2
  12. package/adapters/codex.js +1 -0
  13. package/adapters/kimi.js +3 -2
  14. package/adapters/opencode.js +3 -2
  15. package/agents/review-coordinator.md +9 -6
  16. package/docs/AGENT-CONTEXT.md +15 -8
  17. package/docs/ARCHITECTURE.md +33 -12
  18. package/docs/PLAN.zh-CN.md +3 -2
  19. package/docs/ROADMAP.md +8 -4
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  21. package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
  22. package/docs/WORKFLOW-LIFECYCLE.md +266 -0
  23. package/hooks/README.md +6 -1
  24. package/hooks/health_check.py +12 -3
  25. package/hooks/subagent_tracker.py +52 -77
  26. package/mcp-server/hook-event.cjs +34 -0
  27. package/mcp-server/lib/archive-journal.cjs +17 -5
  28. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  29. package/mcp-server/lib/change-workflow.cjs +550 -85
  30. package/mcp-server/lib/context-spine.cjs +242 -24
  31. package/mcp-server/lib/doctor.cjs +33 -3
  32. package/mcp-server/lib/init-project.cjs +210 -16
  33. package/mcp-server/lib/plan-store.cjs +4 -1
  34. package/mcp-server/lib/pricing.cjs +7 -10
  35. package/mcp-server/lib/project-breadcrumb.cjs +6 -0
  36. package/mcp-server/lib/projector.cjs +57 -3
  37. package/mcp-server/lib/spec-learning.cjs +79 -2
  38. package/mcp-server/lib/state-db.cjs +145 -8
  39. package/mcp-server/lib/state-ops.cjs +160 -9
  40. package/mcp-server/lib/workflow-state.cjs +2292 -0
  41. package/mcp-server/server.cjs +161 -15
  42. package/orchestrator/daemon.cjs +1 -2
  43. package/orchestrator/dispatch-rules.cjs +5 -31
  44. package/orchestrator/planner/plan-builder.cjs +21 -12
  45. package/package.json +13 -3
  46. package/skills/ultra-change/SKILL.md +53 -68
  47. package/skills/ultra-change/references/change-contract.md +36 -0
  48. package/skills/ultra-deliver/SKILL.md +69 -79
  49. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  50. package/skills/ultra-dev/SKILL.md +54 -59
  51. package/skills/ultra-doctor/SKILL.md +40 -71
  52. package/skills/ultra-init/SKILL.md +96 -103
  53. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  54. package/skills/ultra-plan/SKILL.md +79 -85
  55. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  56. package/skills/ultra-research/SKILL.md +87 -60
  57. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  58. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  59. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  60. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  61. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  62. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  63. package/skills/ultra-research/references/10-user-personas.md +29 -0
  64. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  65. package/skills/ultra-research/references/20-user-stories.md +30 -0
  66. package/skills/ultra-research/references/21-features-scope.md +29 -0
  67. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  68. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  69. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  70. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  71. package/skills/ultra-research/references/40-deployment.md +29 -0
  72. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  73. package/skills/ultra-research/references/99-synthesis.md +35 -0
  74. package/skills/ultra-review/SKILL.md +57 -73
  75. package/skills/ultra-review/references/review-modes.md +27 -0
  76. package/skills/ultra-review/references/unified-schema.md +25 -0
  77. package/skills/ultra-review/scripts/review_wait.py +40 -3
  78. package/skills/ultra-status/SKILL.md +41 -45
  79. package/skills/ultra-test/SKILL.md +48 -55
  80. package/skills/ultra-verify/SKILL.md +1 -1
  81. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  82. package/spec/cli-protocol.md +7 -2
  83. package/spec/mcp-tools.yaml +384 -63
  84. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  85. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  86. package/spec/schemas/mcp-tools.schema.json +1 -1
  87. package/spec/schemas/state-db.sql +82 -1
  88. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  89. package/spec/schemas/test-report.v1.schema.json +194 -0
  90. package/ultra-tools/cli.cjs +1 -1
  91. package/ultra-tools/commands/migrate.cjs +3 -3
  92. package/ultra-tools/commands/status.cjs +213 -12
  93. package/.ultra-template/delivery-report.json +0 -9
  94. package/.ultra-template/test-report.json +0 -8
  95. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  96. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  97. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  98. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  99. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  100. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  101. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
  102. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  103. package/adapters/_shared/tests/validate.test.cjs +0 -30
  104. package/adapters/tests/claude.test.cjs +0 -138
  105. package/adapters/tests/codex-hook.test.cjs +0 -142
  106. package/adapters/tests/codex.test.cjs +0 -430
  107. package/adapters/tests/kimi-hook.test.cjs +0 -149
  108. package/adapters/tests/kimi.test.cjs +0 -259
  109. package/adapters/tests/opencode.test.cjs +0 -357
  110. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  111. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  112. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  113. package/mcp-server/lib/context-spine.test.cjs +0 -93
  114. package/mcp-server/lib/doctor.test.cjs +0 -148
  115. package/mcp-server/lib/plan-store.test.cjs +0 -132
  116. package/mcp-server/lib/prd-parser.test.cjs +0 -82
  117. package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
  118. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  119. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  120. package/mcp-server/lib/state-db.test.cjs +0 -314
  121. package/mcp-server/lib/task-expander.test.cjs +0 -169
  122. package/mcp-server/lib/topo.test.cjs +0 -158
  123. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  124. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  125. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  126. package/mcp-server/tests/concurrency.test.cjs +0 -112
  127. package/mcp-server/tests/db-ops.test.cjs +0 -121
  128. package/mcp-server/tests/init-project.test.cjs +0 -393
  129. package/mcp-server/tests/migrate.test.cjs +0 -232
  130. package/mcp-server/tests/projector.test.cjs +0 -162
  131. package/mcp-server/tests/server-contract.test.cjs +0 -1467
  132. package/mcp-server/tests/staleness.test.cjs +0 -190
  133. package/mcp-server/tests/state-db.test.cjs +0 -106
  134. package/mcp-server/tests/state-ops.test.cjs +0 -449
  135. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  136. package/mcp-server/tests/telemetry.test.cjs +0 -219
  137. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  138. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  139. package/orchestrator/tests/daemon.test.cjs +0 -311
  140. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  141. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  142. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  143. package/orchestrator/tests/recovery.test.cjs +0 -190
  144. package/orchestrator/tests/session-runner.test.cjs +0 -314
  145. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  146. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  147. package/skills/ultra-research/references/architecture.md +0 -38
  148. package/skills/ultra-research/references/problem-product.md +0 -39
  149. package/skills/ultra-research/references/quality-delivery.md +0 -34
  150. package/skills/ultra-research/references/synthesis.md +0 -38
  151. package/skills/ultra-research/references/users-scope.md +0 -40
  152. package/ultra-tools/legacy-memory.test.cjs +0 -80
  153. package/ultra-tools/status-command.test.cjs +0 -181
  154. package/ultra-tools/system-command.test.cjs +0 -309
@@ -1,158 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
-
6
- const { computeWaves } = require('./topo.cjs');
7
-
8
- function makeTask(id, deps = []) {
9
- return { id, deps };
10
- }
11
-
12
- function idsSet(ids) {
13
- return new Set(ids);
14
- }
15
-
16
- function asWaveSets(waves) {
17
- return waves.map(idsSet);
18
- }
19
-
20
- test('topo: empty array → empty waves and cycles', () => {
21
- const r = computeWaves([]);
22
- assert.deepEqual(r.waves, []);
23
- assert.deepEqual(r.cycles, []);
24
- });
25
-
26
- test('topo: single task with no deps → single wave', () => {
27
- const r = computeWaves([makeTask('A')]);
28
- assert.deepEqual(r.waves, [['A']]);
29
- assert.deepEqual(r.cycles, []);
30
- });
31
-
32
- test('topo fixture 1: linear chain A, B(A), C(B) → three serial waves', () => {
33
- const r = computeWaves([
34
- makeTask('A'),
35
- makeTask('B', ['A']),
36
- makeTask('C', ['B']),
37
- ]);
38
- assert.deepEqual(asWaveSets(r.waves), [idsSet(['A']), idsSet(['B']), idsSet(['C'])]);
39
- assert.deepEqual(r.cycles, []);
40
- });
41
-
42
- test('topo fixture 2: fan-out A→{B,C} + independent D→E → 2 waves', () => {
43
- const r = computeWaves([
44
- makeTask('A'),
45
- makeTask('B', ['A']),
46
- makeTask('C', ['A']),
47
- makeTask('D'),
48
- makeTask('E', ['D']),
49
- ]);
50
- assert.equal(r.waves.length, 2);
51
- assert.deepEqual(asWaveSets(r.waves), [
52
- idsSet(['A', 'D']),
53
- idsSet(['B', 'C', 'E']),
54
- ]);
55
- assert.deepEqual(r.cycles, []);
56
- });
57
-
58
- test('topo fixture 3: double diamond merges waves correctly', () => {
59
- const r = computeWaves([
60
- makeTask('A'),
61
- makeTask('B', ['A']),
62
- makeTask('C', ['A']),
63
- makeTask('D', ['B', 'C']),
64
- makeTask('E'),
65
- makeTask('F', ['E']),
66
- makeTask('G', ['E']),
67
- makeTask('H', ['F', 'G']),
68
- ]);
69
- assert.deepEqual(asWaveSets(r.waves), [
70
- idsSet(['A', 'E']),
71
- idsSet(['B', 'C', 'F', 'G']),
72
- idsSet(['D', 'H']),
73
- ]);
74
- assert.deepEqual(r.cycles, []);
75
- });
76
-
77
- test('topo fixture 4: 2-node cycle A↔B → cycle reported, no waves', () => {
78
- const r = computeWaves([
79
- makeTask('A', ['B']),
80
- makeTask('B', ['A']),
81
- ]);
82
- assert.deepEqual(r.waves, []);
83
- assert.equal(r.cycles.length, 1);
84
- assert.deepEqual(idsSet(r.cycles[0]), idsSet(['A', 'B']));
85
- });
86
-
87
- test('topo fixture 5: two independent cycles A↔B and C↔D → both reported', () => {
88
- const r = computeWaves([
89
- makeTask('A', ['B']),
90
- makeTask('B', ['A']),
91
- makeTask('C', ['D']),
92
- makeTask('D', ['C']),
93
- ]);
94
- assert.deepEqual(r.waves, []);
95
- assert.equal(r.cycles.length, 2);
96
- const cycleSets = r.cycles.map((c) => idsSet(c));
97
- const expected = [idsSet(['A', 'B']), idsSet(['C', 'D'])];
98
- for (const exp of expected) {
99
- const found = cycleSets.some(
100
- (s) => s.size === exp.size && [...s].every((x) => exp.has(x)),
101
- );
102
- assert.ok(found, `cycle ${[...exp].join(',')} not reported`);
103
- }
104
- });
105
-
106
- test('topo: self-loop counts as cycle', () => {
107
- const r = computeWaves([makeTask('A', ['A'])]);
108
- assert.deepEqual(r.waves, []);
109
- assert.equal(r.cycles.length, 1);
110
- assert.deepEqual(r.cycles[0], ['A']);
111
- });
112
-
113
- test('topo: cycle plus acyclic tail — tail is waved, cycle isolated', () => {
114
- const r = computeWaves([
115
- makeTask('A', ['B']),
116
- makeTask('B', ['A']),
117
- makeTask('X'),
118
- makeTask('Y', ['X']),
119
- ]);
120
- assert.deepEqual(asWaveSets(r.waves), [idsSet(['X']), idsSet(['Y'])]);
121
- assert.equal(r.cycles.length, 1);
122
- assert.deepEqual(idsSet(r.cycles[0]), idsSet(['A', 'B']));
123
- });
124
-
125
- test('topo: external deps (ids not in task_ids) are treated as satisfied', () => {
126
- const r = computeWaves([makeTask('A', ['X'])]);
127
- assert.deepEqual(r.waves, [['A']]);
128
- assert.deepEqual(r.cycles, []);
129
- });
130
-
131
- test('topo: input order does not change wave membership', () => {
132
- const tasks = [
133
- makeTask('A'),
134
- makeTask('B', ['A']),
135
- makeTask('C', ['A']),
136
- makeTask('D', ['B', 'C']),
137
- ];
138
- const r1 = computeWaves(tasks);
139
- const r2 = computeWaves([...tasks].reverse());
140
- assert.deepEqual(asWaveSets(r1.waves), asWaveSets(r2.waves));
141
- assert.deepEqual(r1.cycles, r2.cycles);
142
- });
143
-
144
- test('topo: rejects non-array input', () => {
145
- assert.throws(() => computeWaves(null), TypeError);
146
- assert.throws(() => computeWaves({}), TypeError);
147
- });
148
-
149
- test('topo: 3-node cycle A→B→C→A surfaced intact', () => {
150
- const r = computeWaves([
151
- makeTask('A', ['C']),
152
- makeTask('B', ['A']),
153
- makeTask('C', ['B']),
154
- ]);
155
- assert.deepEqual(r.waves, []);
156
- assert.equal(r.cycles.length, 1);
157
- assert.deepEqual(idsSet(r.cycles[0]), idsSet(['A', 'B', 'C']));
158
- });
@@ -1,125 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 5.2 — Circuit breaker:
4
- // per-task consecutive failures ≥3 → trip; tripped tasks refused spawn.
5
-
6
- const test = require('node:test');
7
- const assert = require('node:assert/strict');
8
- const fs = require('node:fs');
9
- const os = require('node:os');
10
- const path = require('node:path');
11
-
12
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
13
- const ops = require('../lib/state-ops.cjs');
14
-
15
- function freshDb() {
16
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-cb-'));
17
- const file = path.join(dir, 'state.db');
18
- const init = initStateDb(file);
19
- return { dir, db: init.db };
20
- }
21
-
22
- function teardown(dir, db) {
23
- try { closeStateDb(db); } catch (_) { /* ignore */ }
24
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
25
- }
26
-
27
- function seedTask(db, id) {
28
- ops.createTask(db, { id, title: 'cb target', type: 'feature', priority: 'P1' });
29
- return id;
30
- }
31
-
32
- test('isCircuitTripped: unrecorded task → false', () => {
33
- const { dir, db } = freshDb();
34
- try {
35
- seedTask(db, 'cb-1');
36
- assert.equal(ops.isCircuitTripped(db, 'cb-1'), false);
37
- } finally { teardown(dir, db); }
38
- });
39
-
40
- test('recordTaskFailure: 3 consecutive → trips + task_circuit_broken event', () => {
41
- const { dir, db } = freshDb();
42
- try {
43
- seedTask(db, 'cb-2');
44
- ops.recordTaskFailure(db, 'cb-2', { reason: 'test1' });
45
- assert.equal(ops.isCircuitTripped(db, 'cb-2'), false);
46
- ops.recordTaskFailure(db, 'cb-2', { reason: 'test2' });
47
- assert.equal(ops.isCircuitTripped(db, 'cb-2'), false);
48
- ops.recordTaskFailure(db, 'cb-2', { reason: 'test3' });
49
- assert.equal(ops.isCircuitTripped(db, 'cb-2'), true);
50
-
51
- const { events } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
52
- const brokenEvents = events.filter((e) => e.type === 'task_circuit_broken' && e.task_id === 'cb-2');
53
- assert.equal(brokenEvents.length, 1, 'circuit broken event should fire exactly once');
54
- const failures = events.filter((e) => e.type === 'task_failure' && e.task_id === 'cb-2');
55
- assert.equal(failures.length, 3);
56
- } finally { teardown(dir, db); }
57
- });
58
-
59
- test('recordTaskFailure: 4th + 5th failure does not re-emit task_circuit_broken', () => {
60
- const { dir, db } = freshDb();
61
- try {
62
- seedTask(db, 'cb-3');
63
- for (let i = 0; i < 5; i += 1) {
64
- ops.recordTaskFailure(db, 'cb-3', { reason: `test${i}` });
65
- }
66
- assert.equal(ops.isCircuitTripped(db, 'cb-3'), true);
67
- const { events } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
68
- const brokenEvents = events.filter((e) => e.type === 'task_circuit_broken' && e.task_id === 'cb-3');
69
- assert.equal(brokenEvents.length, 1, 'should only emit on the trip transition');
70
- } finally { teardown(dir, db); }
71
- });
72
-
73
- test('resetCircuitBreaker: clears count and tripped_at', () => {
74
- const { dir, db } = freshDb();
75
- try {
76
- seedTask(db, 'cb-4');
77
- ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
78
- ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
79
- ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
80
- assert.equal(ops.isCircuitTripped(db, 'cb-4'), true);
81
-
82
- ops.resetCircuitBreaker(db, 'cb-4');
83
- assert.equal(ops.isCircuitTripped(db, 'cb-4'), false);
84
-
85
- // Next failure starts count at 1, not accumulating pre-reset.
86
- ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
87
- assert.equal(ops.isCircuitTripped(db, 'cb-4'), false);
88
- ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
89
- assert.equal(ops.isCircuitTripped(db, 'cb-4'), false);
90
- } finally { teardown(dir, db); }
91
- });
92
-
93
- test('admissionCheck: tripped task → can_spawn=false, recommended=blocked_by_breaker', () => {
94
- const { dir, db } = freshDb();
95
- try {
96
- seedTask(db, 'cb-5');
97
- for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'cb-5', { reason: 'x' });
98
-
99
- const verdict = ops.admissionCheck(db, 'cb-5');
100
- assert.equal(verdict.can_spawn, false);
101
- assert.equal(verdict.recommended_action, 'blocked_by_breaker');
102
- } finally { teardown(dir, db); }
103
- });
104
-
105
- test('admissionCheck: reset after trip → can_spawn=true', () => {
106
- const { dir, db } = freshDb();
107
- try {
108
- seedTask(db, 'cb-6');
109
- for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'cb-6', { reason: 'x' });
110
- ops.resetCircuitBreaker(db, 'cb-6');
111
- const verdict = ops.admissionCheck(db, 'cb-6');
112
- assert.equal(verdict.can_spawn, true);
113
- } finally { teardown(dir, db); }
114
- });
115
-
116
- test('custom threshold: fail_threshold=2 trips on 2nd failure', () => {
117
- const { dir, db } = freshDb();
118
- try {
119
- seedTask(db, 'cb-7');
120
- ops.recordTaskFailure(db, 'cb-7', { reason: 'x', fail_threshold: 2 });
121
- assert.equal(ops.isCircuitTripped(db, 'cb-7'), false);
122
- ops.recordTaskFailure(db, 'cb-7', { reason: 'x', fail_threshold: 2 });
123
- assert.equal(ops.isCircuitTripped(db, 'cb-7'), true);
124
- } finally { teardown(dir, db); }
125
- });
@@ -1,117 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('node:fs');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
- const { spawnSync } = require('node:child_process');
9
-
10
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
11
- const ops = require('../lib/state-ops.cjs');
12
- const projector = require('../lib/projector.cjs');
13
-
14
- function gitRun(cwd, args, opts = {}) {
15
- const env = {
16
- ...process.env,
17
- GIT_AUTHOR_NAME: 'ubp-test',
18
- GIT_AUTHOR_EMAIL: 'ubp@example.com',
19
- GIT_COMMITTER_NAME: 'ubp-test',
20
- GIT_COMMITTER_EMAIL: 'ubp@example.com',
21
- };
22
- const r = spawnSync('git', args, { cwd, encoding: 'utf8', env, ...opts });
23
- if (r.status !== 0 && !opts.allowFail) {
24
- throw new Error(`git ${args.join(' ')} failed: ${r.stderr || r.stdout}`);
25
- }
26
- return { code: r.status, stdout: r.stdout.trim(), stderr: r.stderr };
27
- }
28
-
29
- function tmpRepo() {
30
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-hash-flow-'));
31
- gitRun(dir, ['init', '-q', '-b', 'main']);
32
- gitRun(dir, ['config', 'commit.gpgsign', 'false']);
33
- // Ignore the SQLite WAL/SHM sidecars so they don't appear as modifications.
34
- fs.writeFileSync(path.join(dir, '.gitignore'), '.ultra/state.db-shm\n.ultra/state.db-wal\n');
35
- return dir;
36
- }
37
-
38
- test('two-commit flow lands feat then chore with the right SHA in context md', () => {
39
- const dir = tmpRepo();
40
- try {
41
- const dbPath = path.join(dir, '.ultra', 'state.db');
42
- const init = initStateDb(dbPath);
43
- const db = init.db;
44
-
45
- // Task starts pending, no completion_commit yet.
46
- ops.createTask(db, { id: 'task-h1', title: 'commit hash flow', type: 'feature', priority: 'P1' });
47
- projector.projectAll(db, { rootDir: dir });
48
-
49
- // Stage initial state and land the feat commit.
50
- fs.writeFileSync(path.join(dir, 'src.js'), '// implementation\n');
51
- gitRun(dir, ['add', '-A']);
52
- gitRun(dir, ['commit', '-q', '-m', 'feat: task-h1 — commit hash flow']);
53
-
54
- const featSha = gitRun(dir, ['rev-parse', 'HEAD']).stdout;
55
-
56
- // Step 3 — update state.db with the new SHA.
57
- ops.patchTask(db, 'task-h1', { completion_commit: featSha });
58
-
59
- // Step 4 — re-project the context so its YAML carries the hash.
60
- projector.projectContext(db, 'task-h1', {}, { rootDir: dir });
61
-
62
- // Step 5 — chore commit captures the projection update.
63
- gitRun(dir, ['add', '-A']);
64
- gitRun(dir, ['commit', '-q', '-m', 'chore: record task-h1 completion hash']);
65
-
66
- const log = gitRun(dir, ['log', '--oneline', '-2']).stdout.split('\n');
67
- assert.match(log[0], /chore: record task-h1 completion hash/);
68
- assert.match(log[1], /feat: task-h1 — commit hash flow/);
69
-
70
- const ctxFile = path.join(dir, '.ultra', 'tasks', 'contexts', 'task-task-h1.md');
71
- const ctxText = fs.readFileSync(ctxFile, 'utf8');
72
- // The chore commit's working tree must reflect the recorded SHA.
73
- const dbRow = db.prepare("SELECT completion_commit FROM tasks WHERE id = 'task-h1'").get();
74
- assert.equal(dbRow.completion_commit, featSha);
75
- // Header is regenerated; ensure projector wrote v4.5 + the right task_id.
76
- assert.match(ctxText, /^---/);
77
- assert.match(ctxText, /schema_version: 4\.5/);
78
- assert.match(ctxText, /task_id: task-h1/);
79
- closeStateDb(db);
80
- } finally {
81
- fs.rmSync(dir, { recursive: true, force: true });
82
- }
83
- });
84
-
85
- test('rerunning steps 4–5 against the same SHA produces an empty chore commit attempt', () => {
86
- const dir = tmpRepo();
87
- try {
88
- const dbPath = path.join(dir, '.ultra', 'state.db');
89
- const init = initStateDb(dbPath);
90
- const db = init.db;
91
- ops.createTask(db, { id: 'task-h2', title: 'idempotent', type: 'feature', priority: 'P1' });
92
- projector.projectAll(db, { rootDir: dir });
93
-
94
- fs.writeFileSync(path.join(dir, 'a.js'), '// a\n');
95
- gitRun(dir, ['add', '-A']);
96
- gitRun(dir, ['commit', '-q', '-m', 'feat: task-h2 — idempotent']);
97
- const sha = gitRun(dir, ['rev-parse', 'HEAD']).stdout;
98
- ops.patchTask(db, 'task-h2', { completion_commit: sha });
99
- projector.projectContext(db, 'task-h2', {}, { rootDir: dir });
100
- gitRun(dir, ['add', '-A']);
101
- gitRun(dir, ['commit', '-q', '-m', 'chore: record task-h2 completion hash']);
102
-
103
- // Idempotency: re-projecting the same db state must produce byte-identical
104
- // context md (generated_at is derived from tasks.updated_at, not now()).
105
- projector.projectContext(db, 'task-h2', {}, { rootDir: dir });
106
- gitRun(dir, ['add', '-A']);
107
- const status = gitRun(dir, ['status', '--porcelain']).stdout;
108
- assert.equal(status, '', 're-projection of unchanged state must leave nothing to commit');
109
-
110
- // A retry chore commit on an empty diff must be refused by git.
111
- const r = gitRun(dir, ['commit', '-q', '-m', 'chore: idempotent retry'], { allowFail: true });
112
- assert.notEqual(r.code, 0, 'a follow-up empty chore commit must fail');
113
- closeStateDb(db);
114
- } finally {
115
- fs.rmSync(dir, { recursive: true, force: true });
116
- }
117
- });
@@ -1,96 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('node:fs');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
- const { Worker, isMainThread, workerData, parentPort } = require('node:worker_threads');
9
-
10
- const { initStateDb, openStateDb, closeStateDb } = require('../lib/state-db.cjs');
11
- const ops = require('../lib/state-ops.cjs');
12
-
13
- const WORKER_COUNT = 20;
14
- const TASKS_PER_WORKER = 50;
15
-
16
- function tmpDb() {
17
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-conc-up-'));
18
- return { dir, file: path.join(dir, 'state.db') };
19
- }
20
-
21
- if (!isMainThread) {
22
- const { dbPath, workerIdx, count } = workerData;
23
- const db = openStateDb(dbPath);
24
- const completed = [];
25
- for (let i = 0; i < count; i++) {
26
- const id = `w${workerIdx}-t${i}`;
27
- ops.updateTaskStatus(db, id, 'in_progress');
28
- ops.updateTaskStatus(db, id, 'completed');
29
- completed.push(id);
30
- }
31
- closeStateDb(db);
32
- parentPort.postMessage({ workerIdx, completed });
33
- } else {
34
- test(`${WORKER_COUNT} workers × ${TASKS_PER_WORKER} updateTaskStatus — no loss, monotonic events`, async () => {
35
- const { dir, file } = tmpDb();
36
- try {
37
- const init = initStateDb(file);
38
- const db = init.db;
39
-
40
- // Pre-seed disjoint tasks so workers never contend on the same row.
41
- for (let w = 0; w < WORKER_COUNT; w++) {
42
- for (let t = 0; t < TASKS_PER_WORKER; t++) {
43
- ops.createTask(db, {
44
- id: `w${w}-t${t}`,
45
- title: `seed w${w}t${t}`,
46
- type: 'feature',
47
- priority: 'P3',
48
- });
49
- }
50
- }
51
- closeStateDb(db);
52
-
53
- const expectedTasks = WORKER_COUNT * TASKS_PER_WORKER;
54
- const results = await Promise.all(
55
- Array.from({ length: WORKER_COUNT }, (_, idx) =>
56
- new Promise((resolve, reject) => {
57
- const w = new Worker(__filename, {
58
- workerData: { dbPath: file, workerIdx: idx, count: TASKS_PER_WORKER },
59
- });
60
- w.on('message', resolve);
61
- w.on('error', reject);
62
- w.on('exit', (code) => {
63
- if (code !== 0) reject(new Error(`worker ${idx} exit ${code}`));
64
- });
65
- }),
66
- ),
67
- );
68
-
69
- const completedClaims = results.flatMap((r) => r.completed);
70
- assert.equal(completedClaims.length, expectedTasks);
71
-
72
- const verify = openStateDb(file);
73
- const completedRows = verify.prepare("SELECT id, status FROM tasks WHERE status = @status").all({ status: 'completed' });
74
- assert.equal(completedRows.length, expectedTasks, 'every task must end in completed');
75
-
76
- // Each task contributes 1 task_created + 1 task_started + 1 task_completed = 3 events.
77
- const totalEvents = verify.prepare('SELECT COUNT(*) AS n FROM events').get().n;
78
- assert.equal(totalEvents, expectedTasks * 3, 'every transition must record an event');
79
-
80
- const ids = verify.prepare('SELECT id FROM events ORDER BY id ASC').all().map((r) => r.id);
81
- assert.equal(new Set(ids).size, ids.length, 'event ids must be unique');
82
- for (let i = 1; i < ids.length; i++) {
83
- assert.ok(ids[i] > ids[i - 1], 'event ids must be strictly monotonic');
84
- }
85
-
86
- const startedCount = verify.prepare("SELECT COUNT(*) AS n FROM events WHERE type = @type").get({ type: 'task_started' }).n;
87
- const completedCount = verify.prepare("SELECT COUNT(*) AS n FROM events WHERE type = @type").get({ type: 'task_completed' }).n;
88
- assert.equal(startedCount, expectedTasks, 'every task got a task_started event');
89
- assert.equal(completedCount, expectedTasks, 'every task got a task_completed event');
90
-
91
- closeStateDb(verify);
92
- } finally {
93
- fs.rmSync(dir, { recursive: true, force: true });
94
- }
95
- });
96
- }
@@ -1,112 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('node:fs');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
- const { Worker, isMainThread, workerData, parentPort } = require('node:worker_threads');
9
-
10
- const { initStateDb, openStateDb, closeStateDb } = require('../lib/state-db.cjs');
11
-
12
- function tmpDb() {
13
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-conc-'));
14
- return { dir, file: path.join(dir, 'state.db') };
15
- }
16
-
17
- function appendEventRaw(db, type, payload) {
18
- const stmt = db.prepare(
19
- `INSERT INTO events (type, runtime, payload_json) VALUES (?, ?, ?)`,
20
- );
21
- return stmt.run(type, 'claude', JSON.stringify(payload)).lastInsertRowid;
22
- }
23
-
24
- function withRetry(fn, attempts = 3, baseMs = 25) {
25
- let lastErr;
26
- for (let i = 0; i < attempts; i++) {
27
- try {
28
- return fn();
29
- } catch (err) {
30
- if (!String(err.code || err.message).includes('SQLITE_BUSY')) throw err;
31
- lastErr = err;
32
- const wait = baseMs * Math.pow(2, i);
33
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
34
- }
35
- }
36
- throw lastErr;
37
- }
38
-
39
- if (!isMainThread) {
40
- const { dbPath, role, count, baseId } = workerData;
41
- const db = openStateDb(dbPath);
42
- const written = [];
43
- for (let i = 0; i < count; i++) {
44
- const id = withRetry(() =>
45
- appendEventRaw(db, 'task_created', { role, baseId, i }),
46
- );
47
- written.push(Number(id));
48
- }
49
- closeStateDb(db);
50
- parentPort.postMessage({ role, written });
51
- } else {
52
- test('three concurrent writers append to events with no loss and monotonic id', async () => {
53
- const { dir, file } = tmpDb();
54
- try {
55
- const init = initStateDb(file);
56
- closeStateDb(init.db);
57
-
58
- const roles = ['mcp', 'cli', 'daemon'];
59
- const COUNT_PER_WORKER = 100;
60
- const expected = roles.length * COUNT_PER_WORKER;
61
-
62
- const results = await Promise.all(
63
- roles.map(
64
- (role, idx) =>
65
- new Promise((resolve, reject) => {
66
- const w = new Worker(__filename, {
67
- workerData: {
68
- dbPath: file,
69
- role,
70
- count: COUNT_PER_WORKER,
71
- baseId: idx * COUNT_PER_WORKER,
72
- },
73
- });
74
- w.on('message', (m) => resolve(m));
75
- w.on('error', reject);
76
- w.on('exit', (code) => {
77
- if (code !== 0) reject(new Error(`worker ${role} exit ${code}`));
78
- });
79
- }),
80
- ),
81
- );
82
-
83
- const verify = openStateDb(file);
84
- const total = verify.prepare('SELECT COUNT(*) AS n FROM events').get().n;
85
- assert.equal(total, expected, 'total event count must match');
86
-
87
- const ids = verify.prepare('SELECT id FROM events ORDER BY id ASC').all().map((r) => r.id);
88
- assert.equal(new Set(ids).size, ids.length, 'event ids must be unique');
89
- for (let i = 1; i < ids.length; i++) {
90
- assert.ok(ids[i] > ids[i - 1], 'event ids must be strictly monotonic');
91
- }
92
- const minId = ids[0];
93
- const maxId = ids[ids.length - 1];
94
- assert.ok(maxId - minId + 1 >= ids.length, 'sequence must not have collisions');
95
-
96
- const perRole = verify
97
- .prepare("SELECT json_extract(payload_json, '$.role') AS role, COUNT(*) AS n FROM events GROUP BY role")
98
- .all();
99
- for (const row of perRole) {
100
- assert.equal(row.n, COUNT_PER_WORKER, `role ${row.role} count`);
101
- }
102
- closeStateDb(verify);
103
-
104
- // Workers should each have observed their own row ids returned cleanly.
105
- const collected = results.flatMap((r) => r.written);
106
- assert.equal(collected.length, expected);
107
- assert.equal(new Set(collected).size, expected, 'no duplicate ids reported by workers');
108
- } finally {
109
- fs.rmSync(dir, { recursive: true, force: true });
110
- }
111
- });
112
- }