@soleri/core 9.11.0 → 9.13.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 (234) hide show
  1. package/dist/adapters/types.d.ts +2 -0
  2. package/dist/adapters/types.d.ts.map +1 -1
  3. package/dist/brain/brain.d.ts +5 -1
  4. package/dist/brain/brain.d.ts.map +1 -1
  5. package/dist/brain/brain.js +97 -10
  6. package/dist/brain/brain.js.map +1 -1
  7. package/dist/dream/cron-manager.d.ts +10 -0
  8. package/dist/dream/cron-manager.d.ts.map +1 -0
  9. package/dist/dream/cron-manager.js +122 -0
  10. package/dist/dream/cron-manager.js.map +1 -0
  11. package/dist/dream/dream-engine.d.ts +34 -0
  12. package/dist/dream/dream-engine.d.ts.map +1 -0
  13. package/dist/dream/dream-engine.js +88 -0
  14. package/dist/dream/dream-engine.js.map +1 -0
  15. package/dist/dream/dream-ops.d.ts +8 -0
  16. package/dist/dream/dream-ops.d.ts.map +1 -0
  17. package/dist/dream/dream-ops.js +49 -0
  18. package/dist/dream/dream-ops.js.map +1 -0
  19. package/dist/dream/index.d.ts +7 -0
  20. package/dist/dream/index.d.ts.map +1 -0
  21. package/dist/dream/index.js +5 -0
  22. package/dist/dream/index.js.map +1 -0
  23. package/dist/dream/schema.d.ts +3 -0
  24. package/dist/dream/schema.d.ts.map +1 -0
  25. package/dist/dream/schema.js +16 -0
  26. package/dist/dream/schema.js.map +1 -0
  27. package/dist/embeddings/index.d.ts +5 -0
  28. package/dist/embeddings/index.d.ts.map +1 -0
  29. package/dist/embeddings/index.js +3 -0
  30. package/dist/embeddings/index.js.map +1 -0
  31. package/dist/embeddings/openai-provider.d.ts +31 -0
  32. package/dist/embeddings/openai-provider.d.ts.map +1 -0
  33. package/dist/embeddings/openai-provider.js +120 -0
  34. package/dist/embeddings/openai-provider.js.map +1 -0
  35. package/dist/embeddings/pipeline.d.ts +36 -0
  36. package/dist/embeddings/pipeline.d.ts.map +1 -0
  37. package/dist/embeddings/pipeline.js +78 -0
  38. package/dist/embeddings/pipeline.js.map +1 -0
  39. package/dist/embeddings/types.d.ts +62 -0
  40. package/dist/embeddings/types.d.ts.map +1 -0
  41. package/dist/embeddings/types.js +3 -0
  42. package/dist/embeddings/types.js.map +1 -0
  43. package/dist/engine/bin/soleri-engine.js +4 -1
  44. package/dist/engine/bin/soleri-engine.js.map +1 -1
  45. package/dist/engine/module-manifest.d.ts.map +1 -1
  46. package/dist/engine/module-manifest.js +20 -0
  47. package/dist/engine/module-manifest.js.map +1 -1
  48. package/dist/engine/register-engine.d.ts.map +1 -1
  49. package/dist/engine/register-engine.js +12 -0
  50. package/dist/engine/register-engine.js.map +1 -1
  51. package/dist/flows/chain-types.d.ts +8 -8
  52. package/dist/flows/dispatch-registry.d.ts +15 -1
  53. package/dist/flows/dispatch-registry.d.ts.map +1 -1
  54. package/dist/flows/dispatch-registry.js +28 -1
  55. package/dist/flows/dispatch-registry.js.map +1 -1
  56. package/dist/flows/executor.d.ts +20 -2
  57. package/dist/flows/executor.d.ts.map +1 -1
  58. package/dist/flows/executor.js +79 -1
  59. package/dist/flows/executor.js.map +1 -1
  60. package/dist/flows/index.d.ts +2 -1
  61. package/dist/flows/index.d.ts.map +1 -1
  62. package/dist/flows/index.js.map +1 -1
  63. package/dist/flows/types.d.ts +43 -21
  64. package/dist/flows/types.d.ts.map +1 -1
  65. package/dist/index.d.ts +6 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/persona/defaults.d.ts +8 -0
  70. package/dist/persona/defaults.d.ts.map +1 -1
  71. package/dist/persona/defaults.js +49 -0
  72. package/dist/persona/defaults.js.map +1 -1
  73. package/dist/plugins/types.d.ts +31 -31
  74. package/dist/runtime/admin-ops.d.ts.map +1 -1
  75. package/dist/runtime/admin-ops.js +15 -0
  76. package/dist/runtime/admin-ops.js.map +1 -1
  77. package/dist/runtime/admin-setup-ops.js +2 -2
  78. package/dist/runtime/admin-setup-ops.js.map +1 -1
  79. package/dist/runtime/embedding-ops.d.ts +12 -0
  80. package/dist/runtime/embedding-ops.d.ts.map +1 -0
  81. package/dist/runtime/embedding-ops.js +96 -0
  82. package/dist/runtime/embedding-ops.js.map +1 -0
  83. package/dist/runtime/facades/embedding-facade.d.ts +7 -0
  84. package/dist/runtime/facades/embedding-facade.d.ts.map +1 -0
  85. package/dist/runtime/facades/embedding-facade.js +8 -0
  86. package/dist/runtime/facades/embedding-facade.js.map +1 -0
  87. package/dist/runtime/facades/index.d.ts.map +1 -1
  88. package/dist/runtime/facades/index.js +12 -0
  89. package/dist/runtime/facades/index.js.map +1 -1
  90. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  91. package/dist/runtime/facades/orchestrate-facade.js +120 -0
  92. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  93. package/dist/runtime/feature-flags.d.ts.map +1 -1
  94. package/dist/runtime/feature-flags.js +4 -0
  95. package/dist/runtime/feature-flags.js.map +1 -1
  96. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  97. package/dist/runtime/orchestrate-ops.js +140 -9
  98. package/dist/runtime/orchestrate-ops.js.map +1 -1
  99. package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
  100. package/dist/runtime/planning-extra-ops.js +51 -0
  101. package/dist/runtime/planning-extra-ops.js.map +1 -1
  102. package/dist/runtime/preflight.d.ts +32 -0
  103. package/dist/runtime/preflight.d.ts.map +1 -0
  104. package/dist/runtime/preflight.js +29 -0
  105. package/dist/runtime/preflight.js.map +1 -0
  106. package/dist/runtime/runtime.d.ts.map +1 -1
  107. package/dist/runtime/runtime.js +33 -2
  108. package/dist/runtime/runtime.js.map +1 -1
  109. package/dist/runtime/types.d.ts +27 -0
  110. package/dist/runtime/types.d.ts.map +1 -1
  111. package/dist/skills/step-tracker.d.ts +39 -0
  112. package/dist/skills/step-tracker.d.ts.map +1 -0
  113. package/dist/skills/step-tracker.js +105 -0
  114. package/dist/skills/step-tracker.js.map +1 -0
  115. package/dist/skills/sync-skills.d.ts +3 -2
  116. package/dist/skills/sync-skills.d.ts.map +1 -1
  117. package/dist/skills/sync-skills.js +42 -8
  118. package/dist/skills/sync-skills.js.map +1 -1
  119. package/dist/subagent/dispatcher.d.ts +4 -3
  120. package/dist/subagent/dispatcher.d.ts.map +1 -1
  121. package/dist/subagent/dispatcher.js +57 -35
  122. package/dist/subagent/dispatcher.js.map +1 -1
  123. package/dist/subagent/index.d.ts +1 -0
  124. package/dist/subagent/index.d.ts.map +1 -1
  125. package/dist/subagent/index.js.map +1 -1
  126. package/dist/subagent/orphan-reaper.d.ts +51 -4
  127. package/dist/subagent/orphan-reaper.d.ts.map +1 -1
  128. package/dist/subagent/orphan-reaper.js +103 -3
  129. package/dist/subagent/orphan-reaper.js.map +1 -1
  130. package/dist/subagent/types.d.ts +7 -0
  131. package/dist/subagent/types.d.ts.map +1 -1
  132. package/dist/subagent/workspace-resolver.d.ts +2 -0
  133. package/dist/subagent/workspace-resolver.d.ts.map +1 -1
  134. package/dist/subagent/workspace-resolver.js +3 -1
  135. package/dist/subagent/workspace-resolver.js.map +1 -1
  136. package/dist/vault/vault-entries.d.ts +18 -0
  137. package/dist/vault/vault-entries.d.ts.map +1 -1
  138. package/dist/vault/vault-entries.js +73 -0
  139. package/dist/vault/vault-entries.js.map +1 -1
  140. package/dist/vault/vault-manager.d.ts.map +1 -1
  141. package/dist/vault/vault-manager.js +1 -0
  142. package/dist/vault/vault-manager.js.map +1 -1
  143. package/dist/vault/vault-schema.d.ts.map +1 -1
  144. package/dist/vault/vault-schema.js +14 -0
  145. package/dist/vault/vault-schema.js.map +1 -1
  146. package/dist/vault/vault.d.ts +1 -0
  147. package/dist/vault/vault.d.ts.map +1 -1
  148. package/dist/vault/vault.js.map +1 -1
  149. package/package.json +3 -5
  150. package/src/__tests__/cron-manager.test.ts +132 -0
  151. package/src/__tests__/deviation-detection.test.ts +234 -0
  152. package/src/__tests__/embeddings.test.ts +536 -0
  153. package/src/__tests__/preflight.test.ts +97 -0
  154. package/src/__tests__/step-persistence.test.ts +324 -0
  155. package/src/__tests__/step-tracker.test.ts +260 -0
  156. package/src/__tests__/subagent/dispatcher.test.ts +122 -4
  157. package/src/__tests__/subagent/orphan-reaper.test.ts +148 -12
  158. package/src/__tests__/subagent/process-lifecycle.test.ts +422 -0
  159. package/src/__tests__/subagent/workspace-resolver.test.ts +6 -1
  160. package/src/adapters/types.ts +2 -0
  161. package/src/brain/brain.ts +117 -9
  162. package/src/dream/cron-manager.ts +137 -0
  163. package/src/dream/dream-engine.ts +119 -0
  164. package/src/dream/dream-ops.ts +56 -0
  165. package/src/dream/dream.test.ts +182 -0
  166. package/src/dream/index.ts +6 -0
  167. package/src/dream/schema.ts +17 -0
  168. package/src/embeddings/openai-provider.ts +158 -0
  169. package/src/embeddings/pipeline.ts +126 -0
  170. package/src/embeddings/types.ts +67 -0
  171. package/src/engine/bin/soleri-engine.ts +4 -1
  172. package/src/engine/module-manifest.test.ts +4 -4
  173. package/src/engine/module-manifest.ts +20 -0
  174. package/src/engine/register-engine.ts +12 -0
  175. package/src/flows/dispatch-registry.ts +44 -1
  176. package/src/flows/executor.ts +93 -2
  177. package/src/flows/index.ts +2 -0
  178. package/src/flows/types.ts +39 -1
  179. package/src/index.ts +12 -0
  180. package/src/persona/defaults.test.ts +39 -1
  181. package/src/persona/defaults.ts +65 -0
  182. package/src/planning/goal-ancestry.test.ts +3 -5
  183. package/src/planning/planner.test.ts +2 -3
  184. package/src/runtime/admin-ops.test.ts +2 -2
  185. package/src/runtime/admin-ops.ts +17 -0
  186. package/src/runtime/admin-setup-ops.ts +2 -2
  187. package/src/runtime/embedding-ops.ts +116 -0
  188. package/src/runtime/facades/admin-facade.test.ts +31 -0
  189. package/src/runtime/facades/embedding-facade.ts +11 -0
  190. package/src/runtime/facades/index.ts +12 -0
  191. package/src/runtime/facades/orchestrate-facade.test.ts +16 -0
  192. package/src/runtime/facades/orchestrate-facade.ts +146 -0
  193. package/src/runtime/feature-flags.ts +4 -0
  194. package/src/runtime/orchestrate-ops.test.ts +131 -0
  195. package/src/runtime/orchestrate-ops.ts +158 -10
  196. package/src/runtime/planning-extra-ops.ts +77 -0
  197. package/src/runtime/preflight.ts +53 -0
  198. package/src/runtime/runtime.ts +41 -2
  199. package/src/runtime/types.ts +20 -0
  200. package/src/skills/__tests__/sync-skills.test.ts +132 -0
  201. package/src/skills/step-tracker.ts +162 -0
  202. package/src/skills/sync-skills.ts +54 -9
  203. package/src/subagent/dispatcher.ts +62 -39
  204. package/src/subagent/index.ts +1 -0
  205. package/src/subagent/orphan-reaper.test.ts +135 -0
  206. package/src/subagent/orphan-reaper.ts +130 -7
  207. package/src/subagent/types.ts +10 -0
  208. package/src/subagent/workspace-resolver.ts +3 -1
  209. package/src/vault/vault-entries.ts +112 -0
  210. package/src/vault/vault-manager.ts +1 -0
  211. package/src/vault/vault-scaling.test.ts +3 -2
  212. package/src/vault/vault-schema.ts +15 -0
  213. package/src/vault/vault.ts +1 -0
  214. package/vitest.config.ts +2 -1
  215. package/dist/brain/strength-scorer.d.ts +0 -31
  216. package/dist/brain/strength-scorer.d.ts.map +0 -1
  217. package/dist/brain/strength-scorer.js +0 -264
  218. package/dist/brain/strength-scorer.js.map +0 -1
  219. package/dist/engine/index.d.ts +0 -21
  220. package/dist/engine/index.d.ts.map +0 -1
  221. package/dist/engine/index.js +0 -18
  222. package/dist/engine/index.js.map +0 -1
  223. package/dist/hooks/index.d.ts +0 -2
  224. package/dist/hooks/index.d.ts.map +0 -1
  225. package/dist/hooks/index.js +0 -2
  226. package/dist/hooks/index.js.map +0 -1
  227. package/dist/persona/index.d.ts +0 -5
  228. package/dist/persona/index.d.ts.map +0 -1
  229. package/dist/persona/index.js +0 -4
  230. package/dist/persona/index.js.map +0 -1
  231. package/dist/vault/vault-interfaces.d.ts +0 -153
  232. package/dist/vault/vault-interfaces.d.ts.map +0 -1
  233. package/dist/vault/vault-interfaces.js +0 -2
  234. package/dist/vault/vault-interfaces.js.map +0 -1
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Process lifecycle integration tests.
3
+ *
4
+ * These tests spawn real child processes to verify orphan detection,
5
+ * kill escalation, process group management, and the full dispatch
6
+ * lifecycle. No mocking of process.kill — real signals, real PIDs.
7
+ */
8
+
9
+ import { describe, it, expect, afterEach } from 'vitest';
10
+ import { spawn, ChildProcess, execSync } from 'node:child_process';
11
+ import { OrphanReaper } from '../../subagent/orphan-reaper.js';
12
+
13
+ // Collect all spawned processes for cleanup
14
+ const spawnedChildren: ChildProcess[] = [];
15
+
16
+ /** Spawn a long-running node process that does nothing. */
17
+ function spawnIdleProcess(opts?: { detached?: boolean }): ChildProcess {
18
+ const child = spawn('node', ['-e', 'setInterval(()=>{},1000)'], {
19
+ stdio: 'ignore',
20
+ detached: opts?.detached ?? false,
21
+ });
22
+ spawnedChildren.push(child);
23
+ return child;
24
+ }
25
+
26
+ /** Spawn a parent that spawns a grandchild, both idle. */
27
+ function spawnWithGrandchild(): ChildProcess {
28
+ // The parent spawns a child which also idles.
29
+ // Using detached so the parent becomes a process group leader.
30
+ const child = spawn(
31
+ 'node',
32
+ [
33
+ '-e',
34
+ `
35
+ const { spawn } = require('child_process');
36
+ const gc = spawn('node', ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
37
+ // Write grandchild PID to stdout so the test can track it
38
+ process.stdout.write(String(gc.pid));
39
+ setInterval(()=>{}, 1000);
40
+ `,
41
+ ],
42
+ {
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ detached: true,
45
+ },
46
+ );
47
+ spawnedChildren.push(child);
48
+ return child;
49
+ }
50
+
51
+ /** Check if a PID is alive using signal 0. */
52
+ function isAlive(pid: number): boolean {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** Wait until a condition is true, polling every intervalMs. */
62
+ async function waitFor(fn: () => boolean, timeoutMs = 10_000, intervalMs = 100): Promise<void> {
63
+ const start = Date.now();
64
+ while (!fn()) {
65
+ if (Date.now() - start > timeoutMs) {
66
+ throw new Error(`waitFor timed out after ${timeoutMs}ms`);
67
+ }
68
+ await new Promise((r) => setTimeout(r, intervalMs));
69
+ }
70
+ }
71
+
72
+ /** Force-kill a PID, ignoring errors if already dead. */
73
+ function safeKill(pid: number, signal: NodeJS.Signals = 'SIGKILL'): void {
74
+ try {
75
+ process.kill(pid, signal);
76
+ } catch {
77
+ // already dead — fine
78
+ }
79
+ }
80
+
81
+ afterEach(() => {
82
+ // Kill every process we spawned, best-effort
83
+ for (const child of spawnedChildren) {
84
+ if (child.pid) {
85
+ // Try group kill first (for detached), then single
86
+ try {
87
+ process.kill(-child.pid, 'SIGKILL');
88
+ } catch {
89
+ safeKill(child.pid);
90
+ }
91
+ }
92
+ }
93
+ spawnedChildren.length = 0;
94
+ });
95
+
96
+ describe('Process Lifecycle Integration', { timeout: 15_000 }, () => {
97
+ // ── a. Orphan detection ─────────────────────────────────────────
98
+
99
+ describe('orphan detection', () => {
100
+ it('detects a killed process via reap()', async () => {
101
+ const child = spawnIdleProcess();
102
+ const pid = child.pid!;
103
+ expect(pid).toBeGreaterThan(0);
104
+
105
+ const reaper = new OrphanReaper();
106
+ reaper.register(pid, 'orphan-test');
107
+
108
+ // Process should be alive initially
109
+ expect(isAlive(pid)).toBe(true);
110
+ const initialReap = reaper.reap();
111
+ expect(initialReap.reaped).toHaveLength(0);
112
+ expect(reaper.isTracked(pid)).toBe(true);
113
+
114
+ // Kill it externally
115
+ process.kill(pid, 'SIGKILL');
116
+ await waitFor(() => !isAlive(pid));
117
+
118
+ // Now reap should detect it
119
+ const result = reaper.reap();
120
+ expect(result.reaped).toHaveLength(1);
121
+ expect(result.reaped[0]).toBe('orphan-test');
122
+ expect(reaper.isTracked(pid)).toBe(false);
123
+ });
124
+
125
+ it('invokes onOrphan callback for dead processes', async () => {
126
+ const orphanEvents: Array<{ taskId: string; pid: number }> = [];
127
+ const reaper = new OrphanReaper((taskId, pid) => {
128
+ orphanEvents.push({ taskId, pid });
129
+ });
130
+
131
+ const child = spawnIdleProcess();
132
+ const pid = child.pid!;
133
+ reaper.register(pid, 'callback-test');
134
+
135
+ process.kill(pid, 'SIGKILL');
136
+ await waitFor(() => !isAlive(pid));
137
+
138
+ reaper.reap();
139
+ expect(orphanEvents).toHaveLength(1);
140
+ expect(orphanEvents[0]).toEqual({ taskId: 'callback-test', pid });
141
+ });
142
+
143
+ it('handles multiple tracked processes with mixed liveness', async () => {
144
+ const alive = spawnIdleProcess();
145
+ const dead = spawnIdleProcess();
146
+ const alivePid = alive.pid!;
147
+ const deadPid = dead.pid!;
148
+
149
+ const reaper = new OrphanReaper();
150
+ reaper.register(alivePid, 'alive-task');
151
+ reaper.register(deadPid, 'dead-task');
152
+
153
+ // Kill only one
154
+ process.kill(deadPid, 'SIGKILL');
155
+ await waitFor(() => !isAlive(deadPid));
156
+
157
+ const result = reaper.reap();
158
+ expect(result.reaped).toHaveLength(1);
159
+ expect(result.reaped[0]).toBe('dead-task');
160
+ expect(reaper.isTracked(alivePid)).toBe(true);
161
+ expect(reaper.isTracked(deadPid)).toBe(false);
162
+ });
163
+ });
164
+
165
+ // ── b. Timeout escalation (killProcessGroup with SIGKILL) ──────
166
+
167
+ describe('kill escalation', () => {
168
+ it('kills a process with SIGTERM via killProcessGroup', async () => {
169
+ const child = spawnIdleProcess({ detached: true });
170
+ const pid = child.pid!;
171
+
172
+ const reaper = new OrphanReaper();
173
+ reaper.register(pid, 'kill-test');
174
+
175
+ const result = reaper.killProcessGroup(pid, 'SIGTERM');
176
+ expect(result.killed).toBe(true);
177
+
178
+ await waitFor(() => !isAlive(pid));
179
+ expect(isAlive(pid)).toBe(false);
180
+ });
181
+
182
+ it('escalates to SIGKILL when SIGTERM is ignored', async () => {
183
+ // Spawn a process that traps SIGTERM
184
+ const child = spawn(
185
+ 'node',
186
+ ['-e', "process.on('SIGTERM', () => {}); setInterval(()=>{},1000)"],
187
+ { stdio: 'ignore', detached: true },
188
+ );
189
+ spawnedChildren.push(child);
190
+ const pid = child.pid!;
191
+
192
+ const reaper = new OrphanReaper();
193
+ reaper.register(pid, 'escalation-test');
194
+
195
+ // Send SIGTERM — process should still be alive after a short wait
196
+ reaper.killProcessGroup(pid, 'SIGTERM');
197
+ await new Promise((r) => setTimeout(r, 500));
198
+
199
+ // Process should still be alive (it traps SIGTERM)
200
+ if (isAlive(pid)) {
201
+ // Escalate to SIGKILL
202
+ const result = reaper.killProcessGroup(pid, 'SIGKILL');
203
+ expect(result.killed).toBe(true);
204
+ await waitFor(() => !isAlive(pid));
205
+ expect(isAlive(pid)).toBe(false);
206
+ }
207
+ // If it died from SIGTERM that's fine too — OS-dependent behavior
208
+ });
209
+
210
+ it('killProcessGroup returns killed:false for already-dead process', async () => {
211
+ const child = spawnIdleProcess({ detached: true });
212
+ const pid = child.pid!;
213
+
214
+ // Kill it first
215
+ process.kill(pid, 'SIGKILL');
216
+ await waitFor(() => !isAlive(pid));
217
+
218
+ const reaper = new OrphanReaper();
219
+ const result = reaper.killProcessGroup(pid, 'SIGTERM');
220
+ // Should indicate failure since process is dead
221
+ expect(result.killed).toBe(false);
222
+ });
223
+ });
224
+
225
+ // ── c. Process group kill ──────────────────────────────────────
226
+
227
+ describe('process group management', () => {
228
+ it('killProcessGroup kills the parent process group', async () => {
229
+ const child = spawnIdleProcess({ detached: true });
230
+ const pid = child.pid!;
231
+
232
+ const reaper = new OrphanReaper();
233
+ const result = reaper.killProcessGroup(pid);
234
+
235
+ expect(result.killed).toBe(true);
236
+ // Windows doesn't support negative-PID group signals, falls back to 'single'
237
+ expect(result.method).toBe(process.platform === 'win32' ? 'single' : 'group');
238
+
239
+ await waitFor(() => !isAlive(pid));
240
+ expect(isAlive(pid)).toBe(false);
241
+ });
242
+
243
+ it('killProcessGroup with grandchild kills the entire tree', async () => {
244
+ const parent = spawnWithGrandchild();
245
+ const parentPid = parent.pid!;
246
+
247
+ // Read grandchild PID from stdout
248
+ const grandchildPid = await new Promise<number>((resolve, reject) => {
249
+ let data = '';
250
+ const timer = setTimeout(() => reject(new Error('timeout reading grandchild PID')), 5000);
251
+ parent.stdout!.on('data', (chunk: Buffer) => {
252
+ data += chunk.toString();
253
+ const pid = parseInt(data, 10);
254
+ if (!isNaN(pid) && pid > 0) {
255
+ clearTimeout(timer);
256
+ resolve(pid);
257
+ }
258
+ });
259
+ parent.on('error', (err) => {
260
+ clearTimeout(timer);
261
+ reject(err);
262
+ });
263
+ });
264
+
265
+ expect(isAlive(parentPid)).toBe(true);
266
+ expect(isAlive(grandchildPid)).toBe(true);
267
+
268
+ // Kill the process group
269
+ const reaper = new OrphanReaper();
270
+ const result = reaper.killProcessGroup(parentPid, 'SIGKILL');
271
+ expect(result.killed).toBe(true);
272
+ // Windows doesn't support negative-PID group signals, falls back to 'single'
273
+ expect(result.method).toBe(process.platform === 'win32' ? 'single' : 'group');
274
+
275
+ // Both parent and grandchild should be dead
276
+ await waitFor(() => !isAlive(parentPid) && !isAlive(grandchildPid), 5000);
277
+ expect(isAlive(parentPid)).toBe(false);
278
+ expect(isAlive(grandchildPid)).toBe(false);
279
+ });
280
+
281
+ it('killAll kills all tracked processes', async () => {
282
+ const child1 = spawnIdleProcess({ detached: true });
283
+ const child2 = spawnIdleProcess({ detached: true });
284
+ const pid1 = child1.pid!;
285
+ const pid2 = child2.pid!;
286
+
287
+ const reaper = new OrphanReaper();
288
+ reaper.register(pid1, 'task-1');
289
+ reaper.register(pid2, 'task-2');
290
+
291
+ const results = reaper.killAll('SIGKILL');
292
+
293
+ expect(results.size).toBe(2);
294
+ expect(results.get(pid1)!.killed).toBe(true);
295
+ expect(results.get(pid2)!.killed).toBe(true);
296
+
297
+ // Tracking should be cleared
298
+ expect(reaper.listTracked()).toHaveLength(0);
299
+
300
+ await waitFor(() => !isAlive(pid1) && !isAlive(pid2));
301
+ expect(isAlive(pid1)).toBe(false);
302
+ expect(isAlive(pid2)).toBe(false);
303
+ });
304
+
305
+ it('killAll handles empty tracking gracefully', () => {
306
+ const reaper = new OrphanReaper();
307
+ const results = reaper.killAll();
308
+ expect(results.size).toBe(0);
309
+ });
310
+ });
311
+
312
+ // ── d. Full lifecycle ─────────────────────────────────────────
313
+
314
+ describe('full lifecycle', () => {
315
+ it('register → kill externally → reap → verify cleanup', async () => {
316
+ const reaper = new OrphanReaper();
317
+
318
+ // Simulate a dispatch wave: register multiple processes
319
+ const children = [spawnIdleProcess(), spawnIdleProcess(), spawnIdleProcess()];
320
+ const pids = children.map((c) => c.pid!);
321
+
322
+ pids.forEach((pid, i) => reaper.register(pid, `wave-task-${i}`));
323
+ expect(reaper.listTracked()).toHaveLength(3);
324
+
325
+ // All alive initially
326
+ const earlyReap = reaper.reap();
327
+ expect(earlyReap.reaped).toHaveLength(0);
328
+
329
+ // Kill two of three externally
330
+ process.kill(pids[0], 'SIGKILL');
331
+ process.kill(pids[2], 'SIGKILL');
332
+ await waitFor(() => !isAlive(pids[0]) && !isAlive(pids[2]));
333
+
334
+ // Reap should detect the two dead ones
335
+ const result = reaper.reap();
336
+ expect(result.reaped).toHaveLength(2);
337
+ expect(result.reaped.sort()).toEqual(['wave-task-0', 'wave-task-2']);
338
+
339
+ // One should still be tracked
340
+ expect(reaper.listTracked()).toHaveLength(1);
341
+ expect(reaper.isTracked(pids[1])).toBe(true);
342
+
343
+ // Clean up the survivor
344
+ reaper.killAll('SIGKILL');
345
+ expect(reaper.listTracked()).toHaveLength(0);
346
+ });
347
+
348
+ it('reap in finally block pattern works correctly', async () => {
349
+ const reaper = new OrphanReaper();
350
+ const child = spawnIdleProcess();
351
+ const pid = child.pid!;
352
+ reaper.register(pid, 'finally-test');
353
+
354
+ let reapedInFinally: ReturnType<typeof reaper.reap> | null = null;
355
+
356
+ try {
357
+ // Simulate dispatch work
358
+ process.kill(pid, 'SIGKILL');
359
+ await waitFor(() => !isAlive(pid));
360
+ } finally {
361
+ // This is what orchestrate-ops.ts does in the finally block
362
+ reapedInFinally = reaper.reap();
363
+ }
364
+
365
+ expect(reapedInFinally!.reaped).toHaveLength(1);
366
+ expect(reapedInFinally!.reaped[0]).toBe('finally-test');
367
+ expect(reaper.listTracked()).toHaveLength(0);
368
+ });
369
+ });
370
+
371
+ // ── e. Facade integration (admin_reap_orphans structure) ──────
372
+
373
+ describe('admin facade integration', () => {
374
+ it('dispatcher.reapOrphans() returns correct report structure', async () => {
375
+ // We test the dispatcher's reapOrphans method shape which is what
376
+ // admin_reap_orphans calls. We create an OrphanReaper directly since
377
+ // the dispatcher requires a full RuntimeAdapterRegistry.
378
+
379
+ const reaper = new OrphanReaper();
380
+ const child = spawnIdleProcess();
381
+ const pid = child.pid!;
382
+ reaper.register(pid, 'facade-test');
383
+
384
+ // Kill it
385
+ process.kill(pid, 'SIGKILL');
386
+ await waitFor(() => !isAlive(pid));
387
+
388
+ // Simulate what dispatcher.reapOrphans() does internally
389
+ const result = reaper.reap();
390
+
391
+ // reap() returns { reaped: string[], alive: string[] }
392
+ expect(result.reaped).toHaveLength(1);
393
+ expect(result.reaped[0]).toBe('facade-test');
394
+ expect(result.alive).toHaveLength(0);
395
+
396
+ // Verify report structure matches what admin_reap_orphans builds
397
+ const report = {
398
+ reaped: result.reaped.length,
399
+ tasks: result.reaped,
400
+ };
401
+
402
+ expect(report.reaped).toBe(1);
403
+ expect(report.tasks).toEqual(['facade-test']);
404
+ });
405
+
406
+ it('returns empty report when no orphans exist', () => {
407
+ const reaper = new OrphanReaper();
408
+ const child = spawnIdleProcess();
409
+ reaper.register(child.pid!, 'alive-task');
410
+
411
+ // All alive — reap returns nothing reaped
412
+ const result = reaper.reap();
413
+ const report = {
414
+ reaped: result.reaped.length,
415
+ tasks: result.reaped,
416
+ };
417
+
418
+ expect(report).toEqual({ reaped: 0, tasks: [] });
419
+ expect(result.alive).toHaveLength(1);
420
+ });
421
+ });
422
+ });
@@ -54,7 +54,7 @@ describe('WorkspaceResolver', () => {
54
54
  warnSpy.mockRestore();
55
55
  });
56
56
 
57
- it('cleanup() calls git worktree remove and git branch -D', () => {
57
+ it('cleanup() calls git worktree remove and git branch -D but not git push', () => {
58
58
  (execSync as ReturnType<typeof vi.fn>).mockReturnValue('');
59
59
 
60
60
  // First create a worktree
@@ -71,6 +71,11 @@ describe('WorkspaceResolver', () => {
71
71
  expect.stringContaining('git branch -D'),
72
72
  expect.objectContaining({ cwd: baseDir }),
73
73
  );
74
+ // Worktree branches are local-only — should NOT push to remote
75
+ expect(execSync).not.toHaveBeenCalledWith(
76
+ expect.stringContaining('git push'),
77
+ expect.anything(),
78
+ );
74
79
  });
75
80
 
76
81
  it('cleanup() silently handles errors', () => {
@@ -47,6 +47,8 @@ export interface AdapterExecutionResult {
47
47
  exitCode: number;
48
48
  /** Whether execution timed out */
49
49
  timedOut?: boolean;
50
+ /** PID of the spawned child process (if available) */
51
+ pid?: number;
50
52
  /** Token usage */
51
53
  usage?: AdapterTokenUsage;
52
54
  /** Session state to persist for next run */
@@ -10,7 +10,8 @@ import {
10
10
  cosineSimilarity,
11
11
  jaccardSimilarity,
12
12
  } from '../text/similarity.js';
13
- import { rowToEntry } from '../vault/vault-entries.js';
13
+ import { rowToEntry, cosineSearch, getVector } from '../vault/vault-entries.js';
14
+ import type { EmbeddingProvider } from '../embeddings/types.js';
14
15
  import type {
15
16
  ScoringWeights,
16
17
  ScoreBreakdown,
@@ -33,6 +34,21 @@ const SEVERITY_SCORES: Record<string, number> = {
33
34
  suggestion: 0.4,
34
35
  };
35
36
 
37
+ // ─── Vector cosine similarity (dense float arrays) ────────────────
38
+
39
+ function vectorCosineSimilarity(a: number[], b: number[]): number {
40
+ if (a.length !== b.length || a.length === 0) return 0;
41
+ let dot = 0,
42
+ normA = 0,
43
+ normB = 0;
44
+ for (let i = 0; i < a.length; i++) {
45
+ dot += a[i] * b[i];
46
+ normA += a[i] * a[i];
47
+ normB += b[i] * b[i];
48
+ }
49
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB) || 1);
50
+ }
51
+
36
52
  // ─── Brain Class ─────────────────────────────────────────────────
37
53
 
38
54
  const DEFAULT_WEIGHTS: ScoringWeights = {
@@ -44,6 +60,16 @@ const DEFAULT_WEIGHTS: ScoringWeights = {
44
60
  domainMatch: 0.15,
45
61
  };
46
62
 
63
+ /** Weights used when an embedding provider is active — vector gets 0.15, semantic drops to 0.25. */
64
+ const DEFAULT_WEIGHTS_HYBRID: ScoringWeights = {
65
+ semantic: 0.25,
66
+ vector: 0.15,
67
+ severity: 0.15,
68
+ temporalDecay: 0.15,
69
+ tagOverlap: 0.15,
70
+ domainMatch: 0.15,
71
+ };
72
+
47
73
  const WEIGHT_BOUND = 0.15;
48
74
  const FEEDBACK_THRESHOLD = 30;
49
75
  const DUPLICATE_BLOCK_THRESHOLD = 0.8;
@@ -53,16 +79,24 @@ const RECENCY_HALF_LIFE_DAYS = 365;
53
79
  export class Brain {
54
80
  private vault: Vault;
55
81
  private vaultManager: VaultManager | undefined;
82
+ private embeddingProvider: EmbeddingProvider | undefined;
56
83
  private vocabulary: Map<string, number> = new Map();
57
84
  private weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
58
85
 
59
- constructor(vault: Vault, vaultManager?: VaultManager) {
86
+ constructor(vault: Vault, vaultManager?: VaultManager, embeddingProvider?: EmbeddingProvider) {
60
87
  this.vault = vault;
61
88
  this.vaultManager = vaultManager;
89
+ this.embeddingProvider = embeddingProvider;
62
90
  this.loadVocabularyFromDb();
63
91
  this.recomputeWeights();
64
92
  }
65
93
 
94
+ /** Set or replace the embedding provider at runtime. */
95
+ setEmbeddingProvider(provider: EmbeddingProvider | undefined): void {
96
+ this.embeddingProvider = provider;
97
+ this.recomputeWeights();
98
+ }
99
+
66
100
  async intelligentSearch(query: string, options?: SearchOptions): Promise<RankedResult[]> {
67
101
  const limit = options?.limit ?? 10;
68
102
  const fetchLimit = Math.max(limit * 3, 30);
@@ -91,9 +125,8 @@ export class Brain {
91
125
  });
92
126
  }
93
127
 
94
- if (rawResults.length === 0) return [];
128
+ if (rawResults.length === 0 && !this.embeddingProvider) return [];
95
129
 
96
- const seedCount = rawResults.length;
97
130
  const queryTokens = tokenize(query);
98
131
  const queryTags = options?.tags ?? [];
99
132
  const queryDomain = options?.domain;
@@ -105,9 +138,53 @@ export class Brain {
105
138
  ? calculateTfIdf(queryTokens, this.vocabulary)
106
139
  : null;
107
140
 
141
+ // ── Vector recall: embed query and merge cosineSearch candidates ──
142
+ let queryEmbedding: number[] | null = null;
143
+ const vectorSimilarityMap = new Map<string, number>();
144
+
145
+ if (this.embeddingProvider) {
146
+ try {
147
+ const embResult = await this.embeddingProvider.embed([query]);
148
+ if (embResult.vectors.length > 0 && embResult.vectors[0].length > 0) {
149
+ queryEmbedding = embResult.vectors[0];
150
+ const provider = this.vault.getProvider();
151
+ const vectorHits = cosineSearch(provider, queryEmbedding, fetchLimit);
152
+
153
+ // Build similarity lookup and merge vector-only candidates into rawResults
154
+ const ftsIds = new Set(rawResults.map((r) => r.entry.id));
155
+ for (const hit of vectorHits) {
156
+ vectorSimilarityMap.set(hit.entryId, hit.similarity);
157
+ if (!ftsIds.has(hit.entryId)) {
158
+ // Vector-only candidate — fetch full entry and add to pool
159
+ const entry = this.vault.get(hit.entryId);
160
+ if (entry) {
161
+ rawResults.push({ entry, score: hit.similarity });
162
+ }
163
+ }
164
+ }
165
+ }
166
+ } catch {
167
+ // Embedding failed — graceful degradation, continue with FTS-only
168
+ }
169
+ }
170
+
171
+ if (rawResults.length === 0) return [];
172
+
173
+ const seedCount = rawResults.length;
174
+
108
175
  const ranked = rawResults.map((result) => {
109
176
  const entry = result.entry;
110
- const breakdown = this.scoreEntry(entry, queryTokens, queryTags, queryDomain, now, queryVec);
177
+ const vectorSim = vectorSimilarityMap.get(entry.id) ?? null;
178
+ const breakdown = this.scoreEntry(
179
+ entry,
180
+ queryTokens,
181
+ queryTags,
182
+ queryDomain,
183
+ now,
184
+ queryVec,
185
+ queryEmbedding,
186
+ vectorSim,
187
+ );
111
188
  return { entry, score: breakdown.total, breakdown };
112
189
  });
113
190
 
@@ -499,6 +576,8 @@ export class Brain {
499
576
  queryDomain: string | undefined,
500
577
  now: number,
501
578
  queryVec: Map<string, number> | null = null,
579
+ queryEmbedding: number[] | null = null,
580
+ precomputedVectorSim: number | null = null,
502
581
  ): ScoreBreakdown {
503
582
  const w = this.weights;
504
583
 
@@ -523,7 +602,22 @@ export class Brain {
523
602
 
524
603
  const domainMatch = queryDomain && entry.domain === queryDomain ? 1.0 : 0;
525
604
 
526
- const vector = 0;
605
+ // Use precomputed cosine similarity from the vector recall phase when available.
606
+ // If we have a query embedding but no precomputed similarity (entry wasn't in
607
+ // cosineSearch results), try to compute it from the entry's stored vector.
608
+ let vector = 0;
609
+ if (precomputedVectorSim !== null) {
610
+ vector = precomputedVectorSim;
611
+ } else if (queryEmbedding) {
612
+ try {
613
+ const stored = getVector(this.vault.getProvider(), entry.id);
614
+ if (stored) {
615
+ vector = vectorCosineSimilarity(queryEmbedding, stored.vector);
616
+ }
617
+ } catch {
618
+ // No stored vector — vector stays 0
619
+ }
620
+ }
527
621
 
528
622
  const total =
529
623
  w.semantic * semantic +
@@ -681,7 +775,9 @@ export class Brain {
681
775
  }
682
776
  ).count;
683
777
  if (feedbackCount < FEEDBACK_THRESHOLD) {
684
- this.weights = { ...DEFAULT_WEIGHTS };
778
+ this.weights = this.embeddingProvider
779
+ ? { ...DEFAULT_WEIGHTS_HYBRID }
780
+ : { ...DEFAULT_WEIGHTS };
685
781
  return;
686
782
  }
687
783
 
@@ -707,8 +803,20 @@ export class Brain {
707
803
  DEFAULT_WEIGHTS.semantic + WEIGHT_BOUND,
708
804
  );
709
805
 
710
- // vector stays 0 in base weights (only active during hybrid search)
711
- newWeights.vector = 0;
806
+ // When no embedding provider is configured, vector weight stays 0.
807
+ // When provider IS available, vector participates in weight adaptation.
808
+ if (!this.embeddingProvider) {
809
+ newWeights.vector = 0;
810
+ } else {
811
+ // With embeddings active, give vector a meaningful default weight
812
+ // by redistributing from semantic (the closest signal).
813
+ newWeights.vector = DEFAULT_WEIGHTS_HYBRID.vector;
814
+ newWeights.semantic = clamp(
815
+ newWeights.semantic - DEFAULT_WEIGHTS_HYBRID.vector,
816
+ DEFAULT_WEIGHTS.semantic - WEIGHT_BOUND,
817
+ DEFAULT_WEIGHTS.semantic + WEIGHT_BOUND,
818
+ );
819
+ }
712
820
 
713
821
  const remaining = 1.0 - newWeights.semantic - newWeights.vector;
714
822
  const otherSum =