@soleri/core 9.4.0 → 9.6.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 (228) hide show
  1. package/dist/adapters/claude-code-adapter.d.ts +27 -0
  2. package/dist/adapters/claude-code-adapter.d.ts.map +1 -0
  3. package/dist/adapters/claude-code-adapter.js +111 -0
  4. package/dist/adapters/claude-code-adapter.js.map +1 -0
  5. package/dist/adapters/index.d.ts +9 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +10 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/registry.d.ts +21 -0
  10. package/dist/adapters/registry.d.ts.map +1 -0
  11. package/dist/adapters/registry.js +44 -0
  12. package/dist/adapters/registry.js.map +1 -0
  13. package/dist/adapters/types.d.ts +93 -0
  14. package/dist/adapters/types.d.ts.map +1 -0
  15. package/dist/adapters/types.js +10 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/brain/brain.d.ts +12 -1
  18. package/dist/brain/brain.d.ts.map +1 -1
  19. package/dist/brain/brain.js +106 -44
  20. package/dist/brain/brain.js.map +1 -1
  21. package/dist/brain/intelligence.d.ts.map +1 -1
  22. package/dist/brain/intelligence.js +36 -30
  23. package/dist/brain/intelligence.js.map +1 -1
  24. package/dist/chat/agent-loop.js +1 -1
  25. package/dist/chat/agent-loop.js.map +1 -1
  26. package/dist/chat/notifications.d.ts.map +1 -1
  27. package/dist/chat/notifications.js +4 -0
  28. package/dist/chat/notifications.js.map +1 -1
  29. package/dist/control/intent-router.d.ts +1 -0
  30. package/dist/control/intent-router.d.ts.map +1 -1
  31. package/dist/control/intent-router.js +11 -5
  32. package/dist/control/intent-router.js.map +1 -1
  33. package/dist/curator/curator.d.ts +4 -0
  34. package/dist/curator/curator.d.ts.map +1 -1
  35. package/dist/curator/curator.js +141 -27
  36. package/dist/curator/curator.js.map +1 -1
  37. package/dist/hooks/candidate-scorer.d.ts +28 -0
  38. package/dist/hooks/candidate-scorer.d.ts.map +1 -0
  39. package/dist/hooks/candidate-scorer.js +20 -0
  40. package/dist/hooks/candidate-scorer.js.map +1 -0
  41. package/dist/hooks/index.d.ts +2 -0
  42. package/dist/hooks/index.d.ts.map +1 -0
  43. package/dist/hooks/index.js +2 -0
  44. package/dist/hooks/index.js.map +1 -0
  45. package/dist/index.d.ts +14 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +12 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/llm/llm-client.d.ts.map +1 -1
  50. package/dist/llm/llm-client.js +1 -0
  51. package/dist/llm/llm-client.js.map +1 -1
  52. package/dist/packs/index.d.ts +3 -2
  53. package/dist/packs/index.d.ts.map +1 -1
  54. package/dist/packs/index.js +3 -2
  55. package/dist/packs/index.js.map +1 -1
  56. package/dist/packs/lockfile.d.ts +23 -1
  57. package/dist/packs/lockfile.d.ts.map +1 -1
  58. package/dist/packs/lockfile.js +50 -4
  59. package/dist/packs/lockfile.js.map +1 -1
  60. package/dist/packs/pack-installer.d.ts +10 -0
  61. package/dist/packs/pack-installer.d.ts.map +1 -1
  62. package/dist/packs/pack-installer.js +69 -2
  63. package/dist/packs/pack-installer.js.map +1 -1
  64. package/dist/packs/pack-lifecycle.d.ts +50 -0
  65. package/dist/packs/pack-lifecycle.d.ts.map +1 -0
  66. package/dist/packs/pack-lifecycle.js +91 -0
  67. package/dist/packs/pack-lifecycle.js.map +1 -0
  68. package/dist/packs/types.d.ts +64 -44
  69. package/dist/packs/types.d.ts.map +1 -1
  70. package/dist/packs/types.js +9 -0
  71. package/dist/packs/types.js.map +1 -1
  72. package/dist/persistence/sqlite-provider.d.ts +5 -1
  73. package/dist/persistence/sqlite-provider.d.ts.map +1 -1
  74. package/dist/persistence/sqlite-provider.js +22 -2
  75. package/dist/persistence/sqlite-provider.js.map +1 -1
  76. package/dist/planning/github-projection.d.ts +8 -8
  77. package/dist/planning/github-projection.d.ts.map +1 -1
  78. package/dist/planning/github-projection.js +42 -42
  79. package/dist/planning/github-projection.js.map +1 -1
  80. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  81. package/dist/planning/plan-lifecycle.js +6 -1
  82. package/dist/planning/plan-lifecycle.js.map +1 -1
  83. package/dist/plugins/types.d.ts +21 -21
  84. package/dist/queue/pipeline-runner.d.ts.map +1 -1
  85. package/dist/queue/pipeline-runner.js +4 -0
  86. package/dist/queue/pipeline-runner.js.map +1 -1
  87. package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
  88. package/dist/runtime/curator-extra-ops.js +9 -1
  89. package/dist/runtime/curator-extra-ops.js.map +1 -1
  90. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  91. package/dist/runtime/facades/memory-facade.js +169 -0
  92. package/dist/runtime/facades/memory-facade.js.map +1 -1
  93. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  94. package/dist/runtime/orchestrate-ops.js +133 -4
  95. package/dist/runtime/orchestrate-ops.js.map +1 -1
  96. package/dist/runtime/runtime.d.ts.map +1 -1
  97. package/dist/runtime/runtime.js +128 -90
  98. package/dist/runtime/runtime.js.map +1 -1
  99. package/dist/runtime/session-briefing.d.ts.map +1 -1
  100. package/dist/runtime/session-briefing.js +44 -11
  101. package/dist/runtime/session-briefing.js.map +1 -1
  102. package/dist/runtime/shutdown-registry.d.ts +36 -0
  103. package/dist/runtime/shutdown-registry.d.ts.map +1 -0
  104. package/dist/runtime/shutdown-registry.js +74 -0
  105. package/dist/runtime/shutdown-registry.js.map +1 -0
  106. package/dist/runtime/types.d.ts +10 -1
  107. package/dist/runtime/types.d.ts.map +1 -1
  108. package/dist/subagent/concurrency-manager.d.ts +29 -0
  109. package/dist/subagent/concurrency-manager.d.ts.map +1 -0
  110. package/dist/subagent/concurrency-manager.js +73 -0
  111. package/dist/subagent/concurrency-manager.js.map +1 -0
  112. package/dist/subagent/dispatcher.d.ts +41 -0
  113. package/dist/subagent/dispatcher.d.ts.map +1 -0
  114. package/dist/subagent/dispatcher.js +259 -0
  115. package/dist/subagent/dispatcher.js.map +1 -0
  116. package/dist/subagent/index.d.ts +14 -0
  117. package/dist/subagent/index.d.ts.map +1 -0
  118. package/dist/subagent/index.js +15 -0
  119. package/dist/subagent/index.js.map +1 -0
  120. package/dist/subagent/orphan-reaper.d.ts +37 -0
  121. package/dist/subagent/orphan-reaper.d.ts.map +1 -0
  122. package/dist/subagent/orphan-reaper.js +71 -0
  123. package/dist/subagent/orphan-reaper.js.map +1 -0
  124. package/dist/subagent/result-aggregator.d.ts +7 -0
  125. package/dist/subagent/result-aggregator.d.ts.map +1 -0
  126. package/dist/subagent/result-aggregator.js +57 -0
  127. package/dist/subagent/result-aggregator.js.map +1 -0
  128. package/dist/subagent/task-checkout.d.ts +36 -0
  129. package/dist/subagent/task-checkout.d.ts.map +1 -0
  130. package/dist/subagent/task-checkout.js +52 -0
  131. package/dist/subagent/task-checkout.js.map +1 -0
  132. package/dist/subagent/types.d.ts +114 -0
  133. package/dist/subagent/types.d.ts.map +1 -0
  134. package/dist/subagent/types.js +9 -0
  135. package/dist/subagent/types.js.map +1 -0
  136. package/dist/subagent/workspace-resolver.d.ts +35 -0
  137. package/dist/subagent/workspace-resolver.d.ts.map +1 -0
  138. package/dist/subagent/workspace-resolver.js +99 -0
  139. package/dist/subagent/workspace-resolver.js.map +1 -0
  140. package/dist/transport/http-server.d.ts.map +1 -1
  141. package/dist/transport/http-server.js +49 -3
  142. package/dist/transport/http-server.js.map +1 -1
  143. package/dist/transport/ws-server.d.ts.map +1 -1
  144. package/dist/transport/ws-server.js +7 -0
  145. package/dist/transport/ws-server.js.map +1 -1
  146. package/dist/vault/linking.d.ts +3 -4
  147. package/dist/vault/linking.d.ts.map +1 -1
  148. package/dist/vault/linking.js +79 -32
  149. package/dist/vault/linking.js.map +1 -1
  150. package/dist/vault/vault-maintenance.d.ts.map +1 -1
  151. package/dist/vault/vault-maintenance.js +7 -14
  152. package/dist/vault/vault-maintenance.js.map +1 -1
  153. package/dist/vault/vault-memories.d.ts.map +1 -1
  154. package/dist/vault/vault-memories.js +19 -9
  155. package/dist/vault/vault-memories.js.map +1 -1
  156. package/dist/vault/vault-schema.d.ts +1 -0
  157. package/dist/vault/vault-schema.d.ts.map +1 -1
  158. package/dist/vault/vault-schema.js +20 -0
  159. package/dist/vault/vault-schema.js.map +1 -1
  160. package/dist/vault/vault.d.ts.map +1 -1
  161. package/dist/vault/vault.js +7 -3
  162. package/dist/vault/vault.js.map +1 -1
  163. package/package.json +8 -2
  164. package/src/__tests__/adapters/claude-code-adapter.test.ts +167 -0
  165. package/src/__tests__/adapters/registry.test.ts +100 -0
  166. package/src/__tests__/packs/pack-lifecycle.test.ts +379 -0
  167. package/src/__tests__/subagent/concurrency-manager.test.ts +132 -0
  168. package/src/__tests__/subagent/dispatcher.test.ts +195 -0
  169. package/src/__tests__/subagent/orphan-reaper.test.ts +141 -0
  170. package/src/__tests__/subagent/result-aggregator.test.ts +141 -0
  171. package/src/__tests__/subagent/task-checkout.test.ts +86 -0
  172. package/src/__tests__/subagent/workspace-resolver.test.ts +138 -0
  173. package/src/adapters/claude-code-adapter.ts +163 -0
  174. package/src/adapters/index.ts +22 -0
  175. package/src/adapters/registry.ts +53 -0
  176. package/src/adapters/types.ts +114 -0
  177. package/src/brain/brain.ts +120 -46
  178. package/src/brain/intelligence.ts +42 -34
  179. package/src/chat/agent-loop.ts +1 -1
  180. package/src/chat/notifications.ts +4 -0
  181. package/src/control/intent-router.ts +10 -8
  182. package/src/curator/curator.ts +146 -29
  183. package/src/hooks/candidate-scorer.test.ts +76 -0
  184. package/src/hooks/candidate-scorer.ts +39 -0
  185. package/src/index.ts +40 -1
  186. package/src/llm/llm-client.ts +1 -0
  187. package/src/packs/index.ts +5 -1
  188. package/src/packs/lockfile.ts +70 -5
  189. package/src/packs/pack-installer.ts +78 -2
  190. package/src/packs/pack-lifecycle.ts +115 -0
  191. package/src/packs/pack-lockfile.test.ts +1 -1
  192. package/src/packs/pack-system.test.ts +1 -1
  193. package/src/packs/types.ts +40 -2
  194. package/src/persistence/sqlite-provider.ts +27 -2
  195. package/src/planning/github-projection.ts +48 -44
  196. package/src/planning/plan-lifecycle.ts +14 -1
  197. package/src/queue/pipeline-runner.ts +4 -0
  198. package/src/runtime/admin-setup-ops.test.ts +9 -4
  199. package/src/runtime/curator-extra-ops.test.ts +7 -0
  200. package/src/runtime/curator-extra-ops.ts +10 -1
  201. package/src/runtime/facades/curator-facade.test.ts +7 -0
  202. package/src/runtime/facades/memory-facade.ts +187 -0
  203. package/src/runtime/orchestrate-ops.ts +156 -4
  204. package/src/runtime/runtime.test.ts +50 -2
  205. package/src/runtime/runtime.ts +132 -89
  206. package/src/runtime/session-briefing.test.ts +94 -2
  207. package/src/runtime/session-briefing.ts +48 -12
  208. package/src/runtime/shutdown-registry.test.ts +151 -0
  209. package/src/runtime/shutdown-registry.ts +85 -0
  210. package/src/runtime/types.ts +10 -1
  211. package/src/subagent/concurrency-manager.ts +89 -0
  212. package/src/subagent/dispatcher.ts +326 -0
  213. package/src/subagent/index.ts +28 -0
  214. package/src/subagent/orphan-reaper.ts +82 -0
  215. package/src/subagent/result-aggregator.ts +66 -0
  216. package/src/subagent/task-checkout.ts +60 -0
  217. package/src/subagent/types.ts +138 -0
  218. package/src/subagent/workspace-resolver.ts +117 -0
  219. package/src/transport/http-server.ts +50 -3
  220. package/src/transport/ws-server.ts +8 -0
  221. package/src/vault/linking.test.ts +12 -0
  222. package/src/vault/linking.ts +90 -44
  223. package/src/vault/vault-maintenance.ts +11 -18
  224. package/src/vault/vault-memories.ts +21 -13
  225. package/src/vault/vault-scaling.test.ts +3 -2
  226. package/src/vault/vault-schema.ts +21 -0
  227. package/src/vault/vault.ts +8 -3
  228. package/vitest.config.ts +2 -0
