ultra-builder-pro-cli 0.13.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 (172) 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 +86 -1
  8. package/README.md +40 -26
  9. package/adapters/_shared/codex-assets.cjs +29 -165
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/_shared/runtime-assets.cjs +1 -0
  12. package/adapters/claude.js +3 -2
  13. package/adapters/codex.js +1 -0
  14. package/adapters/kimi.js +3 -2
  15. package/adapters/opencode.js +33 -122
  16. package/agents/review-coordinator.md +9 -6
  17. package/docs/AGENT-CONTEXT.md +21 -11
  18. package/docs/ARCHITECTURE.md +41 -13
  19. package/docs/PLAN.zh-CN.md +3 -2
  20. package/docs/ROADMAP.md +9 -5
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +6 -6
  22. package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
  23. package/docs/WORKFLOW-LIFECYCLE.md +266 -0
  24. package/hooks/README.md +6 -1
  25. package/hooks/active_task_context.py +22 -50
  26. package/hooks/context_spine.py +61 -332
  27. package/hooks/health_check.py +29 -18
  28. package/hooks/pre_stop_check.py +28 -33
  29. package/hooks/subagent_tracker.py +54 -85
  30. package/hooks/workflow_checkpoint.py +39 -47
  31. package/hooks/workflow_context.py +4 -9
  32. package/hooks/workflow_resume.py +48 -128
  33. package/mcp-server/breadcrumb.cjs +22 -0
  34. package/mcp-server/hook-event.cjs +34 -0
  35. package/mcp-server/lib/archive-journal.cjs +17 -5
  36. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  37. package/mcp-server/lib/change-workflow.cjs +550 -85
  38. package/mcp-server/lib/context-spine.cjs +242 -24
  39. package/mcp-server/lib/doctor.cjs +33 -3
  40. package/mcp-server/lib/init-project.cjs +210 -16
  41. package/mcp-server/lib/plan-store.cjs +4 -1
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/pricing.cjs +7 -10
  44. package/mcp-server/lib/project-breadcrumb.cjs +145 -0
  45. package/mcp-server/lib/projector.cjs +57 -3
  46. package/mcp-server/lib/spec-learning.cjs +79 -2
  47. package/mcp-server/lib/state-db.cjs +145 -8
  48. package/mcp-server/lib/state-ops.cjs +160 -9
  49. package/mcp-server/lib/task-expander.cjs +9 -38
  50. package/mcp-server/lib/workflow-state.cjs +2292 -0
  51. package/mcp-server/server.cjs +182 -50
  52. package/orchestrator/daemon.cjs +1 -2
  53. package/orchestrator/dispatch-rules.cjs +5 -31
  54. package/orchestrator/planner/plan-builder.cjs +21 -12
  55. package/package.json +14 -6
  56. package/skills/cc-collab/SKILL.md +2 -2
  57. package/skills/codex-collab/SKILL.md +2 -3
  58. package/skills/ultra-change/SKILL.md +53 -68
  59. package/skills/ultra-change/references/change-contract.md +36 -0
  60. package/skills/ultra-deliver/SKILL.md +69 -79
  61. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  62. package/skills/ultra-dev/SKILL.md +54 -59
  63. package/skills/ultra-doctor/SKILL.md +40 -71
  64. package/skills/ultra-init/SKILL.md +96 -103
  65. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  66. package/skills/ultra-plan/SKILL.md +79 -75
  67. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  68. package/skills/ultra-research/SKILL.md +87 -60
  69. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  70. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  71. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  72. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  73. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  74. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  75. package/skills/ultra-research/references/10-user-personas.md +29 -0
  76. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  77. package/skills/ultra-research/references/20-user-stories.md +30 -0
  78. package/skills/ultra-research/references/21-features-scope.md +29 -0
  79. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  80. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  81. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  82. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  83. package/skills/ultra-research/references/40-deployment.md +29 -0
  84. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  85. package/skills/ultra-research/references/99-synthesis.md +35 -0
  86. package/skills/ultra-review/SKILL.md +57 -73
  87. package/skills/ultra-review/references/review-modes.md +27 -0
  88. package/skills/ultra-review/references/unified-schema.md +25 -0
  89. package/skills/ultra-review/scripts/review_wait.py +40 -3
  90. package/skills/ultra-status/SKILL.md +41 -45
  91. package/skills/ultra-test/SKILL.md +48 -55
  92. package/skills/ultra-verify/SKILL.md +22 -31
  93. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  94. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  95. package/spec/cli-protocol.md +7 -2
  96. package/spec/mcp-tools.yaml +422 -73
  97. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  98. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  99. package/spec/schemas/mcp-tools.schema.json +1 -1
  100. package/spec/schemas/state-db.sql +82 -1
  101. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  102. package/spec/schemas/test-report.v1.schema.json +194 -0
  103. package/ultra-tools/cli.cjs +1 -1
  104. package/ultra-tools/commands/migrate.cjs +3 -3
  105. package/ultra-tools/commands/status.cjs +213 -12
  106. package/.ultra-template/delivery-report.json +0 -9
  107. package/.ultra-template/test-report.json +0 -8
  108. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  109. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  110. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  111. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  112. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  113. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  114. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -131
  115. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  116. package/adapters/_shared/tests/validate.test.cjs +0 -30
  117. package/adapters/tests/claude.test.cjs +0 -137
  118. package/adapters/tests/codex-hook.test.cjs +0 -115
  119. package/adapters/tests/codex.test.cjs +0 -423
  120. package/adapters/tests/kimi-hook.test.cjs +0 -122
  121. package/adapters/tests/kimi.test.cjs +0 -259
  122. package/adapters/tests/opencode.test.cjs +0 -371
  123. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  124. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  125. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  126. package/mcp-server/lib/context-spine.test.cjs +0 -93
  127. package/mcp-server/lib/doctor.test.cjs +0 -148
  128. package/mcp-server/lib/llm-client.cjs +0 -157
  129. package/mcp-server/lib/llm-client.test.cjs +0 -156
  130. package/mcp-server/lib/plan-store.test.cjs +0 -132
  131. package/mcp-server/lib/prd-parser.test.cjs +0 -171
  132. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  133. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  134. package/mcp-server/lib/state-db.test.cjs +0 -314
  135. package/mcp-server/lib/task-expander.test.cjs +0 -324
  136. package/mcp-server/lib/topo.test.cjs +0 -158
  137. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  138. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  139. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  140. package/mcp-server/tests/concurrency.test.cjs +0 -112
  141. package/mcp-server/tests/db-ops.test.cjs +0 -121
  142. package/mcp-server/tests/init-project.test.cjs +0 -393
  143. package/mcp-server/tests/migrate.test.cjs +0 -232
  144. package/mcp-server/tests/projector.test.cjs +0 -162
  145. package/mcp-server/tests/server-contract.test.cjs +0 -1471
  146. package/mcp-server/tests/staleness.test.cjs +0 -190
  147. package/mcp-server/tests/state-db.test.cjs +0 -106
  148. package/mcp-server/tests/state-ops.test.cjs +0 -449
  149. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  150. package/mcp-server/tests/telemetry.test.cjs +0 -219
  151. package/orchestrator/planner/expand-prompt.cjs +0 -62
  152. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  153. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  154. package/orchestrator/planner/prd-prompt.cjs +0 -44
  155. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
  156. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  157. package/orchestrator/tests/daemon.test.cjs +0 -311
  158. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  159. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  160. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  161. package/orchestrator/tests/recovery.test.cjs +0 -190
  162. package/orchestrator/tests/session-runner.test.cjs +0 -314
  163. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  164. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  165. package/skills/ultra-research/references/architecture.md +0 -38
  166. package/skills/ultra-research/references/problem-product.md +0 -39
  167. package/skills/ultra-research/references/quality-delivery.md +0 -34
  168. package/skills/ultra-research/references/synthesis.md +0 -38
  169. package/skills/ultra-research/references/users-scope.md +0 -40
  170. package/ultra-tools/legacy-memory.test.cjs +0 -80
  171. package/ultra-tools/status-command.test.cjs +0 -181
  172. package/ultra-tools/system-command.test.cjs +0 -309
