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,70 @@
1
+ 'use strict';
2
+
3
+ // Phase 5.1 — Recovery decision layer.
4
+ //
5
+ // Phase 4.5's reapOrphanSessions marks running sessions whose lease or
6
+ // heartbeat has expired as `orphan`. Orphan is a safe waypoint — we don't yet
7
+ // know whether the child is dead, deadlocked, or just slow.
8
+ //
9
+ // recoverOnBoot upgrades orphans whose pid is demonstrably dead to `crashed`,
10
+ // records a task_failure event, and leaves orphans with live pids alone so
11
+ // they can recover on their own heartbeat.
12
+ //
13
+ // The circuit breaker (Phase 5.2) consumes task_failure events to decide
14
+ // whether the task should be tripped.
15
+
16
+ const ops = require('../mcp-server/lib/state-ops.cjs');
17
+
18
+ function isPidAlive(pid) {
19
+ if (!pid || pid <= 0) return false;
20
+ try {
21
+ // Signal 0 performs error checking without actually sending a signal.
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch (err) {
25
+ // ESRCH = no such process; EPERM = process exists but owned by another user
26
+ // (still counts as alive for our purposes).
27
+ if (err && err.code === 'EPERM') return true;
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function listOrphanSessions(db) {
33
+ return db.prepare("SELECT * FROM sessions WHERE status = 'orphan'").all();
34
+ }
35
+
36
+ function recoverOnBoot(db, { graceSeconds = 300 } = {}) {
37
+ if (!db) throw new Error('recoverOnBoot: db handle required');
38
+
39
+ // Step 1 — promote stale running sessions to orphan (Phase 4.5 logic).
40
+ ops.reapOrphanSessions(db, { graceSeconds });
41
+
42
+ // Step 2 — for every orphan, decide crashed vs. keep-watching.
43
+ const orphans = listOrphanSessions(db);
44
+ const recovered = [];
45
+ for (const s of orphans) {
46
+ const alive = isPidAlive(s.pid);
47
+ if (alive) continue;
48
+
49
+ ops.updateSession(db, s.sid, { status: 'crashed' });
50
+ ops.recordTaskFailure(db, s.task_id, {
51
+ reason: 'session_crashed_on_boot',
52
+ session_id: s.sid,
53
+ });
54
+ recovered.push({
55
+ sid: s.sid,
56
+ task_id: s.task_id,
57
+ pid: s.pid,
58
+ pid_alive: false,
59
+ reason: 'session_crashed_on_boot',
60
+ });
61
+ }
62
+
63
+ return { recovered, count: recovered.length };
64
+ }
65
+
66
+ module.exports = {
67
+ recoverOnBoot,
68
+ // exposed for tests
69
+ _internal: { isPidAlive, listOrphanSessions },
70
+ };
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ // Phase 4.5.1 — Session 标准单元 runner.
4
+ // Session = new process + independent git worktree + state.db sessions-table
5
+ // lease/heartbeat (D20 + D32 — no .ultra/sessions/<sid>/lease.json file).
6
+ //
7
+ // This module is the execution layer. MCP session.* tools record state,
8
+ // orchestrator runs the actual git worktree + child_process work.
9
+ // Callers share a single open state-db connection (WAL supports multi-writer).
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const { spawn, execFileSync } = require('node:child_process');
14
+ const { randomUUID } = require('node:crypto');
15
+
16
+ const ops = require('../mcp-server/lib/state-ops.cjs');
17
+ const skillMiner = require('./skill-miner.cjs');
18
+
19
+ class SessionRunnerError extends Error {
20
+ constructor(code, message, { cause } = {}) {
21
+ super(message);
22
+ this.name = 'SessionRunnerError';
23
+ this.code = code;
24
+ if (cause) this.cause = cause;
25
+ }
26
+ }
27
+
28
+ function gitWorktreeAdd(repoRoot, worktreePath, ref = 'HEAD') {
29
+ fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
30
+ try {
31
+ execFileSync('git', ['worktree', 'add', '--detach', worktreePath, ref], {
32
+ cwd: repoRoot, stdio: 'pipe',
33
+ });
34
+ } catch (err) {
35
+ throw new SessionRunnerError('WORKTREE_FAILED', `git worktree add failed: ${err.stderr ? err.stderr.toString().trim() : err.message}`, { cause: err });
36
+ }
37
+ }
38
+
39
+ function gitWorktreeRemove(repoRoot, worktreePath) {
40
+ try {
41
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
42
+ cwd: repoRoot, stdio: 'pipe',
43
+ });
44
+ } catch (_err) {
45
+ // worktree may already be detached; fall through to fs removal
46
+ }
47
+ if (fs.existsSync(worktreePath)) {
48
+ fs.rmSync(worktreePath, { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ function mintSessionId() {
53
+ return `sess-${randomUUID().slice(0, 8)}`;
54
+ }
55
+
56
+ function spawnSession({
57
+ db,
58
+ repoRoot,
59
+ task_id,
60
+ runtime,
61
+ takeover = false,
62
+ command = null,
63
+ args = [],
64
+ env = {},
65
+ stdio = 'ignore',
66
+ worktreeRef = 'HEAD',
67
+ lease_seconds = 1800,
68
+ }) {
69
+ if (!db) throw new SessionRunnerError('VALIDATION_ERROR', 'db handle required');
70
+ if (!repoRoot) throw new SessionRunnerError('VALIDATION_ERROR', 'repoRoot required');
71
+ if (!task_id) throw new SessionRunnerError('VALIDATION_ERROR', 'task_id required');
72
+ if (!runtime) throw new SessionRunnerError('VALIDATION_ERROR', 'runtime required');
73
+
74
+ // 1. Admission check
75
+ const verdict = ops.admissionCheck(db, task_id);
76
+ // Circuit breaker trumps takeover — tripped tasks require explicit reset.
77
+ if (verdict.recommended_action === 'blocked_by_breaker') {
78
+ const err = new SessionRunnerError(
79
+ 'CIRCUIT_TRIPPED',
80
+ `task ${task_id} is tripped by circuit breaker; call resetCircuitBreaker before spawning`,
81
+ );
82
+ err.verdict = verdict;
83
+ throw err;
84
+ }
85
+ if (!verdict.can_spawn && !takeover) {
86
+ const err = new SessionRunnerError(
87
+ 'ADMISSION_DENIED',
88
+ `active session exists for task ${task_id} (${verdict.conflict && verdict.conflict.sid}); recommended=${verdict.recommended_action}`,
89
+ );
90
+ err.verdict = verdict;
91
+ throw err;
92
+ }
93
+ if (!verdict.can_spawn && takeover && verdict.conflict) {
94
+ ops.updateSession(db, verdict.conflict.sid, { status: 'crashed' });
95
+ }
96
+
97
+ // 2. Mint sid + paths
98
+ const sid = mintSessionId();
99
+ const worktree_path = path.join(repoRoot, '.ultra', 'worktrees', sid);
100
+ const artifact_dir = path.join(repoRoot, '.ultra', 'sessions', sid);
101
+
102
+ // 3. Git worktree (throws WORKTREE_FAILED on failure)
103
+ gitWorktreeAdd(repoRoot, worktree_path, worktreeRef);
104
+ fs.mkdirSync(artifact_dir, { recursive: true });
105
+
106
+ // 4. Spawn child process (optional — caller may want a record-only session)
107
+ let proc = null;
108
+ if (command) {
109
+ proc = spawn(command, args, {
110
+ cwd: worktree_path,
111
+ env: {
112
+ ...process.env,
113
+ UBP_SESSION_ID: sid,
114
+ UBP_TASK_ID: task_id,
115
+ UBP_RUNTIME: runtime,
116
+ UBP_WORKTREE: worktree_path,
117
+ UBP_ARTIFACT_DIR: artifact_dir,
118
+ ...env,
119
+ },
120
+ stdio,
121
+ detached: false,
122
+ });
123
+ }
124
+
125
+ // 5. Write session record
126
+ let session;
127
+ try {
128
+ session = ops.createSession(db, {
129
+ sid,
130
+ task_id,
131
+ runtime,
132
+ pid: proc ? proc.pid : null,
133
+ worktree_path,
134
+ artifact_dir,
135
+ lease_seconds,
136
+ });
137
+ } catch (err) {
138
+ // state.db insert failed — roll back worktree + child
139
+ if (proc && !proc.killed) { try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ } }
140
+ gitWorktreeRemove(repoRoot, worktree_path);
141
+ throw err;
142
+ }
143
+
144
+ return {
145
+ sid,
146
+ worktree_path,
147
+ artifact_dir,
148
+ lease_expires_at: session.lease_expires_at,
149
+ pid: proc ? proc.pid : null,
150
+ process: proc,
151
+ };
152
+ }
153
+
154
+ function closeSession({
155
+ db,
156
+ repoRoot,
157
+ sid,
158
+ }, {
159
+ status = 'completed',
160
+ remove_worktree = true,
161
+ kill_signal = 'SIGTERM',
162
+ mineSkill = false,
163
+ skillsRoot = null,
164
+ autoMerge = false,
165
+ mergeBaseBranch = 'main',
166
+ } = {}) {
167
+ if (!db || !sid) throw new SessionRunnerError('VALIDATION_ERROR', 'db + sid required');
168
+ const session = ops.readSession(db, sid);
169
+ if (!session) {
170
+ throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
171
+ }
172
+ if (session.pid) {
173
+ try { process.kill(session.pid, kill_signal); }
174
+ catch (_) { /* already dead */ }
175
+ }
176
+ ops.updateSession(db, sid, { status });
177
+ // Phase 7.3 — opt-in skill mining (same lifecycle moment; runs after
178
+ // status flip so task_completed events are visible).
179
+ if (mineSkill && skillsRoot) {
180
+ try { skillMiner.mineSession(db, { sid, skillsRoot }); }
181
+ catch (err) { process.stderr.write(`skill-miner error: ${err.message}\n`); }
182
+ }
183
+ // Phase 8B.4 — opt-in auto-merge. Conflict preserves worktree for the
184
+ // human; clean/no-op merges fall through to normal removal below.
185
+ let mergeResult;
186
+ let effectiveRemove = remove_worktree;
187
+ if (autoMerge && status === 'completed' && repoRoot && session.worktree_path) {
188
+ const autoMergeMod = require('./auto-merge.cjs');
189
+ mergeResult = autoMergeMod.autoMerge({
190
+ repoRoot,
191
+ worktreePath: session.worktree_path,
192
+ baseBranch: mergeBaseBranch,
193
+ sid,
194
+ task_id: session.task_id,
195
+ db,
196
+ });
197
+ if (mergeResult.reason === 'conflict') effectiveRemove = false;
198
+ }
199
+ if (effectiveRemove && repoRoot && session.worktree_path) {
200
+ gitWorktreeRemove(repoRoot, session.worktree_path);
201
+ }
202
+ const out = { sid, status };
203
+ if (mergeResult) out.merge = mergeResult;
204
+ return out;
205
+ }
206
+
207
+ function attachHeartbeat(db, sid, { intervalMs = 30000 } = {}) {
208
+ if (!db || !sid) throw new SessionRunnerError('VALIDATION_ERROR', 'db + sid required');
209
+ const timer = setInterval(() => {
210
+ try { ops.heartbeatSession(db, sid); }
211
+ catch (_err) { clearInterval(timer); }
212
+ }, intervalMs);
213
+ if (typeof timer.unref === 'function') timer.unref();
214
+ return () => clearInterval(timer);
215
+ }
216
+
217
+ module.exports = {
218
+ SessionRunnerError,
219
+ spawnSession,
220
+ closeSession,
221
+ attachHeartbeat,
222
+ // exposed for tests
223
+ _internal: { gitWorktreeAdd, gitWorktreeRemove, mintSessionId },
224
+ };
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // Phase 7.3 — Heuristic skill miner.
4
+ //
5
+ // Scans a completed session's event stream for "solved something
6
+ // non-trivial" signals. Each signal produces a skills/learned/
7
+ // <ts>_<sid>_unverified.md draft the operator can promote to a proper
8
+ // skill by removing the _unverified suffix and filling in reuse notes.
9
+ //
10
+ // No LLM call. The heuristic is deliberately coarse so the feature ships
11
+ // zero-API-cost; signal quality is explicitly marked "unverified" in the
12
+ // frontmatter.
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ const ops = require('../mcp-server/lib/state-ops.cjs');
18
+
19
+ const LEARNED_DIR = 'learned';
20
+
21
+ // Event type → draft kind + description template.
22
+ const SIGNALS = Object.freeze({
23
+ task_completed: {
24
+ kind: 'task-completion',
25
+ describe: (task) => `Shipped task ${task.id} (${task.title}). Heuristic draft — fill reuse notes before removing _unverified.`,
26
+ },
27
+ task_circuit_broken: {
28
+ kind: 'debug-pattern',
29
+ describe: (task, e) => `Task ${task.id} (${task.title}) tripped the breaker after ${e.payload && e.payload.threshold} failures. Inspect the failure stream for a recurring debug pattern.`,
30
+ },
31
+ session_crashed: {
32
+ kind: 'recovery-pattern',
33
+ describe: (task) => `Session on task ${task.id} (${task.title}) crashed. Record the recovery path once investigated.`,
34
+ },
35
+ });
36
+
37
+ function sanitizeForFilename(s) {
38
+ return String(s).replace(/[^\w.-]/g, '_').slice(0, 80);
39
+ }
40
+
41
+ function renderDraft({ task, session, signalType, kind, description, events }) {
42
+ const ts = new Date().toISOString();
43
+ const evIds = events.map((e) => e.id).slice(0, 20);
44
+ const lines = [
45
+ '---',
46
+ `name: ${sanitizeForFilename(task.id + '_' + session.sid)}_unverified`,
47
+ `description: "Heuristic draft from session ${session.sid}, task ${task.id}"`,
48
+ `kind: ${kind}`,
49
+ `source_session: ${session.sid}`,
50
+ `source_task: ${task.id}`,
51
+ `signal: ${signalType}`,
52
+ `ts: ${ts}`,
53
+ 'unverified: true',
54
+ '---',
55
+ '',
56
+ '## Problem',
57
+ '',
58
+ `${task.title}${task.trace_to ? ` (trace: ${task.trace_to})` : ''}`,
59
+ '',
60
+ '## Signal',
61
+ '',
62
+ description,
63
+ '',
64
+ '## Evidence',
65
+ '',
66
+ `- events: [${evIds.join(', ')}]`,
67
+ `- runtime: ${session.runtime}`,
68
+ `- final task status: ${task.status}`,
69
+ '',
70
+ '## Suggested reuse',
71
+ '',
72
+ '[human: fill in before removing the `_unverified` suffix]',
73
+ '',
74
+ ];
75
+ return lines.join('\n');
76
+ }
77
+
78
+ function existingDraftsForSession(learnedDir, sid) {
79
+ if (!fs.existsSync(learnedDir)) return [];
80
+ return fs.readdirSync(learnedDir).filter((f) => f.includes(sid) && f.endsWith('_unverified.md'));
81
+ }
82
+
83
+ function mineSession(db, { sid, skillsRoot } = {}) {
84
+ if (!sid || !skillsRoot) return { drafts: [] };
85
+ const session = ops.readSession(db, sid);
86
+ if (!session) return { drafts: [] };
87
+
88
+ const learnedDir = path.join(skillsRoot, LEARNED_DIR);
89
+ fs.mkdirSync(learnedDir, { recursive: true });
90
+
91
+ // Idempotency: skip if a draft already exists for this sid.
92
+ if (existingDraftsForSession(learnedDir, sid).length > 0) {
93
+ return { drafts: [] };
94
+ }
95
+
96
+ const task = ops.readTask(db, session.task_id);
97
+ if (!task) return { drafts: [] };
98
+
99
+ const { events } = ops.subscribeEventsSince(db, {
100
+ since_id: 0,
101
+ task_id: session.task_id,
102
+ limit: 500,
103
+ });
104
+ const sessionEvents = events.filter((e) => e.session_id === sid);
105
+
106
+ // Pick the first matching signal (most specific one per session — we don't
107
+ // want one session producing three drafts).
108
+ for (const e of sessionEvents) {
109
+ const sig = SIGNALS[e.type];
110
+ if (!sig) continue;
111
+ const content = renderDraft({
112
+ task,
113
+ session,
114
+ signalType: e.type,
115
+ kind: sig.kind,
116
+ description: sig.describe(task, e),
117
+ events: sessionEvents,
118
+ });
119
+ const filename = `${Date.now()}_${sanitizeForFilename(sid)}_unverified.md`;
120
+ const target = path.join(learnedDir, filename);
121
+ fs.writeFileSync(target, content);
122
+ return { drafts: [target] };
123
+ }
124
+
125
+ return { drafts: [] };
126
+ }
127
+
128
+ module.exports = {
129
+ mineSession,
130
+ SIGNALS,
131
+ // exposed for tests
132
+ _internal: { renderDraft, existingDraftsForSession, sanitizeForFilename },
133
+ };
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ // Phase 8B.4 — Auto-merge back.
4
+ //
5
+ // autoMerge({ repoRoot, worktreePath, baseBranch, sid, task_id, db }):
6
+ // • no changes (session_sha === base_sha) → { merged:false, reason:'no_changes' }
7
+ // • clean merge → { merged:true } + merged_back event
8
+ // • conflict → { merged:false, reason:'conflict', conflict_paths[] }
9
+ // + merge_conflict event + merge --abort
10
+ // closeSession({ autoMerge:true }) integration:
11
+ // • autoMerge=true + conflict → worktree kept on disk (for human to resolve)
12
+ // • autoMerge=false (default) → behavior unchanged (Phase 4.5 / 5 / 7 tests)
13
+
14
+ const { test } = require('node:test');
15
+ const assert = require('node:assert/strict');
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const { execFileSync } = require('node:child_process');
20
+
21
+ const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
22
+ const ops = require('../../mcp-server/lib/state-ops.cjs');
23
+ const wtmgr = require('../worktree-manager.cjs');
24
+ const autoMerge = require('../auto-merge.cjs');
25
+ const runner = require('../session-runner.cjs');
26
+
27
+ function mkRepo() {
28
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-automerge-'));
29
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
30
+ execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
31
+ execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
32
+ fs.writeFileSync(path.join(dir, 'seed.md'), '# seed\n');
33
+ // The DB and worktree dirs live under .ultra/ — keep them out of git so
34
+ // `git status` in conflict tests isn't polluted by untracked test artifacts.
35
+ fs.writeFileSync(path.join(dir, '.gitignore'), '.ultra/\n');
36
+ execFileSync('git', ['add', '-A'], { cwd: dir });
37
+ execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
38
+ return dir;
39
+ }
40
+
41
+ function mkDb(repoRoot) {
42
+ const dbPath = path.join(repoRoot, '.ultra', 'state.db');
43
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
44
+ const { db } = initStateDb(dbPath);
45
+ return db;
46
+ }
47
+
48
+ function commitInWorktree(wtPath, filename, content, msg = 'session change') {
49
+ fs.writeFileSync(path.join(wtPath, filename), content);
50
+ execFileSync('git', ['add', '-A'], { cwd: wtPath });
51
+ execFileSync('git', ['-c', 'user.email=s@ubp.dev', '-c', 'user.name=s-ubp',
52
+ 'commit', '-q', '-m', msg], { cwd: wtPath });
53
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: wtPath, stdio: ['ignore', 'pipe', 'pipe'] })
54
+ .toString().trim();
55
+ }
56
+
57
+ function cleanup(repoRoot, db) {
58
+ try { if (db) closeStateDb(db); } catch (_) { /* best-effort */ }
59
+ try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
60
+ }
61
+
62
+ // ─── autoMerge pure function ──────────────────────────────────────────────
63
+
64
+ test('autoMerge: no session commits → merged:false reason:no_changes', () => {
65
+ const repo = mkRepo();
66
+ try {
67
+ const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid: 's-noop' });
68
+ const r = autoMerge.autoMerge({
69
+ repoRoot: repo, worktreePath: worktree_path,
70
+ baseBranch: 'main', sid: 's-noop',
71
+ });
72
+ assert.equal(r.merged, false);
73
+ assert.equal(r.reason, 'no_changes');
74
+ } finally { cleanup(repo); }
75
+ });
76
+
77
+ test('autoMerge: 3 slices with independent files → all merged back to main', () => {
78
+ const repo = mkRepo();
79
+ const db = mkDb(repo);
80
+ try {
81
+ for (const sid of ['s1', 's2', 's3']) {
82
+ ops.createTask(db, { id: sid, title: sid, type: 'feature', priority: 'P2' });
83
+ }
84
+ const shas = {};
85
+ for (const [sid, file] of [['s1', 'a.txt'], ['s2', 'b.txt'], ['s3', 'c.txt']]) {
86
+ const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid });
87
+ shas[sid] = commitInWorktree(worktree_path, file, `${file}\n`);
88
+ const r = autoMerge.autoMerge({
89
+ repoRoot: repo, worktreePath: worktree_path,
90
+ baseBranch: 'main', sid, task_id: sid, db,
91
+ });
92
+ assert.equal(r.merged, true, `${sid} should merge`);
93
+ assert.equal(r.session_sha, shas[sid]);
94
+ }
95
+ assert.ok(fs.existsSync(path.join(repo, 'a.txt')));
96
+ assert.ok(fs.existsSync(path.join(repo, 'b.txt')));
97
+ assert.ok(fs.existsSync(path.join(repo, 'c.txt')));
98
+ const { events } = ops.subscribeEventsSince(db, 0);
99
+ const merged = events.filter((e) => e.type === 'merged_back');
100
+ assert.equal(merged.length, 3);
101
+ } finally { cleanup(repo, db); }
102
+ });
103
+
104
+ test('autoMerge: 2 slices on same file → first merges, second conflict + event', () => {
105
+ const repo = mkRepo();
106
+ const db = mkDb(repo);
107
+ try {
108
+ ops.createTask(db, { id: 'cf1', title: 'cf1', type: 'feature', priority: 'P2' });
109
+ ops.createTask(db, { id: 'cf2', title: 'cf2', type: 'feature', priority: 'P2' });
110
+ const { worktree_path: w1 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf1' });
111
+ const { worktree_path: w2 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf2' });
112
+ commitInWorktree(w1, 'conflict.txt', 'version-A\n');
113
+ commitInWorktree(w2, 'conflict.txt', 'version-B\n');
114
+
115
+ const r1 = autoMerge.autoMerge({
116
+ repoRoot: repo, worktreePath: w1, baseBranch: 'main', sid: 'cf1', task_id: 'cf1', db,
117
+ });
118
+ assert.equal(r1.merged, true);
119
+ const r2 = autoMerge.autoMerge({
120
+ repoRoot: repo, worktreePath: w2, baseBranch: 'main', sid: 'cf2', task_id: 'cf2', db,
121
+ });
122
+ assert.equal(r2.merged, false);
123
+ assert.equal(r2.reason, 'conflict');
124
+ assert.ok(Array.isArray(r2.conflict_paths));
125
+ assert.ok(r2.conflict_paths.includes('conflict.txt'));
126
+
127
+ // main HEAD rolled back to r1's merge commit, not mid-conflict
128
+ const status = execFileSync('git', ['status', '--porcelain'], {
129
+ cwd: repo, stdio: ['ignore', 'pipe', 'pipe'],
130
+ }).toString();
131
+ assert.equal(status.trim(), '', 'working tree must be clean after abort');
132
+
133
+ const { events } = ops.subscribeEventsSince(db, 0);
134
+ const conflict = events.find((e) => e.type === 'merge_conflict');
135
+ assert.ok(conflict, 'merge_conflict event expected');
136
+ assert.deepEqual(conflict.payload.conflict_paths, ['conflict.txt']);
137
+ } finally { cleanup(repo, db); }
138
+ });
139
+
140
+ // ─── closeSession integration ─────────────────────────────────────────────
141
+
142
+ test('closeSession autoMerge=true + clean merge → worktree removed', () => {
143
+ const repo = mkRepo();
144
+ const db = mkDb(repo);
145
+ try {
146
+ ops.createTask(db, { id: 'cs-clean', title: 'x', type: 'feature', priority: 'P2' });
147
+ const handle = runner.spawnSession({
148
+ db, repoRoot: repo,
149
+ task_id: 'cs-clean', runtime: 'claude',
150
+ command: process.execPath, args: ['-e', 'process.exit(0)'],
151
+ });
152
+ // Simulate agent commit inside worktree
153
+ commitInWorktree(handle.worktree_path, 'new.txt', 'hi\n');
154
+ // Wait for child to exit so closeSession can kill cleanly (it already did)
155
+ if (handle.process) { try { handle.process.kill('SIGTERM'); } catch (_) { /* noop */ } }
156
+
157
+ const result = runner.closeSession(
158
+ { db, repoRoot: repo, sid: handle.sid },
159
+ { autoMerge: true, mergeBaseBranch: 'main' },
160
+ );
161
+ assert.equal(result.merge && result.merge.merged, true);
162
+ assert.equal(fs.existsSync(handle.worktree_path), false, 'clean merge → worktree removed');
163
+ } finally { cleanup(repo, db); }
164
+ });
165
+
166
+ test('closeSession autoMerge=true + conflict → worktree kept', () => {
167
+ const repo = mkRepo();
168
+ const db = mkDb(repo);
169
+ try {
170
+ ops.createTask(db, { id: 'cs-cflict', title: 'x', type: 'feature', priority: 'P2' });
171
+
172
+ // Spawn FIRST so the worktree forks at seed commit (before main diverges).
173
+ const handle = runner.spawnSession({
174
+ db, repoRoot: repo,
175
+ task_id: 'cs-cflict', runtime: 'claude',
176
+ command: process.execPath, args: ['-e', 'process.exit(0)'],
177
+ });
178
+ // Advance main with a conflicting version of shared.txt.
179
+ fs.writeFileSync(path.join(repo, 'shared.txt'), 'main-version\n');
180
+ execFileSync('git', ['add', '-A'], { cwd: repo });
181
+ execFileSync('git', ['commit', '-q', '-m', 'main change'], { cwd: repo });
182
+ // Session worktree forks off seed and writes its own version of the same file.
183
+ commitInWorktree(handle.worktree_path, 'shared.txt', 'session-version\n');
184
+
185
+ const result = runner.closeSession(
186
+ { db, repoRoot: repo, sid: handle.sid },
187
+ { autoMerge: true, mergeBaseBranch: 'main' },
188
+ );
189
+ assert.equal(result.merge && result.merge.merged, false);
190
+ assert.equal(result.merge.reason, 'conflict');
191
+ assert.ok(fs.existsSync(handle.worktree_path), 'conflict → worktree preserved for resolution');
192
+ } finally { cleanup(repo, db); }
193
+ });
194
+
195
+ test('closeSession autoMerge=false (default) → no merge, legacy behavior', () => {
196
+ const repo = mkRepo();
197
+ const db = mkDb(repo);
198
+ try {
199
+ ops.createTask(db, { id: 'cs-off', title: 'x', type: 'feature', priority: 'P2' });
200
+ const handle = runner.spawnSession({
201
+ db, repoRoot: repo,
202
+ task_id: 'cs-off', runtime: 'claude',
203
+ command: process.execPath, args: ['-e', 'process.exit(0)'],
204
+ });
205
+ commitInWorktree(handle.worktree_path, 'n.txt', 'x\n');
206
+
207
+ const result = runner.closeSession(
208
+ { db, repoRoot: repo, sid: handle.sid },
209
+ {}, // autoMerge not set
210
+ );
211
+ assert.equal(result.merge, undefined, 'no merge when opt-in disabled');
212
+ assert.equal(fs.existsSync(handle.worktree_path), false, 'worktree removed normally');
213
+ // main must still be at seed (no merge happened)
214
+ assert.equal(fs.existsSync(path.join(repo, 'n.txt')), false);
215
+ } finally { cleanup(repo, db); }
216
+ });