@@ -60,6 +60,10 @@ import { loadPersona } from '../persona/loader.js';
60
60
  import { generatePersonaInstructions } from '../persona/prompt-generator.js';
61
61
  import { OperatorProfileStore } from '../operator/operator-profile.js';
62
62
  import { ContextHealthMonitor } from './context-health.js';
63
+ import { ShutdownRegistry } from './shutdown-registry.js';
64
+ import { RuntimeAdapterRegistry } from '../adapters/registry.js';
65
+ import { ClaudeCodeRuntimeAdapter } from '../adapters/claude-code-adapter.js';
66
+ import { SubagentDispatcher } from '../subagent/dispatcher.js';
63
67
 
64
68
  /**
65
69
  * Create a fully initialized agent runtime.
@@ -221,6 +225,127 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
221
225
  learningRadar.setOperatorProfile(operatorProfile);
222
226
  brainIntelligence.setOperatorProfile(operatorProfile);
223
227
 
228
+ // ─── Shutdown Registry ────────────────────────────────────────────
229
+ const shutdownRegistry = new ShutdownRegistry();
230
+
231
+ // Build pipeline runner before the runtime object so we can reference it
232
+ const pipelineRunner = (() => {
233
+ const jq = new JobQueue(vault.getProvider());
234
+ const pr = new PipelineRunner(jq);
235
+ // Register default job handlers for curator pipeline
236
+ pr.registerHandler('tag-normalize', async (job) => {
237
+ const entry = vault.get(job.entryId ?? '');
238
+ if (!entry) return { skipped: true, reason: 'entry not found' };
239
+ const result = curator.normalizeTag(entry.tags[0] ?? '');
240
+ return result;
241
+ });
242
+ pr.registerHandler('dedup-check', async (job) => {
243
+ const entry = vault.get(job.entryId ?? '');
244
+ if (!entry) return { skipped: true, reason: 'entry not found' };
245
+ return curator.detectDuplicates(entry.id);
246
+ });
247
+ pr.registerHandler('auto-link', async (job) => {
248
+ if (linkManager) {
249
+ const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
250
+ for (const s of suggestions) {
251
+ linkManager.addLink(
252
+ job.entryId ?? '',
253
+ s.entryId,
254
+ s.suggestedType,
255
+ `pipeline: ${s.reason}`,
256
+ );
257
+ }
258
+ return { linked: suggestions.length };
259
+ }
260
+ return { skipped: true, reason: 'link manager not available' };
261
+ });
262
+ pr.registerHandler('quality-gate', async (job) => {
263
+ const entry = vault.get(job.entryId ?? '');
264
+ if (!entry) return { skipped: true, reason: 'entry not found' };
265
+ return evaluateQuality(entry, llmClient);
266
+ });
267
+ pr.registerHandler('classify', async (job) => {
268
+ const entry = vault.get(job.entryId ?? '');
269
+ if (!entry) return { skipped: true, reason: 'entry not found' };
270
+ return classifyEntry(entry, llmClient);
271
+ });
272
+
273
+ // ─── 9 additional handlers for full Salvador parity (#216) ────
274
+ pr.registerHandler('enrich-frontmatter', async (job) => {
275
+ const entry = vault.get(job.entryId ?? '');
276
+ if (!entry) return { skipped: true, reason: 'entry not found' };
277
+ return curator.enrichMetadata(entry.id);
278
+ });
279
+ pr.registerHandler('detect-staleness', async (job) => {
280
+ const entry = vault.get(job.entryId ?? '');
281
+ if (!entry) return { skipped: true, reason: 'entry not found' };
282
+ // Check if entry is older than 90 days (using validFrom or fallback to 0)
283
+ const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
284
+ const ageMs = Date.now() - entryTimestamp;
285
+ const staleDays = 90;
286
+ const isStale = ageMs > staleDays * 86400000;
287
+ return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
288
+ });
289
+ pr.registerHandler('detect-duplicate', async (job) => {
290
+ const entry = vault.get(job.entryId ?? '');
291
+ if (!entry) return { skipped: true, reason: 'entry not found' };
292
+ return curator.detectDuplicates(entry.id);
293
+ });
294
+ pr.registerHandler('detect-contradiction', async (job) => {
295
+ const entry = vault.get(job.entryId ?? '');
296
+ if (!entry) return { skipped: true, reason: 'entry not found' };
297
+ const contradictions = curator.detectContradictions(0.4);
298
+ const relevant = contradictions.filter(
299
+ (c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
300
+ );
301
+ return { found: relevant.length, contradictions: relevant };
302
+ });
303
+ pr.registerHandler('consolidate-duplicates', async (_job) => {
304
+ return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
305
+ });
306
+ pr.registerHandler('archive-stale', async (_job) => {
307
+ // Run consolidation with stale detection
308
+ const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
309
+ return { archived: result.staleEntries.length, result };
310
+ });
311
+ pr.registerHandler('verify-searchable', async (job) => {
312
+ const entry = vault.get(job.entryId ?? '');
313
+ if (!entry) return { skipped: true, reason: 'entry not found' };
314
+ const searchResults = vault.search(entry.title, { limit: 1 });
315
+ const found = searchResults.some((r) => r.entry.id === entry.id);
316
+ return { searchable: found, entryId: entry.id };
317
+ });
318
+ return pr;
319
+ })();
320
+
321
+ // ─── Register cleanup callbacks (LIFO: first registered = last closed) ──
322
+ // Vault manager closes last (other modules may flush to vault during close)
323
+ shutdownRegistry.register('vaultManager', () => vaultManager.close());
324
+ // Pipeline runner — clear its polling interval
325
+ shutdownRegistry.register('pipelineRunner', () => pipelineRunner.stop());
326
+ // Agency manager — close FSWatchers and debounce timers
327
+ shutdownRegistry.register('agencyManager', () => agencyManager.disable());
328
+
329
+ shutdownRegistry.register('subagentDispatcher', () => subagentDispatcher.cleanup());
330
+ // Loop manager — clear accumulated state
331
+ shutdownRegistry.register('loopManager', () => {
332
+ if (loop.isActive()) {
333
+ try {
334
+ loop.cancelLoop();
335
+ } catch {
336
+ // Loop may already be inactive
337
+ }
338
+ }
339
+ });
340
+
341
+ // Runtime Adapter Registry — dispatch work to different AI CLIs
342
+ const adapterRegistry = new RuntimeAdapterRegistry();
343
+ adapterRegistry.register('claude-code', new ClaudeCodeRuntimeAdapter());
344
+ adapterRegistry.setDefault('claude-code');
345
+
346
+ // Subagent Dispatcher — spawn and manage child agent processes
347
+ const subagentDispatcher = new SubagentDispatcher({ adapterRegistry });
348
+
224
349
  return {
225
350
  config,
226
351
  logger,
@@ -256,94 +381,7 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
256
381
  knowledgeSynthesizer: new KnowledgeSynthesizer(brain, llmClient),
257
382
  chainRunner: new ChainRunner(vault.getProvider()),
258
383
  jobQueue: new JobQueue(vault.getProvider()),
259
- pipelineRunner: (() => {
260
- const jq = new JobQueue(vault.getProvider());
261
- const pr = new PipelineRunner(jq);
262
- // Register default job handlers for curator pipeline
263
- pr.registerHandler('tag-normalize', async (job) => {
264
- const entry = vault.get(job.entryId ?? '');
265
- if (!entry) return { skipped: true, reason: 'entry not found' };
266
- const result = curator.normalizeTag(entry.tags[0] ?? '');
267
- return result;
268
- });
269
- pr.registerHandler('dedup-check', async (job) => {
270
- const entry = vault.get(job.entryId ?? '');
271
- if (!entry) return { skipped: true, reason: 'entry not found' };
272
- return curator.detectDuplicates(entry.id);
273
- });
274
- pr.registerHandler('auto-link', async (job) => {
275
- if (linkManager) {
276
- const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
277
- for (const s of suggestions) {
278
- linkManager.addLink(
279
- job.entryId ?? '',
280
- s.entryId,
281
- s.suggestedType,
282
- `pipeline: ${s.reason}`,
283
- );
284
- }
285
- return { linked: suggestions.length };
286
- }
287
- return { skipped: true, reason: 'link manager not available' };
288
- });
289
- pr.registerHandler('quality-gate', async (job) => {
290
- const entry = vault.get(job.entryId ?? '');
291
- if (!entry) return { skipped: true, reason: 'entry not found' };
292
- return evaluateQuality(entry, llmClient);
293
- });
294
- pr.registerHandler('classify', async (job) => {
295
- const entry = vault.get(job.entryId ?? '');
296
- if (!entry) return { skipped: true, reason: 'entry not found' };
297
- return classifyEntry(entry, llmClient);
298
- });
299
-
300
- // ─── 9 additional handlers for full Salvador parity (#216) ────
301
- pr.registerHandler('enrich-frontmatter', async (job) => {
302
- const entry = vault.get(job.entryId ?? '');
303
- if (!entry) return { skipped: true, reason: 'entry not found' };
304
- return curator.enrichMetadata(entry.id);
305
- });
306
- pr.registerHandler('detect-staleness', async (job) => {
307
- const entry = vault.get(job.entryId ?? '');
308
- if (!entry) return { skipped: true, reason: 'entry not found' };
309
- // Check if entry is older than 90 days (using validFrom or fallback to 0)
310
- const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
311
- const ageMs = Date.now() - entryTimestamp;
312
- const staleDays = 90;
313
- const isStale = ageMs > staleDays * 86400000;
314
- return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
315
- });
316
- pr.registerHandler('detect-duplicate', async (job) => {
317
- const entry = vault.get(job.entryId ?? '');
318
- if (!entry) return { skipped: true, reason: 'entry not found' };
319
- return curator.detectDuplicates(entry.id);
320
- });
321
- pr.registerHandler('detect-contradiction', async (job) => {
322
- const entry = vault.get(job.entryId ?? '');
323
- if (!entry) return { skipped: true, reason: 'entry not found' };
324
- const contradictions = curator.detectContradictions(0.4);
325
- const relevant = contradictions.filter(
326
- (c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
327
- );
328
- return { found: relevant.length, contradictions: relevant };
329
- });
330
- pr.registerHandler('consolidate-duplicates', async (_job) => {
331
- return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
332
- });
333
- pr.registerHandler('archive-stale', async (_job) => {
334
- // Run consolidation with stale detection
335
- const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
336
- return { archived: result.staleEntries.length, result };
337
- });
338
- pr.registerHandler('verify-searchable', async (job) => {
339
- const entry = vault.get(job.entryId ?? '');
340
- if (!entry) return { skipped: true, reason: 'entry not found' };
341
- const searchResults = vault.search(entry.title, { limit: 1 });
342
- const found = searchResults.some((r) => r.entry.id === entry.id);
343
- return { searchable: found, entryId: entry.id };
344
- });
345
- return pr;
346
- })(),
384
+ pipelineRunner,
347
385
  operatorProfile,
348
386
  persona: (() => {
349
387
  const p = loadPersona(agentId, config.persona ?? undefined);
@@ -354,10 +392,15 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
354
392
  const p = loadPersona(agentId, config.persona ?? undefined);
355
393
  return generatePersonaInstructions(p);
356
394
  })(),
395
+ adapterRegistry,
396
+ subagentDispatcher,
357
397
  contextHealth: new ContextHealthMonitor(),
398
+ shutdownRegistry,
358
399
  createdAt: Date.now(),
359
400
  close: () => {
360
- vaultManager.close();
401
+ // Synchronous close — runs all registered callbacks in LIFO order,
402
+ // then closes the vault (registered first, so runs last).
403
+ shutdownRegistry.closeAllSync();
361
404
  },
362
405
  };
363
406
  }
@@ -17,6 +17,14 @@ function makeRuntime(overrides?: {
17
17
  toolsUsed: string[];
18
18
  filesModified: string[];
19
19
  }>;
20
+ memories?: Array<{
21
+ id?: string;
22
+ createdAt: number;
23
+ projectPath?: string;
24
+ summary?: string;
25
+ context?: string;
26
+ type?: string;
27
+ }>;
20
28
  plans?: Array<{
21
29
  id: string;
22
30
  status: string;
@@ -45,6 +53,7 @@ function makeRuntime(overrides?: {
45
53
  vault: {
46
54
  stats: () => o.vaultStats ?? { totalEntries: 50, byType: { playbook: 5 } },
47
55
  getRecent: (_n: number) => o.recentEntries ?? [],
56
+ listMemories: () => o.memories ?? [],
48
57
  },
49
58
  curator: {
50
59
  healthAudit: () => ({
@@ -95,11 +104,34 @@ describe('session-briefing', () => {
95
104
  expect(data.sections.find((s) => s.label === 'Welcome')).toBeUndefined();
96
105
  });
97
106
 
98
- it('includes Last session section', async () => {
107
+ it('includes Last session from cross-project memories when fresh', async () => {
108
+ const runtime = makeRuntime({
109
+ memories: [
110
+ {
111
+ id: 'mem-1',
112
+ createdAt: Date.now() - 600_000, // 10 min ago (ms)
113
+ projectPath: '/Users/me/projects/other-app',
114
+ summary: 'Fixed KPI card layout in the dashboard',
115
+ type: 'session',
116
+ },
117
+ ],
118
+ });
119
+ const ops = captureOps(createSessionBriefingOps(runtime));
120
+ const res = await executeOp(ops, 'session_briefing', {});
121
+
122
+ const data = res.data as { sections: Array<{ label: string; content: string }> };
123
+ const session = data.sections.find((s) => s.label === 'Last session');
124
+ expect(session).toBeDefined();
125
+ expect(session!.content).toContain('other-app');
126
+ expect(session!.content).toContain('Fixed KPI card layout');
127
+ });
128
+
129
+ it('falls back to brain sessions when no fresh memories exist', async () => {
99
130
  const runtime = makeRuntime({
131
+ memories: [], // no memories
100
132
  sessions: [
101
133
  {
102
- endedAt: new Date(Date.now() - 3600000).toISOString(),
134
+ endedAt: new Date(Date.now() - 3600_000).toISOString(), // 1h ago
103
135
  domain: 'frontend',
104
136
  context: 'Refactored button component',
105
137
  toolsUsed: ['vault_search', 'brain_recommend'],
@@ -117,6 +149,63 @@ describe('session-briefing', () => {
117
149
  expect(session!.content).toContain('Refactored button component');
118
150
  });
119
151
 
152
+ it('skips Last session when all sessions are stale', async () => {
153
+ const staleTs = Date.now() - 72 * 3600_000; // 72h ago — beyond default 48h
154
+ const runtime = makeRuntime({
155
+ memories: [
156
+ {
157
+ id: 'mem-old',
158
+ createdAt: staleTs,
159
+ projectPath: '/old-project',
160
+ summary: 'Ancient session',
161
+ type: 'session',
162
+ },
163
+ ],
164
+ sessions: [
165
+ {
166
+ endedAt: new Date(staleTs).toISOString(),
167
+ domain: 'old',
168
+ context: 'Ancient brain session',
169
+ toolsUsed: [],
170
+ filesModified: [],
171
+ },
172
+ ],
173
+ });
174
+ const ops = captureOps(createSessionBriefingOps(runtime));
175
+ const res = await executeOp(ops, 'session_briefing', {});
176
+
177
+ const data = res.data as { sections: Array<{ label: string; content: string }> };
178
+ const session = data.sections.find((s) => s.label === 'Last session');
179
+ expect(session).toBeUndefined();
180
+ });
181
+
182
+ it('respects custom recencyHours parameter', async () => {
183
+ const runtime = makeRuntime({
184
+ memories: [
185
+ {
186
+ id: 'mem-3h',
187
+ createdAt: Date.now() - 3 * 3600_000, // 3h ago
188
+ projectPath: '/recent-project',
189
+ summary: 'Recent work',
190
+ type: 'session',
191
+ },
192
+ ],
193
+ });
194
+ const ops = captureOps(createSessionBriefingOps(runtime));
195
+
196
+ // With 1h window — should skip
197
+ const narrow = await executeOp(ops, 'session_briefing', { recencyHours: 1 });
198
+ const narrowData = narrow.data as { sections: Array<{ label: string }> };
199
+ expect(narrowData.sections.find((s) => s.label === 'Last session')).toBeUndefined();
200
+
201
+ // With 4h window — should include
202
+ const wide = await executeOp(ops, 'session_briefing', { recencyHours: 4 });
203
+ const wideData = wide.data as { sections: Array<{ label: string; content: string }> };
204
+ const session = wideData.sections.find((s) => s.label === 'Last session');
205
+ expect(session).toBeDefined();
206
+ expect(session!.content).toContain('Recent work');
207
+ });
208
+
120
209
  it('includes Active plans section', async () => {
121
210
  const runtime = makeRuntime({
122
211
  plans: [
@@ -234,6 +323,9 @@ describe('session-briefing', () => {
234
323
  getRecent: () => {
235
324
  throw new Error('no vault');
236
325
  },
326
+ listMemories: () => {
327
+ throw new Error('no vault');
328
+ },
237
329
  },
238
330
  curator: {
239
331
  healthAudit: () => {
@@ -57,22 +57,58 @@ export function createSessionBriefingOps(runtime: AgentRuntime): OpDefinition[]
57
57
  // Vault stats unavailable — skip
58
58
  }
59
59
 
60
- // 1. Last session
60
+ // 1. Last session — cross-project, with staleness threshold
61
61
  try {
62
- const sessions = brainIntelligence.listSessions({ limit: 1, active: false });
63
- dataPoints += sessions.length;
64
- if (sessions.length > 0) {
65
- const last = sessions[0];
66
- const ago = formatTimeAgo(last.endedAt ? new Date(last.endedAt).getTime() : Date.now());
67
- const domain = last.domain ? ` [${last.domain}]` : '';
68
- const context = last.context ? `: ${last.context.slice(0, 80)}` : '';
69
- const tools = last.toolsUsed.length > 0 ? `, used ${last.toolsUsed.length} tools` : '';
70
- const files =
71
- last.filesModified.length > 0 ? `, modified ${last.filesModified.length} files` : '';
62
+ const recencyMs = (params.recencyHours as number) * 3600_000;
63
+ const cutoff = Date.now() - recencyMs;
64
+
65
+ // Try cross-project memories first (most recent work, any project)
66
+ const recentMemories = vault.listMemories({ type: 'session', limit: 3 });
67
+ const freshMemory = recentMemories.find((m) => {
68
+ const ts = m.createdAt > 1e12 ? m.createdAt : m.createdAt * 1000;
69
+ return ts > cutoff;
70
+ });
71
+
72
+ if (freshMemory) {
73
+ const ts =
74
+ freshMemory.createdAt > 1e12 ? freshMemory.createdAt : freshMemory.createdAt * 1000;
75
+ const ago = formatTimeAgo(ts);
76
+ const project = freshMemory.projectPath
77
+ ? ` [${freshMemory.projectPath.split('/').pop()}]`
78
+ : '';
79
+ const summary = freshMemory.summary
80
+ ? `: ${freshMemory.summary.slice(0, 100)}`
81
+ : freshMemory.context
82
+ ? `: ${freshMemory.context.slice(0, 100)}`
83
+ : '';
84
+ dataPoints += recentMemories.length;
72
85
  sections.push({
73
86
  label: 'Last session',
74
- content: `(${ago})${domain}${context}${tools}${files}`,
87
+ content: `(${ago})${project}${summary}`,
75
88
  });
89
+ } else {
90
+ // Fall back to brain sessions (same-project only) if no fresh memory
91
+ const sessions = brainIntelligence.listSessions({ limit: 1, active: false });
92
+ dataPoints += sessions.length;
93
+ if (sessions.length > 0) {
94
+ const last = sessions[0];
95
+ const endTs = last.endedAt ? new Date(last.endedAt).getTime() : 0;
96
+ if (endTs > cutoff) {
97
+ const ago = formatTimeAgo(endTs);
98
+ const domain = last.domain ? ` [${last.domain}]` : '';
99
+ const context = last.context ? `: ${last.context.slice(0, 80)}` : '';
100
+ const tools =
101
+ last.toolsUsed.length > 0 ? `, used ${last.toolsUsed.length} tools` : '';
102
+ const files =
103
+ last.filesModified.length > 0
104
+ ? `, modified ${last.filesModified.length} files`
105
+ : '';
106
+ sections.push({
107
+ label: 'Last session',
108
+ content: `(${ago})${domain}${context}${tools}${files}`,
109
+ });
110
+ }
111
+ }
76
112
  }
77
113
  } catch {
78
114
  // Session data unavailable — skip
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Unit tests for ShutdownRegistry — centralized cleanup for agent runtime.
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { ShutdownRegistry } from './shutdown-registry.js';
7
+
8
+ describe('ShutdownRegistry', () => {
9
+ it('starts with zero entries and not closed', () => {
10
+ const registry = new ShutdownRegistry();
11
+ expect(registry.size).toBe(0);
12
+ expect(registry.isClosed).toBe(false);
13
+ });
14
+
15
+ it('tracks registered entries', () => {
16
+ const registry = new ShutdownRegistry();
17
+ registry.register('a', vi.fn());
18
+ registry.register('b', vi.fn());
19
+ expect(registry.size).toBe(2);
20
+ });
21
+
22
+ it('calls callbacks in LIFO order on closeAll', async () => {
23
+ const order: string[] = [];
24
+ const registry = new ShutdownRegistry();
25
+ registry.register('first', () => {
26
+ order.push('first');
27
+ });
28
+ registry.register('second', () => {
29
+ order.push('second');
30
+ });
31
+ registry.register('third', () => {
32
+ order.push('third');
33
+ });
34
+
35
+ await registry.closeAll();
36
+ expect(order).toEqual(['third', 'second', 'first']);
37
+ });
38
+
39
+ it('calls callbacks in LIFO order on closeAllSync', () => {
40
+ const order: string[] = [];
41
+ const registry = new ShutdownRegistry();
42
+ registry.register('first', () => {
43
+ order.push('first');
44
+ });
45
+ registry.register('second', () => {
46
+ order.push('second');
47
+ });
48
+ registry.register('third', () => {
49
+ order.push('third');
50
+ });
51
+
52
+ registry.closeAllSync();
53
+ expect(order).toEqual(['third', 'second', 'first']);
54
+ });
55
+
56
+ it('is idempotent — second closeAll is a no-op', async () => {
57
+ const callback = vi.fn();
58
+ const registry = new ShutdownRegistry();
59
+ registry.register('test', callback);
60
+
61
+ await registry.closeAll();
62
+ await registry.closeAll();
63
+
64
+ expect(callback).toHaveBeenCalledTimes(1);
65
+ expect(registry.isClosed).toBe(true);
66
+ });
67
+
68
+ it('is idempotent — second closeAllSync is a no-op', () => {
69
+ const callback = vi.fn();
70
+ const registry = new ShutdownRegistry();
71
+ registry.register('test', callback);
72
+
73
+ registry.closeAllSync();
74
+ registry.closeAllSync();
75
+
76
+ expect(callback).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('handles async callbacks in closeAll', async () => {
80
+ const order: string[] = [];
81
+ const registry = new ShutdownRegistry();
82
+ registry.register('sync', () => {
83
+ order.push('sync');
84
+ });
85
+ registry.register('async', async () => {
86
+ await new Promise((r) => setTimeout(r, 5));
87
+ order.push('async');
88
+ });
89
+
90
+ await registry.closeAll();
91
+ expect(order).toEqual(['async', 'sync']);
92
+ });
93
+
94
+ it('continues on error — remaining callbacks still execute', async () => {
95
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
96
+ const order: string[] = [];
97
+ const registry = new ShutdownRegistry();
98
+ registry.register('first', () => {
99
+ order.push('first');
100
+ });
101
+ registry.register('failing', () => {
102
+ throw new Error('boom');
103
+ });
104
+ registry.register('third', () => {
105
+ order.push('third');
106
+ });
107
+
108
+ await registry.closeAll();
109
+
110
+ // third runs first (LIFO), failing throws but first still runs
111
+ expect(order).toEqual(['third', 'first']);
112
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('boom'));
113
+ stderrSpy.mockRestore();
114
+ });
115
+
116
+ it('closeAllSync swallows errors silently', () => {
117
+ const order: string[] = [];
118
+ const registry = new ShutdownRegistry();
119
+ registry.register('first', () => {
120
+ order.push('first');
121
+ });
122
+ registry.register('failing', () => {
123
+ throw new Error('boom');
124
+ });
125
+ registry.register('third', () => {
126
+ order.push('third');
127
+ });
128
+
129
+ // Should not throw
130
+ registry.closeAllSync();
131
+ expect(order).toEqual(['third', 'first']);
132
+ });
133
+
134
+ it('ignores registrations after close', async () => {
135
+ const registry = new ShutdownRegistry();
136
+ await registry.closeAll();
137
+
138
+ const callback = vi.fn();
139
+ registry.register('late', callback);
140
+ expect(registry.size).toBe(0);
141
+ });
142
+
143
+ it('clears entries after closeAll', async () => {
144
+ const registry = new ShutdownRegistry();
145
+ registry.register('test', vi.fn());
146
+ expect(registry.size).toBe(1);
147
+
148
+ await registry.closeAll();
149
+ expect(registry.size).toBe(0);
150
+ });
151
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shutdown Registry — centralized cleanup for agent runtime resources.
3
+ *
4
+ * Modules register their cleanup callbacks (clear timers, close watchers,
5
+ * kill child processes). On shutdown, callbacks run in LIFO order so
6
+ * dependents close before their dependencies.
7
+ *
8
+ * Idempotent: calling `closeAll()` multiple times is safe.
9
+ */
10
+
11
+ export type ShutdownCallback = () => void | Promise<void>;
12
+
13
+ interface ShutdownEntry {
14
+ name: string;
15
+ callback: ShutdownCallback;
16
+ }
17
+
18
+ export class ShutdownRegistry {
19
+ private entries: ShutdownEntry[] = [];
20
+ private closed = false;
21
+
22
+ /**
23
+ * Register a named cleanup callback.
24
+ * Callbacks are invoked in LIFO order (last registered = first closed).
25
+ */
26
+ register(name: string, callback: ShutdownCallback): void {
27
+ if (this.closed) return;
28
+ this.entries.push({ name, callback });
29
+ }
30
+
31
+ /**
32
+ * Run all registered cleanup callbacks in LIFO order.
33
+ * Idempotent — subsequent calls are no-ops.
34
+ * Errors in individual callbacks are caught and logged to stderr
35
+ * so that remaining callbacks still execute.
36
+ */
37
+ async closeAll(): Promise<void> {
38
+ if (this.closed) return;
39
+ this.closed = true;
40
+
41
+ // LIFO order
42
+ for (let i = this.entries.length - 1; i >= 0; i--) {
43
+ const entry = this.entries[i];
44
+ try {
45
+ await entry.callback();
46
+ } catch (err) {
47
+ // Log but don't throw — remaining cleanups must still run
48
+ const msg = err instanceof Error ? err.message : String(err);
49
+ process.stderr.write(`[shutdown] ${entry.name}: ${msg}\n`);
50
+ }
51
+ }
52
+
53
+ this.entries = [];
54
+ }
55
+
56
+ /**
57
+ * Synchronous close — best-effort for non-async callbacks.
58
+ * Use when you can't await (e.g. process.on('exit')).
59
+ */
60
+ closeAllSync(): void {
61
+ if (this.closed) return;
62
+ this.closed = true;
63
+
64
+ for (let i = this.entries.length - 1; i >= 0; i--) {
65
+ const entry = this.entries[i];
66
+ try {
67
+ entry.callback();
68
+ } catch {
69
+ // Best-effort — swallow errors in sync path
70
+ }
71
+ }
72
+
73
+ this.entries = [];
74
+ }
75
+
76
+ /** Number of registered callbacks. */
77
+ get size(): number {
78
+ return this.entries.length;
79
+ }
80
+
81
+ /** Whether closeAll() has been called. */
82
+ get isClosed(): boolean {
83
+ return this.closed;
84
+ }
85
+ }