@@ -1,449 +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
-
9
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
10
- const ops = require('../lib/state-ops.cjs');
11
-
12
- function tmpDb() {
13
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-ops-'));
14
- return { dir, file: path.join(dir, 'state.db') };
15
- }
16
-
17
- function freshDb() {
18
- const t = tmpDb();
19
- const init = initStateDb(t.file);
20
- return { ...t, db: init.db };
21
- }
22
-
23
- function seedChange(db, id, status = 'active') {
24
- db.prepare(
25
- `INSERT INTO changes (id, title, kind, status, intent, artifact_root)
26
- VALUES (?, ?, 'quick', ?, ?, ?)`,
27
- ).run(id, `Change ${id}`, status, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
28
- }
29
-
30
- test('createTask inserts a row, preserves estimated_days, defaults status=pending, emits task_created', () => {
31
- const { dir, db } = freshDb();
32
- try {
33
- const out = ops.createTask(db, {
34
- id: 'task-001', title: 'first task', type: 'feature', priority: 'P1', estimated_days: 2.5,
35
- });
36
- assert.equal(out.id, 'task-001');
37
- assert.equal(out.status, 'pending');
38
- assert.equal(out.estimated_days, 2.5);
39
- const events = db.prepare('SELECT type, task_id FROM events').all();
40
- assert.equal(events.length, 1);
41
- assert.equal(events[0].type, 'task_created');
42
- assert.equal(events[0].task_id, 'task-001');
43
- closeStateDb(db);
44
- } finally {
45
- fs.rmSync(dir, { recursive: true, force: true });
46
- }
47
- });
48
-
49
- test('patchTask updates estimated_days', () => {
50
- const { dir, db } = freshDb();
51
- try {
52
- ops.createTask(db, { id: 'estimate', title: 'estimate task', type: 'feature', priority: 'P2' });
53
- const out = ops.patchTask(db, 'estimate', { estimated_days: 1.5 });
54
- assert.equal(out.estimated_days, 1.5);
55
- closeStateDb(db);
56
- } finally {
57
- fs.rmSync(dir, { recursive: true, force: true });
58
- }
59
- });
60
-
61
- test('createTask rejects duplicates with DUPLICATE_TASK_ID', () => {
62
- const { dir, db } = freshDb();
63
- try {
64
- ops.createTask(db, { id: 'dup', title: 'one', type: 'feature', priority: 'P0' });
65
- assert.throws(
66
- () => ops.createTask(db, { id: 'dup', title: 'two', type: 'feature', priority: 'P0' }),
67
- (e) => e.code === 'DUPLICATE_TASK_ID',
68
- );
69
- closeStateDb(db);
70
- } finally {
71
- fs.rmSync(dir, { recursive: true, force: true });
72
- }
73
- });
74
-
75
- test('createTask preserves parent change ownership and rejects terminal change targets', () => {
76
- const { dir, db } = freshDb();
77
- try {
78
- seedChange(db, 'change-a');
79
- seedChange(db, 'change-b');
80
- seedChange(db, 'change-closed', 'archived');
81
- ops.createTask(db, {
82
- id: 'parent-owned', title: 'owned parent', type: 'feature', priority: 'P1',
83
- change_id: 'change-a',
84
- });
85
- const inherited = ops.createTask(db, {
86
- id: 'child-inherited', title: 'inherited child', type: 'feature', priority: 'P1',
87
- parent_id: 'parent-owned',
88
- });
89
- assert.equal(inherited.change_id, 'change-a');
90
- assert.throws(
91
- () => ops.createTask(db, {
92
- id: 'child-mismatch', title: 'mismatched child', type: 'feature', priority: 'P1',
93
- parent_id: 'parent-owned', change_id: 'change-b',
94
- }),
95
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
96
- );
97
- assert.throws(
98
- () => ops.createTask(db, {
99
- id: 'child-orphan', title: 'orphan child', type: 'feature', priority: 'P1',
100
- parent_id: 'missing-parent',
101
- }),
102
- (error) => error.code === 'TASK_NOT_FOUND',
103
- );
104
- assert.throws(
105
- () => ops.createTask(db, {
106
- id: 'closed-change-task', title: 'closed change task', type: 'feature', priority: 'P1',
107
- change_id: 'change-closed',
108
- }),
109
- (error) => error.code === 'CHANGE_NOT_MUTABLE',
110
- );
111
- } finally {
112
- closeStateDb(db);
113
- fs.rmSync(dir, { recursive: true, force: true });
114
- }
115
- });
116
-
117
- test('patchTask keeps established ownership immutable and rejects terminal-change writes', () => {
118
- const { dir, db } = freshDb();
119
- try {
120
- seedChange(db, 'change-a');
121
- seedChange(db, 'change-b');
122
- seedChange(db, 'change-ready', 'ready');
123
- ops.createTask(db, {
124
- id: 'patch-parent', title: 'patch parent', type: 'feature', priority: 'P1',
125
- change_id: 'change-a',
126
- });
127
- ops.createTask(db, {
128
- id: 'patch-child', title: 'patch child', type: 'feature', priority: 'P1',
129
- parent_id: 'patch-parent',
130
- });
131
- assert.throws(
132
- () => ops.patchTask(db, 'patch-child', { change_id: null }),
133
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
134
- );
135
- assert.throws(
136
- () => ops.patchTask(db, 'patch-child', { change_id: 'change-b' }),
137
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
138
- );
139
- assert.throws(
140
- () => ops.patchTask(db, 'patch-parent', { change_id: 'change-b' }),
141
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
142
- );
143
- const root = ops.createTask(db, {
144
- id: 'patch-root', title: 'owned root', type: 'feature', priority: 'P1',
145
- change_id: 'change-a',
146
- });
147
- assert.equal(root.change_id, 'change-a');
148
- assert.throws(
149
- () => ops.patchTask(db, 'patch-root', { change_id: null }),
150
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
151
- );
152
- assert.throws(
153
- () => ops.patchTask(db, 'patch-root', { change_id: 'change-b' }),
154
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
155
- );
156
- const standalone = ops.createTask(db, {
157
- id: 'patch-standalone', title: 'patch standalone', type: 'feature', priority: 'P1',
158
- });
159
- assert.equal(standalone.change_id, null);
160
- assert.equal(ops.patchTask(db, 'patch-standalone', { change_id: 'change-b' }).change_id, 'change-b');
161
- assert.throws(
162
- () => ops.patchTask(db, 'patch-root', { change_id: 'change-ready' }),
163
- (error) => error.code === 'TASK_CHANGE_OWNERSHIP_MISMATCH',
164
- );
165
- db.prepare("UPDATE changes SET status = 'archived' WHERE id = 'change-a'").run();
166
- assert.throws(
167
- () => ops.patchTask(db, 'patch-root', { priority: 'P0' }),
168
- (error) => error.code === 'CHANGE_NOT_MUTABLE',
169
- );
170
- } finally {
171
- closeStateDb(db);
172
- fs.rmSync(dir, { recursive: true, force: true });
173
- }
174
- });
175
-
176
- test('updateTaskStatus enforces the legal transition graph', () => {
177
- const { dir, db } = freshDb();
178
- try {
179
- ops.createTask(db, { id: 't', title: 'x', type: 'feature', priority: 'P0' });
180
-
181
- // pending → in_progress allowed
182
- const t1 = ops.updateTaskStatus(db, 't', 'in_progress');
183
- assert.equal(t1.status, 'in_progress');
184
-
185
- // in_progress → completed allowed
186
- const t2 = ops.updateTaskStatus(db, 't', 'completed');
187
- assert.equal(t2.status, 'completed');
188
-
189
- // completed → pending forbidden
190
- assert.throws(
191
- () => ops.updateTaskStatus(db, 't', 'pending'),
192
- (e) => e.code === 'ILLEGAL_STATUS_TRANSITION',
193
- );
194
- closeStateDb(db);
195
- } finally {
196
- fs.rmSync(dir, { recursive: true, force: true });
197
- }
198
- });
199
-
200
- test('patchTask updates JSON arrays + flags + status atomically', () => {
201
- const { dir, db } = freshDb();
202
- try {
203
- ops.createTask(db, { id: 'p1', title: 'patch me', type: 'feature', priority: 'P2' });
204
- const out = ops.patchTask(db, 'p1', {
205
- files_modified: ['a.ts', 'b.ts'],
206
- session_id: 'ses_1',
207
- stale: true,
208
- status: 'in_progress',
209
- });
210
- assert.deepEqual(out.files_modified, ['a.ts', 'b.ts']);
211
- assert.equal(out.session_id, 'ses_1');
212
- assert.equal(out.stale, true);
213
- assert.equal(out.status, 'in_progress');
214
-
215
- const types = db.prepare('SELECT type FROM events ORDER BY id').all().map((r) => r.type);
216
- assert.deepEqual(types, ['task_created', 'task_started']);
217
- closeStateDb(db);
218
- } finally {
219
- fs.rmSync(dir, { recursive: true, force: true });
220
- }
221
- });
222
-
223
- test('patchTask status event follows the newly assigned continuous change', () => {
224
- const { dir, db } = freshDb();
225
- try {
226
- db.prepare(
227
- `INSERT INTO changes (id, title, kind, intent, artifact_root)
228
- VALUES ('chg-link', 'Link task', 'quick', 'Link task to change.', '.ultra/changes/active/chg-link')`,
229
- ).run();
230
- ops.createTask(db, { id: 'link-task', title: 'link me', type: 'feature', priority: 'P1' });
231
- ops.patchTask(db, 'link-task', { change_id: 'chg-link', status: 'in_progress' });
232
- const event = db.prepare(
233
- "SELECT change_id FROM events WHERE task_id = 'link-task' AND type = 'task_started'",
234
- ).get();
235
- assert.equal(event.change_id, 'chg-link');
236
- closeStateDb(db);
237
- } finally {
238
- fs.rmSync(dir, { recursive: true, force: true });
239
- }
240
- });
241
-
242
- test('patchTask rejects unknown fields', () => {
243
- const { dir, db } = freshDb();
244
- try {
245
- ops.createTask(db, { id: 'r', title: 'r', type: 'feature', priority: 'P1' });
246
- assert.throws(
247
- () => ops.patchTask(db, 'r', { mystery: 1 }),
248
- (e) => e.code === 'VALIDATION_ERROR',
249
- );
250
- closeStateDb(db);
251
- } finally {
252
- fs.rmSync(dir, { recursive: true, force: true });
253
- }
254
- });
255
-
256
- test('appendEvent + subscribeEventsSince produces monotonic cursor with no gaps', () => {
257
- const { dir, db } = freshDb();
258
- try {
259
- for (let i = 0; i < 10; i++) {
260
- ops.appendEvent(db, {
261
- type: 'task_created', task_id: `t-${i}`, payload: { i },
262
- });
263
- }
264
- const first = ops.subscribeEventsSince(db, { since_id: 0, limit: 4 });
265
- assert.equal(first.events.length, 4);
266
- assert.equal(first.events[0].id, 1);
267
- assert.equal(first.next_since_id, 4);
268
-
269
- const second = ops.subscribeEventsSince(db, { since_id: first.next_since_id, limit: 100 });
270
- assert.equal(second.events.length, 6);
271
- assert.equal(second.events[0].id, 5);
272
- assert.equal(second.next_since_id, 10);
273
-
274
- const empty = ops.subscribeEventsSince(db, { since_id: second.next_since_id });
275
- assert.equal(empty.events.length, 0);
276
- assert.equal(empty.next_since_id, second.next_since_id);
277
- closeStateDb(db);
278
- } finally {
279
- fs.rmSync(dir, { recursive: true, force: true });
280
- }
281
- });
282
-
283
- test('subscribeEventsSince filters by type', () => {
284
- const { dir, db } = freshDb();
285
- try {
286
- ops.appendEvent(db, { type: 'task_created', task_id: 'a' });
287
- ops.appendEvent(db, { type: 'task_started', task_id: 'a' });
288
- ops.appendEvent(db, { type: 'task_completed', task_id: 'a' });
289
- const r = ops.subscribeEventsSince(db, { since_id: 0, types: ['task_completed'] });
290
- assert.equal(r.events.length, 1);
291
- assert.equal(r.events[0].type, 'task_completed');
292
- closeStateDb(db);
293
- } finally {
294
- fs.rmSync(dir, { recursive: true, force: true });
295
- }
296
- });
297
-
298
- test('createSession requires existing task and emits session_spawned', () => {
299
- const { dir, db } = freshDb();
300
- try {
301
- assert.throws(
302
- () => ops.createSession(db, {
303
- sid: 's', task_id: 'missing', runtime: 'claude',
304
- worktree_path: '/tmp/wt', artifact_dir: '/tmp/art',
305
- }),
306
- (e) => e.code === 'TASK_NOT_FOUND',
307
- );
308
-
309
- ops.createTask(db, { id: 'have', title: 'h', type: 'feature', priority: 'P1' });
310
- const ses = ops.createSession(db, {
311
- sid: 's1', task_id: 'have', runtime: 'claude',
312
- worktree_path: '/tmp/wt', artifact_dir: '/tmp/art', lease_seconds: 60,
313
- });
314
- assert.equal(ses.sid, 's1');
315
- assert.equal(ses.status, 'running');
316
- const evt = db.prepare(`SELECT type FROM events WHERE session_id = 's1'`).get();
317
- assert.equal(evt.type, 'session_spawned');
318
- closeStateDb(db);
319
- } finally {
320
- fs.rmSync(dir, { recursive: true, force: true });
321
- }
322
- });
323
-
324
- test('runtime-bearing mutations reject unsupported runtimes before touching state', () => {
325
- const { dir, db } = freshDb();
326
- const retiredRuntime = ['gem', 'ini'].join('');
327
- try {
328
- ops.createTask(db, { id: 'runtime-guard', title: 'guard', type: 'feature', priority: 'P1' });
329
- assert.throws(
330
- () => ops.createSession(db, {
331
- sid: 'unsupported', task_id: 'runtime-guard', runtime: retiredRuntime,
332
- worktree_path: '/tmp/wt', artifact_dir: '/tmp/art',
333
- }),
334
- (error) => error.code === 'VALIDATION_ERROR' && /unsupported runtime/.test(error.message),
335
- );
336
- assert.throws(
337
- () => ops.appendEvent(db, { type: 'task_started', task_id: 'runtime-guard', runtime: retiredRuntime }),
338
- (error) => error.code === 'VALIDATION_ERROR' && /unsupported runtime/.test(error.message),
339
- );
340
- assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n, 0);
341
- closeStateDb(db);
342
- } finally {
343
- fs.rmSync(dir, { recursive: true, force: true });
344
- }
345
- });
346
-
347
- test('updateSession status=completed emits session_closed', () => {
348
- const { dir, db } = freshDb();
349
- try {
350
- ops.createTask(db, { id: 'k', title: 'k', type: 'feature', priority: 'P1' });
351
- ops.createSession(db, {
352
- sid: 'sx', task_id: 'k', runtime: 'codex',
353
- worktree_path: '/tmp/wt', artifact_dir: '/tmp/art',
354
- });
355
- const out = ops.updateSession(db, 'sx', { status: 'completed' });
356
- assert.equal(out.status, 'completed');
357
- const types = db.prepare(`SELECT type FROM events WHERE session_id = 'sx' ORDER BY id`).all().map((r) => r.type);
358
- assert.deepEqual(types, ['session_spawned', 'session_closed']);
359
- closeStateDb(db);
360
- } finally {
361
- fs.rmSync(dir, { recursive: true, force: true });
362
- }
363
- });
364
-
365
- test('listActiveSessions returns only running rows for a task', () => {
366
- const { dir, db } = freshDb();
367
- try {
368
- ops.createTask(db, { id: 'm', title: 'm', type: 'feature', priority: 'P1' });
369
- ops.createSession(db, { sid: 'a', task_id: 'm', runtime: 'claude', worktree_path: '/tmp/a', artifact_dir: '/tmp/a' });
370
- ops.createSession(db, { sid: 'b', task_id: 'm', runtime: 'codex', worktree_path: '/tmp/b', artifact_dir: '/tmp/b' });
371
- ops.updateSession(db, 'a', { status: 'completed' });
372
- const active = ops.listActiveSessions(db, { task_id: 'm' });
373
- assert.equal(active.length, 1);
374
- assert.equal(active[0].sid, 'b');
375
- closeStateDb(db);
376
- } finally {
377
- fs.rmSync(dir, { recursive: true, force: true });
378
- }
379
- });
380
-
381
- test('deleteTask refuses when a session is bound unless force=true', () => {
382
- const { dir, db } = freshDb();
383
- try {
384
- ops.createTask(db, { id: 'dl', title: 'd', type: 'feature', priority: 'P1' });
385
- ops.patchTask(db, 'dl', { session_id: 'ses_x' });
386
- assert.throws(
387
- () => ops.deleteTask(db, 'dl'),
388
- (e) => e.code === 'SESSION_ACTIVE',
389
- );
390
- const r = ops.deleteTask(db, 'dl', { force: true });
391
- assert.equal(r.ok, true);
392
- assert.equal(ops.readTask(db, 'dl'), null);
393
- closeStateDb(db);
394
- } finally {
395
- fs.rmSync(dir, { recursive: true, force: true });
396
- }
397
- });
398
-
399
- test('listTasks filters by status / tag', () => {
400
- const { dir, db } = freshDb();
401
- try {
402
- ops.createTask(db, { id: 'a', title: 'a', type: 'feature', priority: 'P0', tag: 'main' });
403
- ops.createTask(db, { id: 'b', title: 'b', type: 'bugfix', priority: 'P1', tag: 'feat-x' });
404
- ops.createTask(db, { id: 'c', title: 'c', type: 'feature', priority: 'P2', tag: 'main' });
405
- ops.updateTaskStatus(db, 'a', 'in_progress');
406
-
407
- const inProg = ops.listTasks(db, { status: 'in_progress' });
408
- assert.equal(inProg.length, 1);
409
- assert.equal(inProg[0].id, 'a');
410
-
411
- const onMain = ops.listTasks(db, { tag: 'main' });
412
- assert.equal(onMain.length, 2);
413
- closeStateDb(db);
414
- } finally {
415
- fs.rmSync(dir, { recursive: true, force: true });
416
- }
417
- });
418
-
419
- test('tx() rolls back on error so events are not partially written', () => {
420
- const { dir, db } = freshDb();
421
- try {
422
- ops.createTask(db, { id: 'r', title: 'r', type: 'feature', priority: 'P1' });
423
- const before = db.prepare('SELECT COUNT(*) AS n FROM events').get().n;
424
- assert.throws(() => ops.tx(db, () => {
425
- db.prepare(`INSERT INTO events (type) VALUES ('task_created')`).run();
426
- throw new Error('boom');
427
- }));
428
- const after = db.prepare('SELECT COUNT(*) AS n FROM events').get().n;
429
- assert.equal(after, before, 'rollback must remove the partial event row');
430
- closeStateDb(db);
431
- } finally {
432
- fs.rmSync(dir, { recursive: true, force: true });
433
- }
434
- });
435
-
436
- test('listStaleTasks finds running sessions whose heartbeat is older than grace', () => {
437
- const { dir, db } = freshDb();
438
- try {
439
- ops.createTask(db, { id: 'k', title: 'k', type: 'feature', priority: 'P0' });
440
- ops.createSession(db, { sid: 'old', task_id: 'k', runtime: 'claude', worktree_path: '/tmp/o', artifact_dir: '/tmp/o' });
441
- db.prepare(`UPDATE sessions SET heartbeat_at = '2000-01-01T00:00:00.000Z' WHERE sid = 'old'`).run();
442
- const stale = ops.listStaleTasks(db, 60);
443
- assert.equal(stale.length, 1);
444
- assert.equal(stale[0].id, 'k');
445
- closeStateDb(db);
446
- } finally {
447
- fs.rmSync(dir, { recursive: true, force: true });
448
- }
449
- });
@@ -1,163 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 7.2 — Tagged task lists:
4
- // • deriveBranchTag(cwd) — git HEAD → sanitized tag
5
- // • switchTaskTag(db, task_id, new_tag) — update tag + emit event
6
- // • createTask auto-derives tag from cwd when not provided
7
-
8
- const { test } = require('node:test');
9
- const assert = require('node:assert/strict');
10
- const fs = require('node:fs');
11
- const os = require('node:os');
12
- const path = require('node:path');
13
- const { execFileSync } = require('node:child_process');
14
-
15
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
16
- const ops = require('../lib/state-ops.cjs');
17
-
18
- function mkRepo(branch = 'main') {
19
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-tag-repo-'));
20
- execFileSync('git', ['init', '-q', '-b', branch], { cwd: dir });
21
- execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir });
22
- execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
23
- fs.writeFileSync(path.join(dir, 'README.md'), '# r\n');
24
- execFileSync('git', ['add', '-A'], { cwd: dir });
25
- execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
26
- return dir;
27
- }
28
-
29
- function checkout(repoRoot, branch) {
30
- execFileSync('git', ['checkout', '-q', '-b', branch], { cwd: repoRoot });
31
- }
32
-
33
- function freshDb(repoRoot) {
34
- const dbPath = path.join(repoRoot, '.ultra', 'state.db');
35
- const init = initStateDb(dbPath);
36
- return init.db;
37
- }
38
-
39
- function teardown(dir, db) {
40
- try { closeStateDb(db); } catch (_) { /* ignore */ }
41
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
42
- }
43
-
44
- test('deriveBranchTag: main branch → "main"', () => {
45
- const repo = mkRepo('main');
46
- try { assert.equal(ops.deriveBranchTag(repo), 'main'); }
47
- finally { fs.rmSync(repo, { recursive: true, force: true }); }
48
- });
49
-
50
- test('deriveBranchTag: feat/auth → "feat-auth" (slash sanitized)', () => {
51
- const repo = mkRepo('main');
52
- try {
53
- checkout(repo, 'feat/auth');
54
- assert.equal(ops.deriveBranchTag(repo), 'feat-auth');
55
- } finally { fs.rmSync(repo, { recursive: true, force: true }); }
56
- });
57
-
58
- test('deriveBranchTag: detached HEAD → null', () => {
59
- const repo = mkRepo('main');
60
- try {
61
- const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
62
- execFileSync('git', ['checkout', '-q', sha], { cwd: repo });
63
- assert.equal(ops.deriveBranchTag(repo), null);
64
- } finally { fs.rmSync(repo, { recursive: true, force: true }); }
65
- });
66
-
67
- test('deriveBranchTag: non-git directory → null', () => {
68
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-nongit-'));
69
- try { assert.equal(ops.deriveBranchTag(dir), null); }
70
- finally { fs.rmSync(dir, { recursive: true, force: true }); }
71
- });
72
-
73
- test('deriveBranchTag: sanitizes special chars (/ → -, @ dropped)', () => {
74
- const repo = mkRepo('main');
75
- try {
76
- checkout(repo, 'release/v1.2@staging');
77
- // Sanitized: slashes → hyphens, @ stripped
78
- const tag = ops.deriveBranchTag(repo);
79
- assert.match(tag, /^release-v1\.2.staging$/);
80
- assert.ok(!tag.includes('/'));
81
- } finally { fs.rmSync(repo, { recursive: true, force: true }); }
82
- });
83
-
84
- test('createTask: auto-derives tag from cwd branch when not supplied', () => {
85
- const repo = mkRepo('main');
86
- const db = freshDb(repo);
87
- try {
88
- checkout(repo, 'feat/billing');
89
- const task = ops.createTask(db, {
90
- id: 't-auto', title: 'autotag', type: 'feature', priority: 'P1', _cwd: repo,
91
- });
92
- assert.equal(task.tag, 'feat-billing');
93
- } finally { teardown(repo, db); }
94
- });
95
-
96
- test('createTask: explicit tag wins over auto-derivation', () => {
97
- const repo = mkRepo('main');
98
- const db = freshDb(repo);
99
- try {
100
- checkout(repo, 'feat/a');
101
- const task = ops.createTask(db, {
102
- id: 't-explicit', title: 'explicit', type: 'feature', priority: 'P1',
103
- tag: 'custom-tag', _cwd: repo,
104
- });
105
- assert.equal(task.tag, 'custom-tag');
106
- } finally { teardown(repo, db); }
107
- });
108
-
109
- test('createTask: non-git cwd → tag remains null', () => {
110
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-task-nongit-'));
111
- const db = freshDb(dir);
112
- try {
113
- const task = ops.createTask(db, {
114
- id: 't-nogit', title: 'n/a', type: 'feature', priority: 'P1', _cwd: dir,
115
- });
116
- assert.equal(task.tag, null);
117
- } finally { teardown(dir, db); }
118
- });
119
-
120
- test('switchTaskTag: updates tag + emits task_tag_changed event', () => {
121
- const repo = mkRepo('main');
122
- const db = freshDb(repo);
123
- try {
124
- ops.createTask(db, { id: 't-sw', title: 'switch me', type: 'feature', priority: 'P1', tag: 'old' });
125
- const { events: before } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
126
- const result = ops.switchTaskTag(db, 't-sw', 'new-tag');
127
- assert.equal(result.tag, 'new-tag');
128
- const task = ops.readTask(db, 't-sw');
129
- assert.equal(task.tag, 'new-tag');
130
-
131
- const { events: after } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
132
- const newEvents = after.slice(before.length);
133
- assert.ok(newEvents.some((e) => e.type === 'task_tag_changed' && e.task_id === 't-sw'));
134
- } finally { teardown(repo, db); }
135
- });
136
-
137
- test('switchTaskTag: missing task → TASK_NOT_FOUND', () => {
138
- const repo = mkRepo('main');
139
- const db = freshDb(repo);
140
- try {
141
- assert.throws(
142
- () => ops.switchTaskTag(db, 'nope', 'x'),
143
- (err) => err instanceof ops.StateOpsError && err.code === 'TASK_NOT_FOUND',
144
- );
145
- } finally { teardown(repo, db); }
146
- });
147
-
148
- test('listTasks: filter by tag returns only matching', () => {
149
- const repo = mkRepo('main');
150
- const db = freshDb(repo);
151
- try {
152
- ops.createTask(db, { id: 'ta', title: 'a', type: 'feature', priority: 'P1', tag: 'auth' });
153
- ops.createTask(db, { id: 'tb', title: 'b', type: 'feature', priority: 'P1', tag: 'billing' });
154
- ops.createTask(db, { id: 'tc', title: 'c', type: 'feature', priority: 'P1', tag: 'auth' });
155
-
156
- const auth = ops.listTasks(db, { tag: 'auth' });
157
- assert.equal(auth.length, 2);
158
- assert.ok(auth.every((t) => t.tag === 'auth'));
159
-
160
- const billing = ops.listTasks(db, { tag: 'billing' });
161
- assert.equal(billing.length, 1);
162
- } finally { teardown(repo, db); }
163
- });