ultra-builder-pro-cli 0.5.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 (206) hide show
  1. package/.ultra-template/delivery-report.json +9 -0
  2. package/.ultra-template/docs/research/README.md +53 -0
  3. package/.ultra-template/specs/architecture.md +250 -0
  4. package/.ultra-template/specs/discovery.md +248 -0
  5. package/.ultra-template/specs/product.md +223 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +57 -0
  7. package/.ultra-template/tasks/tasks.json +33 -0
  8. package/.ultra-template/test-report.json +8 -0
  9. package/CHANGELOG.md +194 -0
  10. package/LICENSE +21 -0
  11. package/README.md +187 -0
  12. package/adapters/_shared/codex-assets.cjs +1128 -0
  13. package/adapters/_shared/codex-ultra-tools-entry.cjs +11 -0
  14. package/adapters/_shared/file-ops.cjs +115 -0
  15. package/adapters/_shared/frontmatter.cjs +52 -0
  16. package/adapters/_shared/handbook.cjs +144 -0
  17. package/adapters/_shared/md-to-toml.cjs +54 -0
  18. package/adapters/_shared/path-rewrite.cjs +79 -0
  19. package/adapters/_shared/rtk-detect.cjs +86 -0
  20. package/adapters/_shared/runtime-assets.cjs +70 -0
  21. package/adapters/_shared/settings-merge.cjs +76 -0
  22. package/adapters/_shared/tests/file-ops.test.cjs +54 -0
  23. package/adapters/_shared/tests/frontmatter.test.cjs +47 -0
  24. package/adapters/_shared/tests/handbook.test.cjs +86 -0
  25. package/adapters/_shared/tests/md-to-toml.test.cjs +48 -0
  26. package/adapters/_shared/tests/path-rewrite.test.cjs +60 -0
  27. package/adapters/_shared/tests/resolve-target.test.cjs +85 -0
  28. package/adapters/_shared/tests/rtk-detect.test.cjs +116 -0
  29. package/adapters/_shared/tests/runtime-assets.test.cjs +107 -0
  30. package/adapters/_shared/tests/settings-merge.test.cjs +85 -0
  31. package/adapters/_shared/tests/validate.test.cjs +30 -0
  32. package/adapters/_shared/validate.cjs +24 -0
  33. package/adapters/claude.js +182 -0
  34. package/adapters/codex.js +385 -0
  35. package/adapters/gemini.js +204 -0
  36. package/adapters/opencode.js +506 -0
  37. package/adapters/tests/claude.test.cjs +117 -0
  38. package/adapters/tests/codex-hook.test.cjs +90 -0
  39. package/adapters/tests/codex.test.cjs +356 -0
  40. package/adapters/tests/gemini.test.cjs +112 -0
  41. package/adapters/tests/opencode.test.cjs +246 -0
  42. package/agents/code-reviewer.md +102 -0
  43. package/agents/debugger.md +127 -0
  44. package/agents/review-code.md +125 -0
  45. package/agents/review-comments.md +98 -0
  46. package/agents/review-coordinator.md +146 -0
  47. package/agents/review-design.md +133 -0
  48. package/agents/review-errors.md +87 -0
  49. package/agents/review-tests.md +93 -0
  50. package/agents/tdd-runner.md +92 -0
  51. package/bin/handbook.js +54 -0
  52. package/bin/install.js +254 -0
  53. package/bin/orchestrator.js +214 -0
  54. package/commands/learn.md +41 -0
  55. package/commands/ultra-deliver.md +45 -0
  56. package/commands/ultra-dev.md +50 -0
  57. package/commands/ultra-init.md +51 -0
  58. package/commands/ultra-plan.md +49 -0
  59. package/commands/ultra-research.md +59 -0
  60. package/commands/ultra-status.md +46 -0
  61. package/commands/ultra-test.md +48 -0
  62. package/commands/ultra-think.md +45 -0
  63. package/docs/AGENT-CONTEXT.md +143 -0
  64. package/docs/ARCHITECTURE.md +197 -0
  65. package/docs/COMMIT-HASH-BACKFILL.md +101 -0
  66. package/docs/LEGACY-HERMES.md +1175 -0
  67. package/docs/PLAN.zh-CN.md +1683 -0
  68. package/docs/ROADMAP.md +102 -0
  69. package/docs/RUNTIME-COMPAT-MATRIX.md +98 -0
  70. package/docs/STATE-DB-ACCESS-POLICY.md +109 -0
  71. package/docs/USER-HANDBOOK-CONTRACT.md +61 -0
  72. package/hooks/README.md +24 -0
  73. package/hooks/active_task_context.py +44 -0
  74. package/hooks/adapters/codex.py +302 -0
  75. package/hooks/health_check.py +61 -0
  76. package/hooks/pre_stop_check.py +56 -0
  77. package/hooks/subagent_tracker.py +116 -0
  78. package/hooks/workflow_checkpoint.py +56 -0
  79. package/hooks/workflow_context.py +63 -0
  80. package/hooks/workflow_resume.py +50 -0
  81. package/mcp-server/lib/init-project.cjs +162 -0
  82. package/mcp-server/lib/llm-client.cjs +157 -0
  83. package/mcp-server/lib/llm-client.test.cjs +156 -0
  84. package/mcp-server/lib/plan-store.cjs +96 -0
  85. package/mcp-server/lib/plan-store.test.cjs +132 -0
  86. package/mcp-server/lib/prd-parser.cjs +110 -0
  87. package/mcp-server/lib/prd-parser.test.cjs +171 -0
  88. package/mcp-server/lib/pricing.cjs +53 -0
  89. package/mcp-server/lib/projector.cjs +158 -0
  90. package/mcp-server/lib/state-db.cjs +113 -0
  91. package/mcp-server/lib/state-db.test.cjs +116 -0
  92. package/mcp-server/lib/state-ops.cjs +725 -0
  93. package/mcp-server/lib/task-expander.cjs +123 -0
  94. package/mcp-server/lib/task-expander.test.cjs +253 -0
  95. package/mcp-server/lib/telemetry.cjs +72 -0
  96. package/mcp-server/lib/topo.cjs +131 -0
  97. package/mcp-server/lib/topo.test.cjs +158 -0
  98. package/mcp-server/server.cjs +482 -0
  99. package/mcp-server/tests/circuit-breaker.test.cjs +125 -0
  100. package/mcp-server/tests/commit-hash-flow.test.cjs +117 -0
  101. package/mcp-server/tests/concurrency-update.test.cjs +96 -0
  102. package/mcp-server/tests/concurrency.test.cjs +112 -0
  103. package/mcp-server/tests/db-ops.test.cjs +117 -0
  104. package/mcp-server/tests/init-project.test.cjs +141 -0
  105. package/mcp-server/tests/migrate.test.cjs +144 -0
  106. package/mcp-server/tests/projector.test.cjs +130 -0
  107. package/mcp-server/tests/server-contract.test.cjs +750 -0
  108. package/mcp-server/tests/staleness.test.cjs +190 -0
  109. package/mcp-server/tests/state-ops.test.cjs +286 -0
  110. package/mcp-server/tests/tagged-tasks.test.cjs +163 -0
  111. package/mcp-server/tests/telemetry.test.cjs +205 -0
  112. package/orchestrator/auto-merge.cjs +109 -0
  113. package/orchestrator/code-graph-watcher.cjs +121 -0
  114. package/orchestrator/daemon.cjs +112 -0
  115. package/orchestrator/dispatch-rules.cjs +93 -0
  116. package/orchestrator/parallel-orchestrator.cjs +195 -0
  117. package/orchestrator/planner/expand-prompt.cjs +62 -0
  118. package/orchestrator/planner/expand-prompt.test.cjs +52 -0
  119. package/orchestrator/planner/plan-builder.cjs +116 -0
  120. package/orchestrator/planner/plan-builder.test.cjs +165 -0
  121. package/orchestrator/planner/prd-prompt.cjs +44 -0
  122. package/orchestrator/planner/prd-prompt.test.cjs +30 -0
  123. package/orchestrator/recovery.cjs +70 -0
  124. package/orchestrator/session-runner.cjs +224 -0
  125. package/orchestrator/skill-miner.cjs +133 -0
  126. package/orchestrator/tests/auto-merge.test.cjs +216 -0
  127. package/orchestrator/tests/code-graph-watcher.test.cjs +183 -0
  128. package/orchestrator/tests/daemon.test.cjs +260 -0
  129. package/orchestrator/tests/dispatch-rules.test.cjs +122 -0
  130. package/orchestrator/tests/parallel-orchestrator.test.cjs +233 -0
  131. package/orchestrator/tests/phase-8b-gate.test.cjs +183 -0
  132. package/orchestrator/tests/recovery.test.cjs +190 -0
  133. package/orchestrator/tests/session-runner.test.cjs +314 -0
  134. package/orchestrator/tests/skill-miner.test.cjs +147 -0
  135. package/orchestrator/tests/worktree-manager.test.cjs +148 -0
  136. package/orchestrator/worktree-manager.cjs +131 -0
  137. package/output-styles/coding-vibes.md +67 -0
  138. package/output-styles/structural-thinking.md +61 -0
  139. package/package.json +109 -0
  140. package/settings.json +8 -0
  141. package/skills/cc-collab/SKILL.md +41 -0
  142. package/skills/code-review-expert/SKILL.md +118 -0
  143. package/skills/code-review-expert/references/code-quality-checklist.md +112 -0
  144. package/skills/code-review-expert/references/integration-checklist.md +46 -0
  145. package/skills/code-review-expert/references/removal-plan.md +52 -0
  146. package/skills/code-review-expert/references/security-checklist.md +100 -0
  147. package/skills/code-review-expert/references/solid-checklist.md +65 -0
  148. package/skills/codex-collab/SKILL.md +52 -0
  149. package/skills/codex-collab/evals/evals.json +40 -0
  150. package/skills/gemini-collab/SKILL.md +51 -0
  151. package/skills/gemini-collab/evals/evals.json +42 -0
  152. package/skills/integration-rules/SKILL.md +71 -0
  153. package/skills/learn/SKILL.md +117 -0
  154. package/skills/security-rules/SKILL.md +60 -0
  155. package/skills/testing-rules/SKILL.md +56 -0
  156. package/skills/ultra-deliver/SKILL.md +171 -0
  157. package/skills/ultra-dev/SKILL.md +324 -0
  158. package/skills/ultra-init/SKILL.md +148 -0
  159. package/skills/ultra-plan/SKILL.md +314 -0
  160. package/skills/ultra-research/SKILL.md +156 -0
  161. package/skills/ultra-research/steps/step-00-problem-validation.md +154 -0
  162. package/skills/ultra-research/steps/step-01-opportunity-discovery.md +172 -0
  163. package/skills/ultra-research/steps/step-02-market-assessment.md +195 -0
  164. package/skills/ultra-research/steps/step-03-competitive-landscape.md +197 -0
  165. package/skills/ultra-research/steps/step-04-product-strategy.md +196 -0
  166. package/skills/ultra-research/steps/step-05-assumptions-validation.md +234 -0
  167. package/skills/ultra-research/steps/step-10-user-personas.md +169 -0
  168. package/skills/ultra-research/steps/step-11-user-scenarios.md +181 -0
  169. package/skills/ultra-research/steps/step-20-user-stories.md +156 -0
  170. package/skills/ultra-research/steps/step-21-features-scope.md +157 -0
  171. package/skills/ultra-research/steps/step-22-success-metrics.md +195 -0
  172. package/skills/ultra-research/steps/step-30-architecture-context.md +154 -0
  173. package/skills/ultra-research/steps/step-31-solution-strategy.md +172 -0
  174. package/skills/ultra-research/steps/step-32-building-blocks.md +188 -0
  175. package/skills/ultra-research/steps/step-40-deployment.md +123 -0
  176. package/skills/ultra-research/steps/step-41-quality-risks.md +135 -0
  177. package/skills/ultra-research/steps/step-99-synthesis.md +188 -0
  178. package/skills/ultra-review/SKILL.md +437 -0
  179. package/skills/ultra-review/references/unified-schema.md +127 -0
  180. package/skills/ultra-review/scripts/review_verdict_update.py +99 -0
  181. package/skills/ultra-review/scripts/review_wait.py +120 -0
  182. package/skills/ultra-status/SKILL.md +162 -0
  183. package/skills/ultra-test/SKILL.md +195 -0
  184. package/skills/ultra-think/SKILL.md +109 -0
  185. package/skills/ultra-verify/SKILL.md +111 -0
  186. package/skills/ultra-verify/evals/evals.json +51 -0
  187. package/skills/ultra-verify/references/confidence-system.md +38 -0
  188. package/skills/ultra-verify/references/cross-verify-modes.md +73 -0
  189. package/skills/ultra-verify/scripts/verify_wait.py +131 -0
  190. package/spec/cli-protocol.md +118 -0
  191. package/spec/mcp-tools.yaml +658 -0
  192. package/spec/schemas/command-manifest.schema.json +47 -0
  193. package/spec/schemas/context-file.v4.5.schema.json +54 -0
  194. package/spec/schemas/mcp-tools.schema.json +136 -0
  195. package/spec/schemas/skill-manifest.schema.json +94 -0
  196. package/spec/schemas/state-db.sql +154 -0
  197. package/spec/schemas/tasks.v4.5.schema.json +66 -0
  198. package/ultra-tools/cli.cjs +121 -0
  199. package/ultra-tools/commands/db.cjs +175 -0
  200. package/ultra-tools/commands/legacy-memory.cjs +159 -0
  201. package/ultra-tools/commands/migrate.cjs +354 -0
  202. package/ultra-tools/commands/session.cjs +180 -0
  203. package/ultra-tools/commands/status.cjs +158 -0
  204. package/ultra-tools/commands/task.cjs +95 -0
  205. package/ultra-tools/legacy-memory.test.cjs +80 -0
  206. package/ultra-tools/status-command.test.cjs +132 -0
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ // Phase 5.3 — Task staleness consumer:
4
+ // • markTasksStaleBySpecSections: UPDATE pending tasks whose trace_to
5
+ // matches a changed spec section → stale=1 + task_stale_marked event.
6
+ // • consumeSpecChangedEvents: pulls unprocessed spec_changed events from
7
+ // the event stream and applies the rule above, advancing a cursor.
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
16
+ const ops = require('../lib/state-ops.cjs');
17
+ const projector = require('../lib/projector.cjs');
18
+
19
+ function freshDb() {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-stale-'));
21
+ const file = path.join(dir, 'state.db');
22
+ const init = initStateDb(file);
23
+ return { dir, db: init.db };
24
+ }
25
+
26
+ function teardown(dir, db) {
27
+ try { closeStateDb(db); } catch (_) { /* ignore */ }
28
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
29
+ }
30
+
31
+ function seed(db, id, { trace_to, status = 'pending' } = {}) {
32
+ ops.createTask(db, { id, title: `task ${id}`, type: 'feature', priority: 'P2', trace_to });
33
+ if (status !== 'pending') ops.patchTask(db, id, { status });
34
+ }
35
+
36
+ test('markTasksStaleBySpecSections: marks pending tasks with matching trace_to', () => {
37
+ const { dir, db } = freshDb();
38
+ try {
39
+ seed(db, 't-prod', { trace_to: 'product' });
40
+ seed(db, 't-arch', { trace_to: 'arch' });
41
+ seed(db, 't-exec', { trace_to: 'exec' });
42
+
43
+ const result = ops.markTasksStaleBySpecSections(db, ['product']);
44
+ assert.equal(result.marked_count, 1);
45
+ assert.deepEqual(result.marked_ids, ['t-prod']);
46
+
47
+ assert.equal(ops.readTask(db, 't-prod').stale, true);
48
+ assert.equal(ops.readTask(db, 't-arch').stale, false);
49
+ assert.equal(ops.readTask(db, 't-exec').stale, false);
50
+ } finally { teardown(dir, db); }
51
+ });
52
+
53
+ test('markTasksStaleBySpecSections: multi-section update', () => {
54
+ const { dir, db } = freshDb();
55
+ try {
56
+ seed(db, 't-a', { trace_to: 'product' });
57
+ seed(db, 't-b', { trace_to: 'arch' });
58
+ seed(db, 't-c', { trace_to: 'exec' });
59
+
60
+ const result = ops.markTasksStaleBySpecSections(db, ['product', 'arch']);
61
+ assert.equal(result.marked_count, 2);
62
+ assert.ok(result.marked_ids.includes('t-a'));
63
+ assert.ok(result.marked_ids.includes('t-b'));
64
+ assert.equal(ops.readTask(db, 't-c').stale, false);
65
+ } finally { teardown(dir, db); }
66
+ });
67
+
68
+ test('markTasksStaleBySpecSections: skips non-pending tasks', () => {
69
+ const { dir, db } = freshDb();
70
+ try {
71
+ seed(db, 't-running', { trace_to: 'product' });
72
+ ops.patchTask(db, 't-running', { status: 'in_progress' });
73
+
74
+ seed(db, 't-done', { trace_to: 'product' });
75
+ ops.patchTask(db, 't-done', { status: 'in_progress' });
76
+ ops.patchTask(db, 't-done', { status: 'completed' });
77
+
78
+ seed(db, 't-pending', { trace_to: 'product' });
79
+
80
+ const result = ops.markTasksStaleBySpecSections(db, ['product']);
81
+ assert.equal(result.marked_count, 1);
82
+ assert.deepEqual(result.marked_ids, ['t-pending']);
83
+ } finally { teardown(dir, db); }
84
+ });
85
+
86
+ test('markTasksStaleBySpecSections: emits task_stale_marked event per task', () => {
87
+ const { dir, db } = freshDb();
88
+ try {
89
+ seed(db, 't-e1', { trace_to: 'product' });
90
+ seed(db, 't-e2', { trace_to: 'product' });
91
+
92
+ const { events: before } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
93
+ ops.markTasksStaleBySpecSections(db, ['product']);
94
+ const { events: after } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
95
+
96
+ const marked = after.slice(before.length).filter((e) => e.type === 'task_stale_marked');
97
+ assert.equal(marked.length, 2);
98
+ const taskIds = marked.map((e) => e.task_id).sort();
99
+ assert.deepEqual(taskIds, ['t-e1', 't-e2']);
100
+ } finally { teardown(dir, db); }
101
+ });
102
+
103
+ test('markTasksStaleBySpecSections: idempotent — re-mark already-stale task does not double-emit', () => {
104
+ const { dir, db } = freshDb();
105
+ try {
106
+ seed(db, 't-idem', { trace_to: 'product' });
107
+ ops.markTasksStaleBySpecSections(db, ['product']);
108
+
109
+ const { events: before } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
110
+ ops.markTasksStaleBySpecSections(db, ['product']);
111
+ const { events: after } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
112
+
113
+ const newEvents = after.slice(before.length);
114
+ const marked = newEvents.filter((e) => e.type === 'task_stale_marked');
115
+ assert.equal(marked.length, 0, 'should not re-emit for already-stale task');
116
+ } finally { teardown(dir, db); }
117
+ });
118
+
119
+ test('consumeSpecChangedEvents: processes queued events and advances cursor', () => {
120
+ const { dir, db } = freshDb();
121
+ try {
122
+ seed(db, 't-c1', { trace_to: 'product' });
123
+ seed(db, 't-c2', { trace_to: 'arch' });
124
+
125
+ ops.appendEvent(db, { type: 'spec_changed', payload: { sections: ['product'] } });
126
+ ops.appendEvent(db, { type: 'spec_changed', payload: { sections: ['arch'] } });
127
+
128
+ const result = ops.consumeSpecChangedEvents(db, { since_id: 0 });
129
+ assert.equal(result.processed, 2);
130
+ assert.ok(result.next_since_id > 0);
131
+ assert.equal(ops.readTask(db, 't-c1').stale, true);
132
+ assert.equal(ops.readTask(db, 't-c2').stale, true);
133
+ } finally { teardown(dir, db); }
134
+ });
135
+
136
+ test('consumeSpecChangedEvents: cursor prevents reprocessing', () => {
137
+ const { dir, db } = freshDb();
138
+ try {
139
+ seed(db, 't-cur', { trace_to: 'product' });
140
+ ops.appendEvent(db, { type: 'spec_changed', payload: { sections: ['product'] } });
141
+
142
+ const first = ops.consumeSpecChangedEvents(db, { since_id: 0 });
143
+ assert.equal(first.processed, 1);
144
+
145
+ // second call from the new cursor — no new spec_changed events.
146
+ const second = ops.consumeSpecChangedEvents(db, { since_id: first.next_since_id });
147
+ assert.equal(second.processed, 0);
148
+ assert.equal(second.next_since_id, first.next_since_id);
149
+ } finally { teardown(dir, db); }
150
+ });
151
+
152
+ test('consumeSpecChangedEvents: ignores non-spec_changed events', () => {
153
+ const { dir, db } = freshDb();
154
+ try {
155
+ seed(db, 't-other', { trace_to: 'product' });
156
+ ops.appendEvent(db, { type: 'task_failure', task_id: 't-other', payload: { reason: 'noise' } });
157
+
158
+ const result = ops.consumeSpecChangedEvents(db, { since_id: 0 });
159
+ assert.equal(result.processed, 0);
160
+ assert.equal(ops.readTask(db, 't-other').stale, false);
161
+ } finally { teardown(dir, db); }
162
+ });
163
+
164
+ test('projector: stale task context md carries STALE banner before body', () => {
165
+ const { dir, db } = freshDb();
166
+ const rootDir = dir;
167
+ try {
168
+ seed(db, 't-proj', { trace_to: 'product' });
169
+ ops.markTasksStaleBySpecSections(db, ['product']);
170
+
171
+ const result = projector.projectContext(db, 't-proj', {}, { rootDir });
172
+ const text = fs.readFileSync(result.path, 'utf8');
173
+ // Banner goes after YAML frontmatter end + blank line.
174
+ const frontmatterEnd = text.indexOf('\n---', 3);
175
+ assert.ok(frontmatterEnd > 0, 'frontmatter should exist');
176
+ const body = text.slice(frontmatterEnd + 4);
177
+ assert.match(body, /⚠️\s*STALE/, 'stale banner should render');
178
+ assert.match(body, /spec/i, 'banner should mention spec');
179
+ } finally { teardown(dir, db); }
180
+ });
181
+
182
+ test('projector: non-stale task context md has no STALE banner', () => {
183
+ const { dir, db } = freshDb();
184
+ try {
185
+ seed(db, 't-fresh', { trace_to: 'product' });
186
+ const result = projector.projectContext(db, 't-fresh', {}, { rootDir: dir });
187
+ const text = fs.readFileSync(result.path, 'utf8');
188
+ assert.doesNotMatch(text, /STALE/);
189
+ } finally { teardown(dir, db); }
190
+ });
@@ -0,0 +1,286 @@
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
+ test('createTask inserts a row, defaults status=pending, emits task_created', () => {
24
+ const { dir, db } = freshDb();
25
+ try {
26
+ const out = ops.createTask(db, {
27
+ id: 'task-001', title: 'first task', type: 'feature', priority: 'P1',
28
+ });
29
+ assert.equal(out.id, 'task-001');
30
+ assert.equal(out.status, 'pending');
31
+ const events = db.prepare('SELECT type, task_id FROM events').all();
32
+ assert.equal(events.length, 1);
33
+ assert.equal(events[0].type, 'task_created');
34
+ assert.equal(events[0].task_id, 'task-001');
35
+ closeStateDb(db);
36
+ } finally {
37
+ fs.rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('createTask rejects duplicates with DUPLICATE_TASK_ID', () => {
42
+ const { dir, db } = freshDb();
43
+ try {
44
+ ops.createTask(db, { id: 'dup', title: 'one', type: 'feature', priority: 'P0' });
45
+ assert.throws(
46
+ () => ops.createTask(db, { id: 'dup', title: 'two', type: 'feature', priority: 'P0' }),
47
+ (e) => e.code === 'DUPLICATE_TASK_ID',
48
+ );
49
+ closeStateDb(db);
50
+ } finally {
51
+ fs.rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('updateTaskStatus enforces the legal transition graph', () => {
56
+ const { dir, db } = freshDb();
57
+ try {
58
+ ops.createTask(db, { id: 't', title: 'x', type: 'feature', priority: 'P0' });
59
+
60
+ // pending → in_progress allowed
61
+ const t1 = ops.updateTaskStatus(db, 't', 'in_progress');
62
+ assert.equal(t1.status, 'in_progress');
63
+
64
+ // in_progress → completed allowed
65
+ const t2 = ops.updateTaskStatus(db, 't', 'completed');
66
+ assert.equal(t2.status, 'completed');
67
+
68
+ // completed → pending forbidden
69
+ assert.throws(
70
+ () => ops.updateTaskStatus(db, 't', 'pending'),
71
+ (e) => e.code === 'ILLEGAL_STATUS_TRANSITION',
72
+ );
73
+ closeStateDb(db);
74
+ } finally {
75
+ fs.rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('patchTask updates JSON arrays + flags + status atomically', () => {
80
+ const { dir, db } = freshDb();
81
+ try {
82
+ ops.createTask(db, { id: 'p1', title: 'patch me', type: 'feature', priority: 'P2' });
83
+ const out = ops.patchTask(db, 'p1', {
84
+ files_modified: ['a.ts', 'b.ts'],
85
+ session_id: 'ses_1',
86
+ stale: true,
87
+ status: 'in_progress',
88
+ });
89
+ assert.deepEqual(out.files_modified, ['a.ts', 'b.ts']);
90
+ assert.equal(out.session_id, 'ses_1');
91
+ assert.equal(out.stale, true);
92
+ assert.equal(out.status, 'in_progress');
93
+
94
+ const types = db.prepare('SELECT type FROM events ORDER BY id').all().map((r) => r.type);
95
+ assert.deepEqual(types, ['task_created', 'task_started']);
96
+ closeStateDb(db);
97
+ } finally {
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test('patchTask rejects unknown fields', () => {
103
+ const { dir, db } = freshDb();
104
+ try {
105
+ ops.createTask(db, { id: 'r', title: 'r', type: 'feature', priority: 'P1' });
106
+ assert.throws(
107
+ () => ops.patchTask(db, 'r', { mystery: 1 }),
108
+ (e) => e.code === 'VALIDATION_ERROR',
109
+ );
110
+ closeStateDb(db);
111
+ } finally {
112
+ fs.rmSync(dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ test('appendEvent + subscribeEventsSince produces monotonic cursor with no gaps', () => {
117
+ const { dir, db } = freshDb();
118
+ try {
119
+ for (let i = 0; i < 10; i++) {
120
+ ops.appendEvent(db, {
121
+ type: 'task_created', task_id: `t-${i}`, payload: { i },
122
+ });
123
+ }
124
+ const first = ops.subscribeEventsSince(db, { since_id: 0, limit: 4 });
125
+ assert.equal(first.events.length, 4);
126
+ assert.equal(first.events[0].id, 1);
127
+ assert.equal(first.next_since_id, 4);
128
+
129
+ const second = ops.subscribeEventsSince(db, { since_id: first.next_since_id, limit: 100 });
130
+ assert.equal(second.events.length, 6);
131
+ assert.equal(second.events[0].id, 5);
132
+ assert.equal(second.next_since_id, 10);
133
+
134
+ const empty = ops.subscribeEventsSince(db, { since_id: second.next_since_id });
135
+ assert.equal(empty.events.length, 0);
136
+ assert.equal(empty.next_since_id, second.next_since_id);
137
+ closeStateDb(db);
138
+ } finally {
139
+ fs.rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test('subscribeEventsSince filters by type', () => {
144
+ const { dir, db } = freshDb();
145
+ try {
146
+ ops.appendEvent(db, { type: 'task_created', task_id: 'a' });
147
+ ops.appendEvent(db, { type: 'task_started', task_id: 'a' });
148
+ ops.appendEvent(db, { type: 'task_completed', task_id: 'a' });
149
+ const r = ops.subscribeEventsSince(db, { since_id: 0, types: ['task_completed'] });
150
+ assert.equal(r.events.length, 1);
151
+ assert.equal(r.events[0].type, 'task_completed');
152
+ closeStateDb(db);
153
+ } finally {
154
+ fs.rmSync(dir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test('createSession requires existing task and emits session_spawned', () => {
159
+ const { dir, db } = freshDb();
160
+ try {
161
+ assert.throws(
162
+ () => ops.createSession(db, {
163
+ sid: 's', task_id: 'missing', runtime: 'claude',
164
+ worktree_path: '/tmp/wt', artifact_dir: '/tmp/art',
165
+ }),
166
+ (e) => e.code === 'TASK_NOT_FOUND',
167
+ );
168
+
169
+ ops.createTask(db, { id: 'have', title: 'h', type: 'feature', priority: 'P1' });
170
+ const ses = ops.createSession(db, {
171
+ sid: 's1', task_id: 'have', runtime: 'claude',
172
+ worktree_path: '/tmp/wt', artifact_dir: '/tmp/art', lease_seconds: 60,
173
+ });
174
+ assert.equal(ses.sid, 's1');
175
+ assert.equal(ses.status, 'running');
176
+ const evt = db.prepare(`SELECT type FROM events WHERE session_id = 's1'`).get();
177
+ assert.equal(evt.type, 'session_spawned');
178
+ closeStateDb(db);
179
+ } finally {
180
+ fs.rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('updateSession status=completed emits session_closed', () => {
185
+ const { dir, db } = freshDb();
186
+ try {
187
+ ops.createTask(db, { id: 'k', title: 'k', type: 'feature', priority: 'P1' });
188
+ ops.createSession(db, {
189
+ sid: 'sx', task_id: 'k', runtime: 'codex',
190
+ worktree_path: '/tmp/wt', artifact_dir: '/tmp/art',
191
+ });
192
+ const out = ops.updateSession(db, 'sx', { status: 'completed' });
193
+ assert.equal(out.status, 'completed');
194
+ const types = db.prepare(`SELECT type FROM events WHERE session_id = 'sx' ORDER BY id`).all().map((r) => r.type);
195
+ assert.deepEqual(types, ['session_spawned', 'session_closed']);
196
+ closeStateDb(db);
197
+ } finally {
198
+ fs.rmSync(dir, { recursive: true, force: true });
199
+ }
200
+ });
201
+
202
+ test('listActiveSessions returns only running rows for a task', () => {
203
+ const { dir, db } = freshDb();
204
+ try {
205
+ ops.createTask(db, { id: 'm', title: 'm', type: 'feature', priority: 'P1' });
206
+ ops.createSession(db, { sid: 'a', task_id: 'm', runtime: 'claude', worktree_path: '/tmp/a', artifact_dir: '/tmp/a' });
207
+ ops.createSession(db, { sid: 'b', task_id: 'm', runtime: 'codex', worktree_path: '/tmp/b', artifact_dir: '/tmp/b' });
208
+ ops.updateSession(db, 'a', { status: 'completed' });
209
+ const active = ops.listActiveSessions(db, { task_id: 'm' });
210
+ assert.equal(active.length, 1);
211
+ assert.equal(active[0].sid, 'b');
212
+ closeStateDb(db);
213
+ } finally {
214
+ fs.rmSync(dir, { recursive: true, force: true });
215
+ }
216
+ });
217
+
218
+ test('deleteTask refuses when a session is bound unless force=true', () => {
219
+ const { dir, db } = freshDb();
220
+ try {
221
+ ops.createTask(db, { id: 'dl', title: 'd', type: 'feature', priority: 'P1' });
222
+ ops.patchTask(db, 'dl', { session_id: 'ses_x' });
223
+ assert.throws(
224
+ () => ops.deleteTask(db, 'dl'),
225
+ (e) => e.code === 'SESSION_ACTIVE',
226
+ );
227
+ const r = ops.deleteTask(db, 'dl', { force: true });
228
+ assert.equal(r.ok, true);
229
+ assert.equal(ops.readTask(db, 'dl'), null);
230
+ closeStateDb(db);
231
+ } finally {
232
+ fs.rmSync(dir, { recursive: true, force: true });
233
+ }
234
+ });
235
+
236
+ test('listTasks filters by status / tag', () => {
237
+ const { dir, db } = freshDb();
238
+ try {
239
+ ops.createTask(db, { id: 'a', title: 'a', type: 'feature', priority: 'P0', tag: 'main' });
240
+ ops.createTask(db, { id: 'b', title: 'b', type: 'bugfix', priority: 'P1', tag: 'feat-x' });
241
+ ops.createTask(db, { id: 'c', title: 'c', type: 'feature', priority: 'P2', tag: 'main' });
242
+ ops.updateTaskStatus(db, 'a', 'in_progress');
243
+
244
+ const inProg = ops.listTasks(db, { status: 'in_progress' });
245
+ assert.equal(inProg.length, 1);
246
+ assert.equal(inProg[0].id, 'a');
247
+
248
+ const onMain = ops.listTasks(db, { tag: 'main' });
249
+ assert.equal(onMain.length, 2);
250
+ closeStateDb(db);
251
+ } finally {
252
+ fs.rmSync(dir, { recursive: true, force: true });
253
+ }
254
+ });
255
+
256
+ test('tx() rolls back on error so events are not partially written', () => {
257
+ const { dir, db } = freshDb();
258
+ try {
259
+ ops.createTask(db, { id: 'r', title: 'r', type: 'feature', priority: 'P1' });
260
+ const before = db.prepare('SELECT COUNT(*) AS n FROM events').get().n;
261
+ assert.throws(() => ops.tx(db, () => {
262
+ db.prepare(`INSERT INTO events (type) VALUES ('task_created')`).run();
263
+ throw new Error('boom');
264
+ }));
265
+ const after = db.prepare('SELECT COUNT(*) AS n FROM events').get().n;
266
+ assert.equal(after, before, 'rollback must remove the partial event row');
267
+ closeStateDb(db);
268
+ } finally {
269
+ fs.rmSync(dir, { recursive: true, force: true });
270
+ }
271
+ });
272
+
273
+ test('listStaleTasks finds running sessions whose heartbeat is older than grace', () => {
274
+ const { dir, db } = freshDb();
275
+ try {
276
+ ops.createTask(db, { id: 'k', title: 'k', type: 'feature', priority: 'P0' });
277
+ ops.createSession(db, { sid: 'old', task_id: 'k', runtime: 'claude', worktree_path: '/tmp/o', artifact_dir: '/tmp/o' });
278
+ db.prepare(`UPDATE sessions SET heartbeat_at = '2000-01-01T00:00:00.000Z' WHERE sid = 'old'`).run();
279
+ const stale = ops.listStaleTasks(db, 60);
280
+ assert.equal(stale.length, 1);
281
+ assert.equal(stale[0].id, 'k');
282
+ closeStateDb(db);
283
+ } finally {
284
+ fs.rmSync(dir, { recursive: true, force: true });
285
+ }
286
+ });
@@ -0,0 +1,163 @@
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
+ });