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,205 @@
1
+ 'use strict';
2
+
3
+ // Phase 6.2 — Telemetry collectors:
4
+ // • appendTelemetry writes to state.db.telemetry AND .ultra/telemetry/
5
+ // {YYYY-MM-DD}.jsonl (double-write; jsonl keeps full payload the table
6
+ // can't hold).
7
+ // • computeCost(runtime, model, ti, to) returns USD from 2026-04 pricing.
8
+ // • aggregateTelemetryByRuntime / byTask / bySession drive /ultra-status.
9
+
10
+ const { test } = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
17
+ const ops = require('../lib/state-ops.cjs');
18
+ const telemetry = require('../lib/telemetry.cjs');
19
+ const pricing = require('../lib/pricing.cjs');
20
+
21
+ function freshFixture() {
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-tele-'));
23
+ const file = path.join(dir, '.ultra', 'state.db');
24
+ const init = initStateDb(file);
25
+ return { dir, db: init.db };
26
+ }
27
+
28
+ function teardown(dir, db) {
29
+ try { closeStateDb(db); } catch (_) { /* ignore */ }
30
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
31
+ }
32
+
33
+ function seedSession(db, { sid = 's-1', task_id = 't-1', runtime = 'claude' } = {}) {
34
+ ops.createTask(db, { id: task_id, title: 'tele target', type: 'feature', priority: 'P1' });
35
+ ops.createSession(db, {
36
+ sid, task_id, runtime, pid: null,
37
+ worktree_path: `/tmp/${sid}/wt`,
38
+ artifact_dir: `/tmp/${sid}/art`,
39
+ });
40
+ return { sid, task_id, runtime };
41
+ }
42
+
43
+ // ─── pricing ──────────────────────────────────────────────────────────────
44
+
45
+ test('computeCost: claude opus produces non-zero USD for realistic tokens', () => {
46
+ const cost = pricing.computeCost('claude', 'claude-opus-4-7', 1000, 500);
47
+ assert.ok(cost > 0 && cost < 1, `expected reasonable cost, got ${cost}`);
48
+ });
49
+
50
+ test('computeCost: unknown runtime → null', () => {
51
+ assert.equal(pricing.computeCost('madeup', 'model-x', 100, 100), null);
52
+ });
53
+
54
+ test('computeCost: zero tokens → zero', () => {
55
+ assert.equal(pricing.computeCost('claude', 'claude-sonnet-4-6', 0, 0), 0);
56
+ });
57
+
58
+ // ─── appendTelemetry ─────────────────────────────────────────────────────
59
+
60
+ test('appendTelemetry writes to telemetry table', () => {
61
+ const { dir, db } = freshFixture();
62
+ try {
63
+ seedSession(db, { sid: 's-t1' });
64
+ const out = telemetry.appendTelemetry(db, {
65
+ event_type: 'tool_call',
66
+ tool_name: 'task.list',
67
+ session_id: 's-t1',
68
+ rootDir: dir,
69
+ });
70
+ assert.ok(out.id > 0);
71
+ const row = db.prepare('SELECT * FROM telemetry WHERE id = ?').get(out.id);
72
+ assert.equal(row.event_type, 'tool_call');
73
+ assert.equal(row.tool_name, 'task.list');
74
+ assert.equal(row.session_id, 's-t1');
75
+ } finally { teardown(dir, db); }
76
+ });
77
+
78
+ test('appendTelemetry writes a jsonl line under .ultra/telemetry/YYYY-MM-DD.jsonl', () => {
79
+ const { dir, db } = freshFixture();
80
+ try {
81
+ seedSession(db, { sid: 's-t2' });
82
+ telemetry.appendTelemetry(db, {
83
+ event_type: 'tool_call',
84
+ tool_name: 'task.create',
85
+ session_id: 's-t2',
86
+ rootDir: dir,
87
+ payload: { duration_ms: 42 },
88
+ });
89
+ const today = new Date().toISOString().slice(0, 10);
90
+ const jsonl = path.join(dir, '.ultra', 'telemetry', `${today}.jsonl`);
91
+ assert.ok(fs.existsSync(jsonl), `expected jsonl at ${jsonl}`);
92
+ const lines = fs.readFileSync(jsonl, 'utf8').trim().split('\n');
93
+ assert.equal(lines.length, 1);
94
+ const parsed = JSON.parse(lines[0]);
95
+ assert.equal(parsed.tool_name, 'task.create');
96
+ assert.equal(parsed.payload.duration_ms, 42);
97
+ } finally { teardown(dir, db); }
98
+ });
99
+
100
+ test('appendTelemetry auto-computes cost_usd when runtime + tokens supplied', () => {
101
+ const { dir, db } = freshFixture();
102
+ try {
103
+ seedSession(db, { sid: 's-t3' });
104
+ const out = telemetry.appendTelemetry(db, {
105
+ event_type: 'token_usage',
106
+ tool_name: 'session.close',
107
+ session_id: 's-t3',
108
+ runtime: 'claude',
109
+ tokens_input: 2000,
110
+ tokens_output: 500,
111
+ rootDir: dir,
112
+ payload: { model: 'claude-sonnet-4-6' },
113
+ });
114
+ const row = db.prepare('SELECT cost_usd FROM telemetry WHERE id = ?').get(out.id);
115
+ assert.ok(row.cost_usd > 0, 'cost should be computed from tokens');
116
+ } finally { teardown(dir, db); }
117
+ });
118
+
119
+ test('appendTelemetry tolerates null session_id (pre-session CLI calls)', () => {
120
+ const { dir, db } = freshFixture();
121
+ try {
122
+ const out = telemetry.appendTelemetry(db, {
123
+ event_type: 'tool_call',
124
+ tool_name: 'cli.task.list',
125
+ session_id: null,
126
+ rootDir: dir,
127
+ });
128
+ const row = db.prepare('SELECT * FROM telemetry WHERE id = ?').get(out.id);
129
+ assert.equal(row.session_id, null);
130
+ } finally { teardown(dir, db); }
131
+ });
132
+
133
+ test('appendTelemetry event_type constraint: invalid value rejected', () => {
134
+ const { dir, db } = freshFixture();
135
+ try {
136
+ assert.throws(
137
+ () => telemetry.appendTelemetry(db, { event_type: 'bogus', rootDir: dir }),
138
+ /CHECK|constraint|event_type/,
139
+ );
140
+ } finally { teardown(dir, db); }
141
+ });
142
+
143
+ // ─── aggregation ─────────────────────────────────────────────────────────
144
+
145
+ test('aggregateTelemetryByRuntime: groups calls + sums tokens/cost', () => {
146
+ const { dir, db } = freshFixture();
147
+ try {
148
+ seedSession(db, { sid: 's-a1', task_id: 't-a1', runtime: 'claude' });
149
+ seedSession(db, { sid: 's-a2', task_id: 't-a2', runtime: 'codex' });
150
+ telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'x', session_id: 's-a1', runtime: 'claude', tokens_input: 1000, tokens_output: 200, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
151
+ telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'y', session_id: 's-a1', runtime: 'claude', tokens_input: 500, tokens_output: 100, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
152
+ telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'z', session_id: 's-a2', runtime: 'codex', tokens_input: 800, tokens_output: 300, rootDir: dir, payload: { model: 'gpt-5.4' } });
153
+
154
+ const summary = ops.aggregateTelemetryByRuntime(db);
155
+ const byRuntime = Object.fromEntries(summary.map((r) => [r.runtime, r]));
156
+ assert.equal(byRuntime.claude.calls, 2);
157
+ assert.equal(byRuntime.codex.calls, 1);
158
+ assert.equal(byRuntime.claude.tokens_in, 1500);
159
+ assert.equal(byRuntime.codex.tokens_in, 800);
160
+ assert.ok(byRuntime.claude.cost_usd > 0);
161
+ } finally { teardown(dir, db); }
162
+ });
163
+
164
+ test('aggregateTelemetryByTask: top N tasks by cost', () => {
165
+ const { dir, db } = freshFixture();
166
+ try {
167
+ seedSession(db, { sid: 's-b1', task_id: 't-cheap', runtime: 'claude' });
168
+ seedSession(db, { sid: 's-b2', task_id: 't-expensive', runtime: 'claude' });
169
+ telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'x', session_id: 's-b1', runtime: 'claude', tokens_input: 100, tokens_output: 50, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
170
+ telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'y', session_id: 's-b2', runtime: 'claude', tokens_input: 10000, tokens_output: 5000, rootDir: dir, payload: { model: 'claude-opus-4-7' } });
171
+
172
+ const top = ops.aggregateTelemetryByTask(db, { limit: 1 });
173
+ assert.equal(top.length, 1);
174
+ assert.equal(top[0].task_id, 't-expensive');
175
+ assert.ok(top[0].cost_usd > 0);
176
+ } finally { teardown(dir, db); }
177
+ });
178
+
179
+ test('aggregateTelemetryBySession: per-session roll-up', () => {
180
+ const { dir, db } = freshFixture();
181
+ try {
182
+ seedSession(db, { sid: 's-c1', task_id: 't-c1', runtime: 'claude' });
183
+ telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'task.list', session_id: 's-c1', rootDir: dir });
184
+ telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'task.get', session_id: 's-c1', rootDir: dir });
185
+ telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'session.close', session_id: 's-c1', runtime: 'claude', tokens_input: 500, tokens_output: 100, rootDir: dir, payload: { model: 'claude-haiku-4-5' } });
186
+
187
+ const summary = ops.aggregateTelemetryBySession(db, 's-c1');
188
+ assert.equal(summary.tool_calls, 3);
189
+ assert.equal(summary.tokens_in, 500);
190
+ assert.equal(summary.tokens_out, 100);
191
+ assert.ok(summary.cost_usd > 0);
192
+ } finally { teardown(dir, db); }
193
+ });
194
+
195
+ test('aggregate filter: since cutoff excludes older rows', () => {
196
+ const { dir, db } = freshFixture();
197
+ try {
198
+ seedSession(db, { sid: 's-d1', task_id: 't-d1', runtime: 'claude' });
199
+ // Insert one row; then a cutoff in the future filters it out.
200
+ telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'x', session_id: 's-d1', rootDir: dir });
201
+ const futureCutoff = new Date(Date.now() + 60 * 1000).toISOString();
202
+ const summary = ops.aggregateTelemetryByRuntime(db, { since: futureCutoff });
203
+ assert.equal(summary.length, 0);
204
+ } finally { teardown(dir, db); }
205
+ });
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // Phase 8B.4 — Auto-merge session worktree back onto baseBranch.
4
+ //
5
+ // Called by session-runner.closeSession when autoMerge=true. Three outcomes:
6
+ // 1. session HEAD === base HEAD → { merged:false, reason:'no_changes' }
7
+ // 2. git merge --no-ff clean → { merged:true } + merged_back event
8
+ // 3. conflict → { merged:false, reason:'conflict',
9
+ // conflict_paths[] } + merge_conflict event
10
+ // (caller keeps the worktree so a human can resolve)
11
+ //
12
+ // Note: git worktree add --detach shares the .git dir, so the session commit
13
+ // is already reachable from the repo root. No fetch / push is needed.
14
+
15
+ const { execFileSync } = require('node:child_process');
16
+
17
+ const ops = require('../mcp-server/lib/state-ops.cjs');
18
+
19
+ function runGit(args, opts = {}) {
20
+ return execFileSync('git', args, {
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ encoding: 'utf8',
23
+ ...opts,
24
+ }).trim();
25
+ }
26
+
27
+ function listConflictPaths(repoRoot) {
28
+ try {
29
+ const out = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
30
+ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
31
+ });
32
+ return out.split('\n').filter(Boolean);
33
+ } catch (err) {
34
+ process.stderr.write(`auto-merge: listConflictPaths failed: ${err.message}\n`);
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function autoMerge({ repoRoot, worktreePath, baseBranch = 'main', sid, task_id = null, db = null }) {
40
+ if (!repoRoot) throw new Error('autoMerge: repoRoot required');
41
+ if (!worktreePath) throw new Error('autoMerge: worktreePath required');
42
+
43
+ let sessionSha;
44
+ let baseSha;
45
+ try {
46
+ sessionSha = runGit(['rev-parse', 'HEAD'], { cwd: worktreePath });
47
+ baseSha = runGit(['rev-parse', baseBranch], { cwd: repoRoot });
48
+ } catch (err) {
49
+ // Base branch missing or worktree malformed — surface but don't throw.
50
+ process.stderr.write(`auto-merge: rev-parse failed: ${err.message}\n`);
51
+ return { merged: false, reason: 'rev_parse_failed', session_sha: null, base_sha: null };
52
+ }
53
+
54
+ if (sessionSha === baseSha) {
55
+ return { merged: false, reason: 'no_changes', session_sha: sessionSha, base_sha: baseSha };
56
+ }
57
+
58
+ // Make sure repoRoot is checked out on baseBranch before we merge into it.
59
+ try { runGit(['checkout', baseBranch], { cwd: repoRoot }); }
60
+ catch (err) {
61
+ process.stderr.write(`auto-merge: checkout ${baseBranch} failed: ${err.message}\n`);
62
+ return { merged: false, reason: 'checkout_failed', session_sha: sessionSha, base_sha: baseSha };
63
+ }
64
+
65
+ try {
66
+ runGit(
67
+ ['merge', '--no-ff', '-m', `ubp: merge session ${sid}`, sessionSha],
68
+ { cwd: repoRoot },
69
+ );
70
+ if (db) {
71
+ ops.appendEvent(db, {
72
+ type: 'merged_back',
73
+ task_id,
74
+ session_id: sid,
75
+ payload: { session_sha: sessionSha, base_sha: baseSha, base_branch: baseBranch },
76
+ });
77
+ }
78
+ return { merged: true, session_sha: sessionSha, base_sha: baseSha };
79
+ } catch (err) {
80
+ // Collect conflict info BEFORE aborting — abort resets the index.
81
+ const conflictPaths = listConflictPaths(repoRoot);
82
+ try { runGit(['merge', '--abort'], { cwd: repoRoot }); }
83
+ catch (abortErr) {
84
+ process.stderr.write(`auto-merge: merge --abort failed: ${abortErr.message}\n`);
85
+ }
86
+ if (db) {
87
+ ops.appendEvent(db, {
88
+ type: 'merge_conflict',
89
+ task_id,
90
+ session_id: sid,
91
+ payload: {
92
+ session_sha: sessionSha,
93
+ base_sha: baseSha,
94
+ base_branch: baseBranch,
95
+ conflict_paths: conflictPaths,
96
+ },
97
+ });
98
+ }
99
+ return {
100
+ merged: false,
101
+ reason: 'conflict',
102
+ session_sha: sessionSha,
103
+ base_sha: baseSha,
104
+ conflict_paths: conflictPaths,
105
+ };
106
+ }
107
+ }
108
+
109
+ module.exports = { autoMerge };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ // Phase 6.4 — code-review-graph live watcher.
4
+ //
5
+ // fs-watch the project's source tree, debounce bursts of editor saves, and
6
+ // hand batches off to an onBatch callback. Callers wire the callback to
7
+ // whatever keeps the graph fresh (e.g. the external code-review-graph MCP
8
+ // server's build_or_update_graph_tool). Keeping the glue in callers means
9
+ // this module has no MCP-client dependency and is trivial to unit-test.
10
+ //
11
+ // PLAN D24: real-time feedback beats startup rebuilds; agents learn about
12
+ // impact radius immediately after save, not after the next phase boot.
13
+
14
+ const chokidar = require('chokidar');
15
+
16
+ const DEFAULT_INCLUDE_EXT = new Set([
17
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
18
+ '.py', '.go', '.rs', '.java', '.rb', '.php',
19
+ '.c', '.cc', '.cpp', '.h', '.hpp',
20
+ '.swift', '.kt', '.scala',
21
+ ]);
22
+
23
+ const DEFAULT_EXCLUDE_GLOBS = Object.freeze([
24
+ '**/node_modules/**',
25
+ '**/.git/**',
26
+ '**/.ultra/**',
27
+ '**/.code-review-graph/**',
28
+ '**/dist/**',
29
+ '**/build/**',
30
+ '**/coverage/**',
31
+ '**/.next/**',
32
+ ]);
33
+
34
+ function hasCodeExt(p, includeExt) {
35
+ const idx = p.lastIndexOf('.');
36
+ if (idx < 0) return false;
37
+ return includeExt.has(p.slice(idx).toLowerCase());
38
+ }
39
+
40
+ function startWatcher({
41
+ repoRoot,
42
+ debounceMs = 500,
43
+ includeExt = DEFAULT_INCLUDE_EXT,
44
+ excludeGlobs = DEFAULT_EXCLUDE_GLOBS,
45
+ largeBatchThreshold = 50,
46
+ onBatch,
47
+ } = {}) {
48
+ if (!repoRoot) throw new Error('startWatcher: repoRoot required');
49
+ if (typeof onBatch !== 'function') throw new Error('startWatcher: onBatch required');
50
+
51
+ let stopped = false;
52
+ let ready = false;
53
+ let timer = null;
54
+ // kind → latest kind; path → {kind, ts} so rapid edit+add collapses cleanly.
55
+ const pending = new Map();
56
+
57
+ const w = chokidar.watch(repoRoot, {
58
+ ignored: excludeGlobs,
59
+ ignoreInitial: true,
60
+ persistent: true,
61
+ awaitWriteFinish: { stabilityThreshold: 20, pollInterval: 10 },
62
+ });
63
+
64
+ const readyPromise = new Promise((resolve) => {
65
+ w.once('ready', () => { ready = true; resolve(); });
66
+ });
67
+
68
+ function record(kind, p) {
69
+ // Drop events fired during chokidar's initial scan — ignoreInitial=true
70
+ // mostly handles this but awaitWriteFinish can still surface add events
71
+ // from files that existed when the watcher started.
72
+ if (stopped || !ready) return;
73
+ if (!hasCodeExt(p, includeExt)) return;
74
+ pending.set(p, { kind, ts: Date.now() });
75
+ schedule();
76
+ }
77
+
78
+ function schedule() {
79
+ if (timer) return;
80
+ timer = setTimeout(flush, debounceMs);
81
+ }
82
+
83
+ function flush() {
84
+ timer = null;
85
+ if (pending.size === 0) return;
86
+ const changes = Array.from(pending.entries()).map(([p, meta]) => ({ path: p, kind: meta.kind, ts: meta.ts }));
87
+ pending.clear();
88
+ const batch = {
89
+ changes,
90
+ large_batch: changes.length >= largeBatchThreshold,
91
+ ts: new Date().toISOString(),
92
+ };
93
+ try { onBatch(batch); }
94
+ catch (err) { process.stderr.write(`code-graph-watcher onBatch error: ${err.message}\n`); }
95
+ }
96
+
97
+ w.on('add', (p) => record('add', p));
98
+ w.on('change', (p) => record('change', p));
99
+ w.on('unlink', (p) => record('unlink', p));
100
+ w.on('error', (err) => process.stderr.write(`watcher error: ${err.message}\n`));
101
+
102
+ return {
103
+ stop() {
104
+ if (stopped) return;
105
+ stopped = true;
106
+ if (timer) clearTimeout(timer);
107
+ timer = null;
108
+ w.close().catch(() => { /* ignore close errors */ });
109
+ },
110
+ ready() { return readyPromise; },
111
+ get running() { return !stopped; },
112
+ get pendingCount() { return pending.size; },
113
+ flushNow: flush,
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ startWatcher,
119
+ DEFAULT_INCLUDE_EXT,
120
+ DEFAULT_EXCLUDE_GLOBS,
121
+ };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ // Phase 5.4 — Orchestrator daemon.
4
+ //
5
+ // Polls state.db for pending tasks, runs admission + routing, and spawns
6
+ // session-runner children. This is *not* Phase 8B dispatch rules — just the
7
+ // simplest "pick a runtime, spawn one session per pending task" loop. The
8
+ // daemon never picks a task while admission says blocked_by_breaker; the
9
+ // circuit breaker (5.2) is the only gate above routing.
10
+ //
11
+ // opt-in lives in bin/orchestrator.js which reads settings.json before
12
+ // calling runDaemon — the runtime layer itself has no opt-in concept.
13
+
14
+ const ops = require('../mcp-server/lib/state-ops.cjs');
15
+ const runner = require('./session-runner.cjs');
16
+ const { evaluate, DEFAULT_RULES, ROUTE_PREFERENCES } = require('./dispatch-rules.cjs');
17
+
18
+ // Phase 8B.1 — routeTask is now a thin wrapper over evaluate() so Phase 5.4
19
+ // callers see identical behavior while the parallel orchestrator (8B.2) can
20
+ // feed richer ctx (wave, deps_ready) to the same rule engine.
21
+ function routeTask(task, availableRuntimes) {
22
+ const d = evaluate({
23
+ task: task || {},
24
+ deps_ready: true,
25
+ available_runtimes: Array.isArray(availableRuntimes) ? availableRuntimes : [],
26
+ breaker_state: 'ok',
27
+ wave: null,
28
+ }, DEFAULT_RULES);
29
+ return d.action === 'spawn_agent' ? d.runtime : null;
30
+ }
31
+
32
+ function runDaemon({
33
+ db,
34
+ repoRoot,
35
+ runtimes,
36
+ pollMs = 1000,
37
+ command = null,
38
+ commandArgs = [],
39
+ onError = null,
40
+ branchScoped = false,
41
+ } = {}) {
42
+ if (!db) throw new Error('runDaemon: db required');
43
+ if (!repoRoot) throw new Error('runDaemon: repoRoot required');
44
+ if (!Array.isArray(runtimes) || runtimes.length === 0) {
45
+ throw new Error('runDaemon: runtimes array required');
46
+ }
47
+
48
+ let stopped = false;
49
+ const children = [];
50
+
51
+ function tick() {
52
+ if (stopped) return;
53
+ let pending;
54
+ try {
55
+ const filter = { status: 'pending' };
56
+ if (branchScoped) {
57
+ const tag = ops.deriveBranchTag(repoRoot);
58
+ if (tag) filter.tag = tag;
59
+ }
60
+ pending = ops.listTasks(db, filter);
61
+ } catch (err) {
62
+ if (onError) onError(err); else throw err;
63
+ return;
64
+ }
65
+ for (const task of pending) {
66
+ if (stopped) return;
67
+ // admissionCheck catches both live-session conflicts and tripped breakers.
68
+ let verdict;
69
+ try { verdict = ops.admissionCheck(db, task.id); }
70
+ catch (err) { if (onError) onError(err); continue; }
71
+ if (!verdict.can_spawn) continue;
72
+
73
+ const runtime = routeTask(task, runtimes);
74
+ if (!runtime) continue;
75
+
76
+ try {
77
+ const handle = runner.spawnSession({
78
+ db, repoRoot,
79
+ task_id: task.id,
80
+ runtime,
81
+ command,
82
+ args: commandArgs,
83
+ });
84
+ children.push(handle);
85
+ } catch (err) {
86
+ if (onError) onError(err);
87
+ // Don't throw — one bad task shouldn't stop the loop.
88
+ }
89
+ }
90
+ }
91
+
92
+ const timer = setInterval(tick, pollMs);
93
+ if (typeof timer.unref === 'function') timer.unref();
94
+ // Fire one tick immediately so tests don't need to wait pollMs.
95
+ setImmediate(tick);
96
+
97
+ return {
98
+ stop() {
99
+ if (stopped) return;
100
+ stopped = true;
101
+ clearInterval(timer);
102
+ },
103
+ get running() { return !stopped; },
104
+ get children() { return children.slice(); },
105
+ };
106
+ }
107
+
108
+ module.exports = {
109
+ runDaemon,
110
+ routeTask,
111
+ ROUTE_PREFERENCES,
112
+ };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // Phase 8B.1 — Declarative dispatch rules (GSD-2 pattern).
4
+ //
5
+ // evaluate(ctx, rules) walks a priority-sorted rule array and returns the
6
+ // first matching { rule_id, action, runtime }. Actions:
7
+ // • spawn_agent — orchestrator should spawn a session with `runtime`
8
+ // • defer — leave task pending, revisit next tick (deps/wave wait)
9
+ // • block — terminal: don't spawn (breaker, no runtimes, etc.)
10
+ //
11
+ // Phase 5.4 ROUTE_PREFERENCES lives here now. daemon.cjs's `routeTask` is a
12
+ // thin wrapper around evaluate() so Phase 5.4 tests stay green while the
13
+ // parallel orchestrator (8B.2) gets a richer decision surface: wave state,
14
+ // deps readiness, and custom rule injection.
15
+
16
+ const ROUTE_PREFERENCES = Object.freeze({
17
+ haiku: ['opencode', 'gemini', 'claude', 'codex'],
18
+ sonnet: ['claude', 'codex', 'opencode', 'gemini'],
19
+ opus: ['claude', 'codex'],
20
+ });
21
+
22
+ function resolveByPreference(ctx) {
23
+ const hint = ctx.task && ctx.task.complexity_hint;
24
+ const pref = hint ? ROUTE_PREFERENCES[hint] : null;
25
+ if (pref) {
26
+ for (const r of pref) if (ctx.available_runtimes.includes(r)) return r;
27
+ }
28
+ return ctx.available_runtimes[0];
29
+ }
30
+
31
+ const DEFAULT_RULES = Object.freeze([
32
+ {
33
+ id: 'breaker-blocked',
34
+ priority: 100,
35
+ when: (ctx) => ctx.breaker_state === 'tripped',
36
+ action: 'block',
37
+ },
38
+ {
39
+ id: 'deps-not-ready',
40
+ priority: 90,
41
+ when: (ctx) => ctx.deps_ready === false,
42
+ action: 'defer',
43
+ },
44
+ {
45
+ id: 'no-runtimes',
46
+ priority: 80,
47
+ when: (ctx) => !Array.isArray(ctx.available_runtimes) || ctx.available_runtimes.length === 0,
48
+ action: 'block',
49
+ },
50
+ {
51
+ id: 'wave-conflict',
52
+ priority: 70,
53
+ when: (ctx) => !!(ctx.wave && ctx.wave.parallel === false && (ctx.wave.running_count || 0) > 0),
54
+ action: 'defer',
55
+ },
56
+ {
57
+ id: 'by-preference',
58
+ priority: 10,
59
+ when: (ctx) => {
60
+ const hint = ctx.task && ctx.task.complexity_hint;
61
+ return !!(hint && ROUTE_PREFERENCES[hint]);
62
+ },
63
+ action: 'spawn_agent',
64
+ resolve: resolveByPreference,
65
+ },
66
+ {
67
+ id: 'fallback-first-available',
68
+ priority: 0,
69
+ when: () => true,
70
+ action: 'spawn_agent',
71
+ resolve: (ctx) => ctx.available_runtimes[0],
72
+ },
73
+ ]);
74
+
75
+ function evaluate(ctx, rules = DEFAULT_RULES) {
76
+ const sorted = [...rules].sort((a, b) => (b.priority || 0) - (a.priority || 0));
77
+ for (const rule of sorted) {
78
+ if (rule.when(ctx)) {
79
+ let runtime = null;
80
+ if (rule.action === 'spawn_agent') {
81
+ runtime = rule.resolve ? rule.resolve(ctx) : (rule.runtime || null);
82
+ }
83
+ return { rule_id: rule.id, action: rule.action, runtime };
84
+ }
85
+ }
86
+ return { rule_id: null, action: 'block', runtime: null };
87
+ }
88
+
89
+ module.exports = {
90
+ evaluate,
91
+ DEFAULT_RULES,
92
+ ROUTE_PREFERENCES,
93
+ };