@soleri/core 9.3.1 → 9.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 (205) hide show
  1. package/dist/brain/intelligence.d.ts +5 -0
  2. package/dist/brain/intelligence.d.ts.map +1 -1
  3. package/dist/brain/intelligence.js +115 -26
  4. package/dist/brain/intelligence.js.map +1 -1
  5. package/dist/brain/learning-radar.d.ts +3 -3
  6. package/dist/brain/learning-radar.d.ts.map +1 -1
  7. package/dist/brain/learning-radar.js +8 -4
  8. package/dist/brain/learning-radar.js.map +1 -1
  9. package/dist/control/intent-router.d.ts +2 -2
  10. package/dist/control/intent-router.d.ts.map +1 -1
  11. package/dist/control/intent-router.js +35 -1
  12. package/dist/control/intent-router.js.map +1 -1
  13. package/dist/control/types.d.ts +10 -2
  14. package/dist/control/types.d.ts.map +1 -1
  15. package/dist/curator/curator.d.ts +4 -0
  16. package/dist/curator/curator.d.ts.map +1 -1
  17. package/dist/curator/curator.js +23 -1
  18. package/dist/curator/curator.js.map +1 -1
  19. package/dist/curator/schema.d.ts +1 -1
  20. package/dist/curator/schema.d.ts.map +1 -1
  21. package/dist/curator/schema.js +8 -0
  22. package/dist/curator/schema.js.map +1 -1
  23. package/dist/domain-packs/types.d.ts +6 -0
  24. package/dist/domain-packs/types.d.ts.map +1 -1
  25. package/dist/domain-packs/types.js +1 -0
  26. package/dist/domain-packs/types.js.map +1 -1
  27. package/dist/engine/module-manifest.js +3 -3
  28. package/dist/engine/module-manifest.js.map +1 -1
  29. package/dist/engine/register-engine.d.ts +9 -0
  30. package/dist/engine/register-engine.d.ts.map +1 -1
  31. package/dist/engine/register-engine.js +59 -1
  32. package/dist/engine/register-engine.js.map +1 -1
  33. package/dist/facades/types.d.ts +5 -1
  34. package/dist/facades/types.d.ts.map +1 -1
  35. package/dist/facades/types.js.map +1 -1
  36. package/dist/hooks/candidate-scorer.d.ts +28 -0
  37. package/dist/hooks/candidate-scorer.d.ts.map +1 -0
  38. package/dist/hooks/candidate-scorer.js +20 -0
  39. package/dist/hooks/candidate-scorer.js.map +1 -0
  40. package/dist/hooks/index.d.ts +2 -0
  41. package/dist/hooks/index.d.ts.map +1 -0
  42. package/dist/hooks/index.js +2 -0
  43. package/dist/hooks/index.js.map +1 -0
  44. package/dist/index.d.ts +4 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/operator/operator-context-store.d.ts +54 -0
  49. package/dist/operator/operator-context-store.d.ts.map +1 -0
  50. package/dist/operator/operator-context-store.js +434 -0
  51. package/dist/operator/operator-context-store.js.map +1 -0
  52. package/dist/operator/operator-context-types.d.ts +101 -0
  53. package/dist/operator/operator-context-types.d.ts.map +1 -0
  54. package/dist/operator/operator-context-types.js +27 -0
  55. package/dist/operator/operator-context-types.js.map +1 -0
  56. package/dist/packs/index.d.ts +2 -2
  57. package/dist/packs/index.d.ts.map +1 -1
  58. package/dist/packs/index.js +1 -1
  59. package/dist/packs/index.js.map +1 -1
  60. package/dist/packs/lockfile.d.ts +3 -0
  61. package/dist/packs/lockfile.d.ts.map +1 -1
  62. package/dist/packs/lockfile.js.map +1 -1
  63. package/dist/packs/types.d.ts +8 -2
  64. package/dist/packs/types.d.ts.map +1 -1
  65. package/dist/packs/types.js +6 -0
  66. package/dist/packs/types.js.map +1 -1
  67. package/dist/planning/plan-lifecycle.d.ts +12 -1
  68. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  69. package/dist/planning/plan-lifecycle.js +54 -16
  70. package/dist/planning/plan-lifecycle.js.map +1 -1
  71. package/dist/planning/planner-types.d.ts +6 -0
  72. package/dist/planning/planner-types.d.ts.map +1 -1
  73. package/dist/planning/planner.d.ts +21 -1
  74. package/dist/planning/planner.d.ts.map +1 -1
  75. package/dist/planning/planner.js +62 -3
  76. package/dist/planning/planner.js.map +1 -1
  77. package/dist/planning/task-complexity-assessor.d.ts.map +1 -1
  78. package/dist/planning/task-complexity-assessor.js.map +1 -1
  79. package/dist/plugins/types.d.ts +18 -18
  80. package/dist/runtime/admin-ops.d.ts +1 -1
  81. package/dist/runtime/admin-ops.d.ts.map +1 -1
  82. package/dist/runtime/admin-ops.js +100 -3
  83. package/dist/runtime/admin-ops.js.map +1 -1
  84. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  85. package/dist/runtime/admin-setup-ops.js +19 -9
  86. package/dist/runtime/admin-setup-ops.js.map +1 -1
  87. package/dist/runtime/capture-ops.d.ts.map +1 -1
  88. package/dist/runtime/capture-ops.js +35 -7
  89. package/dist/runtime/capture-ops.js.map +1 -1
  90. package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
  91. package/dist/runtime/facades/brain-facade.js +4 -2
  92. package/dist/runtime/facades/brain-facade.js.map +1 -1
  93. package/dist/runtime/facades/control-facade.d.ts.map +1 -1
  94. package/dist/runtime/facades/control-facade.js +8 -2
  95. package/dist/runtime/facades/control-facade.js.map +1 -1
  96. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  97. package/dist/runtime/facades/curator-facade.js +13 -0
  98. package/dist/runtime/facades/curator-facade.js.map +1 -1
  99. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  100. package/dist/runtime/facades/memory-facade.js +10 -12
  101. package/dist/runtime/facades/memory-facade.js.map +1 -1
  102. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  103. package/dist/runtime/facades/orchestrate-facade.js +36 -1
  104. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  105. package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
  106. package/dist/runtime/facades/plan-facade.js +20 -4
  107. package/dist/runtime/facades/plan-facade.js.map +1 -1
  108. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  109. package/dist/runtime/orchestrate-ops.js +71 -4
  110. package/dist/runtime/orchestrate-ops.js.map +1 -1
  111. package/dist/runtime/plan-feedback-helper.d.ts +21 -0
  112. package/dist/runtime/plan-feedback-helper.d.ts.map +1 -0
  113. package/dist/runtime/plan-feedback-helper.js +52 -0
  114. package/dist/runtime/plan-feedback-helper.js.map +1 -0
  115. package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
  116. package/dist/runtime/planning-extra-ops.js +73 -34
  117. package/dist/runtime/planning-extra-ops.js.map +1 -1
  118. package/dist/runtime/session-briefing.d.ts.map +1 -1
  119. package/dist/runtime/session-briefing.js +9 -1
  120. package/dist/runtime/session-briefing.js.map +1 -1
  121. package/dist/runtime/types.d.ts +3 -0
  122. package/dist/runtime/types.d.ts.map +1 -1
  123. package/dist/skills/sync-skills.d.ts.map +1 -1
  124. package/dist/skills/sync-skills.js +13 -7
  125. package/dist/skills/sync-skills.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/brain/brain-intelligence.test.ts +30 -0
  128. package/src/brain/brain.ts +120 -46
  129. package/src/brain/extraction-quality.test.ts +323 -0
  130. package/src/brain/intelligence.ts +175 -64
  131. package/src/brain/learning-radar.ts +8 -5
  132. package/src/brain/second-brain-features.test.ts +1 -1
  133. package/src/chat/agent-loop.ts +1 -1
  134. package/src/chat/notifications.ts +4 -0
  135. package/src/control/intent-router.test.ts +73 -3
  136. package/src/control/intent-router.ts +48 -9
  137. package/src/control/types.ts +13 -2
  138. package/src/curator/curator.test.ts +92 -0
  139. package/src/curator/curator.ts +162 -18
  140. package/src/curator/schema.ts +8 -0
  141. package/src/domain-packs/types.ts +8 -0
  142. package/src/engine/module-manifest.test.ts +8 -2
  143. package/src/engine/module-manifest.ts +3 -3
  144. package/src/engine/register-engine.test.ts +73 -1
  145. package/src/engine/register-engine.ts +61 -1
  146. package/src/facades/types.ts +5 -0
  147. package/src/hooks/candidate-scorer.test.ts +76 -0
  148. package/src/hooks/candidate-scorer.ts +39 -0
  149. package/src/hooks/index.ts +6 -0
  150. package/src/index.ts +24 -0
  151. package/src/llm/llm-client.ts +1 -0
  152. package/src/operator/operator-context-store.test.ts +698 -0
  153. package/src/operator/operator-context-store.ts +569 -0
  154. package/src/operator/operator-context-types.ts +139 -0
  155. package/src/packs/index.ts +3 -1
  156. package/src/packs/lockfile.ts +3 -0
  157. package/src/packs/types.ts +9 -0
  158. package/src/persistence/sqlite-provider.ts +1 -0
  159. package/src/planning/github-projection.ts +48 -44
  160. package/src/planning/plan-lifecycle.ts +93 -22
  161. package/src/planning/planner-types.ts +6 -0
  162. package/src/planning/planner.ts +74 -4
  163. package/src/planning/task-complexity-assessor.test.ts +6 -2
  164. package/src/planning/task-complexity-assessor.ts +1 -4
  165. package/src/queue/pipeline-runner.ts +4 -0
  166. package/src/runtime/admin-ops.test.ts +139 -6
  167. package/src/runtime/admin-ops.ts +104 -3
  168. package/src/runtime/admin-setup-ops.ts +30 -10
  169. package/src/runtime/capture-ops.test.ts +84 -0
  170. package/src/runtime/capture-ops.ts +35 -7
  171. package/src/runtime/curator-extra-ops.test.ts +7 -0
  172. package/src/runtime/curator-extra-ops.ts +10 -1
  173. package/src/runtime/facades/admin-facade.test.ts +1 -1
  174. package/src/runtime/facades/brain-facade.ts +6 -3
  175. package/src/runtime/facades/control-facade.ts +10 -2
  176. package/src/runtime/facades/curator-facade.test.ts +7 -0
  177. package/src/runtime/facades/curator-facade.ts +18 -0
  178. package/src/runtime/facades/memory-facade.test.ts +14 -12
  179. package/src/runtime/facades/memory-facade.ts +197 -12
  180. package/src/runtime/facades/orchestrate-facade.ts +33 -1
  181. package/src/runtime/facades/plan-facade.test.ts +213 -0
  182. package/src/runtime/facades/plan-facade.ts +23 -4
  183. package/src/runtime/orchestrate-ops.test.ts +202 -2
  184. package/src/runtime/orchestrate-ops.ts +88 -7
  185. package/src/runtime/plan-feedback-helper.test.ts +173 -0
  186. package/src/runtime/plan-feedback-helper.ts +63 -0
  187. package/src/runtime/planning-extra-ops.test.ts +43 -1
  188. package/src/runtime/planning-extra-ops.ts +96 -33
  189. package/src/runtime/runtime.test.ts +50 -2
  190. package/src/runtime/runtime.ts +117 -89
  191. package/src/runtime/session-briefing.test.ts +1 -0
  192. package/src/runtime/session-briefing.ts +10 -1
  193. package/src/runtime/shutdown-registry.test.ts +151 -0
  194. package/src/runtime/shutdown-registry.ts +85 -0
  195. package/src/runtime/types.ts +7 -1
  196. package/src/skills/sync-skills.ts +14 -7
  197. package/src/transport/http-server.ts +50 -3
  198. package/src/transport/ws-server.ts +8 -0
  199. package/src/vault/linking.test.ts +12 -0
  200. package/src/vault/linking.ts +90 -44
  201. package/src/vault/vault-maintenance.ts +11 -18
  202. package/src/vault/vault-memories.ts +21 -13
  203. package/src/vault/vault-schema.ts +21 -0
  204. package/src/vault/vault.ts +8 -3
  205. package/vitest.config.ts +1 -0
@@ -17,6 +17,7 @@ import { collectGitEvidence } from '../planning/evidence-collector.js';
17
17
  import { matchPlaybooks, type PlaybookMatchResult } from '../playbooks/index.js';
18
18
  import { entryToPlaybookDefinition } from '../playbooks/index.js';
19
19
  import { closeIssueWithComment } from './github-integration.js';
20
+ import { recordPlanFeedback } from './plan-feedback-helper.js';
20
21
 
21
22
  /**
22
23
  * Create 22 extended planning operations for an agent runtime.
@@ -37,9 +38,9 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
37
38
  {
38
39
  name: 'plan_iterate',
39
40
  description:
40
- 'Revise a draft plan — change objective, scope, decisions, or add/remove tasks. Only works on draft plans.',
41
+ 'Revise a draft plan — change objective, scope, decisions, alternatives, or add/remove tasks. Only works on draft plans.',
41
42
  auth: 'write',
42
- schema: z.object({
43
+ schema: z.strictObject({
43
44
  planId: z.string().describe('ID of the draft plan to iterate on'),
44
45
  objective: z.string().optional().describe('New objective (replaces existing)'),
45
46
  scope: z.string().optional().describe('New scope (replaces existing)'),
@@ -49,21 +50,54 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
49
50
  .describe(
50
51
  'New decisions list (replaces existing) — strings or {decision, rationale} objects',
51
52
  ),
53
+ alternatives: z
54
+ .array(
55
+ z.object({
56
+ approach: z.string().describe('The alternative approach considered'),
57
+ pros: z.array(z.string()).describe('Advantages of this approach'),
58
+ cons: z.array(z.string()).describe('Disadvantages of this approach'),
59
+ rejected_reason: z.string().describe('Why this alternative was rejected'),
60
+ }),
61
+ )
62
+ .optional()
63
+ .describe('Rejected alternative approaches (replaces existing)'),
52
64
  addTasks: z
53
- .array(z.object({ title: z.string(), description: z.string() }))
65
+ .array(
66
+ z.object({
67
+ title: z.string(),
68
+ description: z.string(),
69
+ phase: z
70
+ .string()
71
+ .optional()
72
+ .describe('Phase this task belongs to (e.g., "wave-1", "discovery")'),
73
+ milestone: z
74
+ .string()
75
+ .optional()
76
+ .describe('Milestone this task contributes to (e.g., "v1.0", "mvp")'),
77
+ parentTaskId: z.string().optional().describe('Parent task ID for sub-task hierarchy'),
78
+ }),
79
+ )
54
80
  .optional()
55
81
  .describe('Tasks to append'),
56
82
  removeTasks: z.array(z.string()).optional().describe('Task IDs to remove'),
57
83
  }),
58
84
  handler: async (params) => {
59
85
  try {
60
- const plan = planner.iterate(params.planId as string, {
86
+ const { plan, mutated } = planner.iterate(params.planId as string, {
61
87
  objective: params.objective as string | undefined,
62
88
  scope: params.scope as string | undefined,
63
89
  decisions: params.decisions as string[] | undefined,
90
+ alternatives: params.alternatives as
91
+ | Array<{ approach: string; pros: string[]; cons: string[]; rejected_reason: string }>
92
+ | undefined,
64
93
  addTasks: params.addTasks as Array<{ title: string; description: string }> | undefined,
65
94
  removeTasks: params.removeTasks as string[] | undefined,
66
95
  });
96
+
97
+ if (mutated === 0) {
98
+ return { iterated: false, reason: 'no changes detected', plan };
99
+ }
100
+
67
101
  return { iterated: true, plan };
68
102
  } catch (err) {
69
103
  return { error: (err as Error).message };
@@ -85,6 +119,17 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
85
119
  title: z.string(),
86
120
  description: z.string(),
87
121
  dependsOn: z.array(z.string()).optional().describe('Task IDs this task depends on'),
122
+ phase: z
123
+ .string()
124
+ .optional()
125
+ .describe(
126
+ 'Phase this task belongs to (e.g., "wave-1", "discovery", "implementation")',
127
+ ),
128
+ milestone: z
129
+ .string()
130
+ .optional()
131
+ .describe('Milestone this task contributes to (e.g., "v1.0", "mvp", "beta")'),
132
+ parentTaskId: z.string().optional().describe('Parent task ID for sub-task hierarchy'),
88
133
  }),
89
134
  )
90
135
  .describe('New task list with optional dependency references (task-1, task-2, etc.)'),
@@ -93,7 +138,14 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
93
138
  try {
94
139
  const plan = planner.splitTasks(
95
140
  params.planId as string,
96
- params.tasks as Array<{ title: string; description: string; dependsOn?: string[] }>,
141
+ params.tasks as Array<{
142
+ title: string;
143
+ description: string;
144
+ dependsOn?: string[];
145
+ phase?: string;
146
+ milestone?: string;
147
+ parentTaskId?: string;
148
+ }>,
97
149
  );
98
150
 
99
151
  // Auto-start brain session linked to the plan for learning pipeline
@@ -139,20 +191,16 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
139
191
  .describe('Specific drift items — differences between plan and reality'),
140
192
  }),
141
193
  handler: async (params) => {
142
- try {
143
- const plan = planner.reconcile(params.planId as string, {
144
- actualOutcome: params.actualOutcome as string,
145
- driftItems: params.driftItems as DriftItem[] | undefined,
146
- });
147
- return {
148
- reconciled: true,
149
- accuracy: plan.reconciliation!.accuracy,
150
- driftCount: plan.reconciliation!.driftItems.length,
151
- plan,
152
- };
153
- } catch (err) {
154
- return { error: (err as Error).message };
155
- }
194
+ const plan = planner.reconcile(params.planId as string, {
195
+ actualOutcome: params.actualOutcome as string,
196
+ driftItems: params.driftItems as DriftItem[] | undefined,
197
+ });
198
+ return {
199
+ reconciled: true,
200
+ accuracy: plan.reconciliation!.accuracy,
201
+ driftCount: plan.reconciliation!.driftItems.length,
202
+ plan,
203
+ };
156
204
  },
157
205
  },
158
206
 
@@ -228,20 +276,7 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
228
276
  }
229
277
 
230
278
  // Auto-record positive feedback for vault entries used as recommendations
231
- let feedbackRecorded = 0;
232
- const entryIdRegex = /\[entryId:([^\]]+)\]/;
233
- for (const d of plan.decisions) {
234
- const decisionStr = typeof d === 'string' ? d : d.decision;
235
- const match = entryIdRegex.exec(decisionStr);
236
- if (match) {
237
- try {
238
- brain.recordFeedback(plan.objective, match[1], 'accepted');
239
- feedbackRecorded++;
240
- } catch {
241
- // Graceful degradation — skip if entry not found or already recorded
242
- }
243
- }
244
- }
279
+ const feedbackRecorded = recordPlanFeedback(plan, brain, brainIntelligence);
245
280
 
246
281
  // Auto-close linked GitHub issue if plan has one
247
282
  let issueClosed = false;
@@ -818,6 +853,34 @@ export function createPlanningExtraOps(runtime: AgentRuntime): OpDefinition[] {
818
853
  },
819
854
  },
820
855
 
856
+ // ─── Plan Close Stale ───────────────────────────────────────
857
+ {
858
+ name: 'plan_close_stale',
859
+ description:
860
+ 'Close stale plans stuck in non-terminal states. Draft/approved plans expire after 30 min. Executing/reconciling plans expire after the configured threshold (default 24h). Returns list of closed plans.',
861
+ auth: 'write',
862
+ schema: z.object({
863
+ olderThanMs: z
864
+ .number()
865
+ .optional()
866
+ .describe(
867
+ 'Custom threshold in milliseconds for executing/reconciling plans (default: 24h). Set to 0 to close ALL non-terminal plans immediately.',
868
+ ),
869
+ }),
870
+ handler: async (params) => {
871
+ const olderThanMs = params.olderThanMs as number | undefined;
872
+ const result = planner.closeStale(olderThanMs);
873
+ return {
874
+ closed: result.closedPlans.length,
875
+ plans: result.closedPlans,
876
+ message:
877
+ result.closedPlans.length > 0
878
+ ? `Closed ${result.closedPlans.length} stale plan(s)`
879
+ : 'No stale plans found',
880
+ };
881
+ },
882
+ },
883
+
821
884
  // ─── Purge Plans (#215) ──────────────────────────────────────────
822
885
  {
823
886
  name: 'plan_purge',
@@ -81,7 +81,10 @@ vi.mock('../governance/governance.js', () => ({
81
81
  }));
82
82
 
83
83
  vi.mock('../loop/loop-manager.js', () => ({
84
- LoopManager: mockClass(),
84
+ LoopManager: vi.fn(function (this: Record<string, unknown>) {
85
+ this.isActive = vi.fn().mockReturnValue(false);
86
+ this.cancelLoop = vi.fn();
87
+ }),
85
88
  }));
86
89
 
87
90
  vi.mock('../control/identity-manager.js', () => ({
@@ -185,7 +188,9 @@ vi.mock('../context/context-engine.js', () => ({
185
188
  }));
186
189
 
187
190
  vi.mock('../agency/agency-manager.js', () => ({
188
- AgencyManager: mockClass(),
191
+ AgencyManager: vi.fn(function (this: Record<string, unknown>) {
192
+ this.disable = vi.fn();
193
+ }),
189
194
  }));
190
195
 
191
196
  vi.mock('../vault/knowledge-review.js', () => ({
@@ -220,6 +225,7 @@ vi.mock('../queue/job-queue.js', () => ({
220
225
  vi.mock('../queue/pipeline-runner.js', () => ({
221
226
  PipelineRunner: vi.fn(function (this: Record<string, unknown>) {
222
227
  this.registerHandler = vi.fn();
228
+ this.stop = vi.fn();
223
229
  }),
224
230
  }));
225
231
 
@@ -261,6 +267,18 @@ vi.mock('./context-health.js', () => ({
261
267
  }),
262
268
  }));
263
269
 
270
+ vi.mock('./shutdown-registry.js', () => ({
271
+ ShutdownRegistry: vi.fn(function (this: Record<string, unknown>) {
272
+ this.register = vi.fn();
273
+ this.closeAll = vi.fn().mockResolvedValue(undefined);
274
+ this.closeAllSync = vi.fn();
275
+ this.size = 0;
276
+ this.isClosed = false;
277
+ this.entries = [];
278
+ this.closed = false;
279
+ }),
280
+ }));
281
+
264
282
  vi.mock('node:fs', () => ({
265
283
  existsSync: vi.fn().mockReturnValue(false),
266
284
  mkdirSync: vi.fn(),
@@ -360,4 +378,34 @@ describe('createAgentRuntime', () => {
360
378
  it('initializes context health monitor', () => {
361
379
  expect(runtime.contextHealth).toBeDefined();
362
380
  });
381
+
382
+ it('initializes shutdown registry', () => {
383
+ expect(runtime.shutdownRegistry).toBeDefined();
384
+ expect(runtime.shutdownRegistry.register).toBeDefined();
385
+ });
386
+
387
+ it('registers cleanup callbacks with shutdown registry', () => {
388
+ // vaultManager, pipelineRunner, agencyManager, loopManager
389
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
390
+ 'vaultManager',
391
+ expect.any(Function),
392
+ );
393
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
394
+ 'pipelineRunner',
395
+ expect.any(Function),
396
+ );
397
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
398
+ 'agencyManager',
399
+ expect.any(Function),
400
+ );
401
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
402
+ 'loopManager',
403
+ expect.any(Function),
404
+ );
405
+ });
406
+
407
+ it('close() calls shutdownRegistry.closeAllSync()', () => {
408
+ runtime.close();
409
+ expect(runtime.shutdownRegistry.closeAllSync).toHaveBeenCalled();
410
+ });
363
411
  });
@@ -60,6 +60,7 @@ 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';
63
64
 
64
65
  /**
65
66
  * Create a fully initialized agent runtime.
@@ -221,6 +222,117 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
221
222
  learningRadar.setOperatorProfile(operatorProfile);
222
223
  brainIntelligence.setOperatorProfile(operatorProfile);
223
224
 
225
+ // ─── Shutdown Registry ────────────────────────────────────────────
226
+ const shutdownRegistry = new ShutdownRegistry();
227
+
228
+ // Build pipeline runner before the runtime object so we can reference it
229
+ const pipelineRunner = (() => {
230
+ const jq = new JobQueue(vault.getProvider());
231
+ const pr = new PipelineRunner(jq);
232
+ // Register default job handlers for curator pipeline
233
+ pr.registerHandler('tag-normalize', async (job) => {
234
+ const entry = vault.get(job.entryId ?? '');
235
+ if (!entry) return { skipped: true, reason: 'entry not found' };
236
+ const result = curator.normalizeTag(entry.tags[0] ?? '');
237
+ return result;
238
+ });
239
+ pr.registerHandler('dedup-check', async (job) => {
240
+ const entry = vault.get(job.entryId ?? '');
241
+ if (!entry) return { skipped: true, reason: 'entry not found' };
242
+ return curator.detectDuplicates(entry.id);
243
+ });
244
+ pr.registerHandler('auto-link', async (job) => {
245
+ if (linkManager) {
246
+ const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
247
+ for (const s of suggestions) {
248
+ linkManager.addLink(
249
+ job.entryId ?? '',
250
+ s.entryId,
251
+ s.suggestedType,
252
+ `pipeline: ${s.reason}`,
253
+ );
254
+ }
255
+ return { linked: suggestions.length };
256
+ }
257
+ return { skipped: true, reason: 'link manager not available' };
258
+ });
259
+ pr.registerHandler('quality-gate', async (job) => {
260
+ const entry = vault.get(job.entryId ?? '');
261
+ if (!entry) return { skipped: true, reason: 'entry not found' };
262
+ return evaluateQuality(entry, llmClient);
263
+ });
264
+ pr.registerHandler('classify', async (job) => {
265
+ const entry = vault.get(job.entryId ?? '');
266
+ if (!entry) return { skipped: true, reason: 'entry not found' };
267
+ return classifyEntry(entry, llmClient);
268
+ });
269
+
270
+ // ─── 9 additional handlers for full Salvador parity (#216) ────
271
+ pr.registerHandler('enrich-frontmatter', async (job) => {
272
+ const entry = vault.get(job.entryId ?? '');
273
+ if (!entry) return { skipped: true, reason: 'entry not found' };
274
+ return curator.enrichMetadata(entry.id);
275
+ });
276
+ pr.registerHandler('detect-staleness', async (job) => {
277
+ const entry = vault.get(job.entryId ?? '');
278
+ if (!entry) return { skipped: true, reason: 'entry not found' };
279
+ // Check if entry is older than 90 days (using validFrom or fallback to 0)
280
+ const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
281
+ const ageMs = Date.now() - entryTimestamp;
282
+ const staleDays = 90;
283
+ const isStale = ageMs > staleDays * 86400000;
284
+ return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
285
+ });
286
+ pr.registerHandler('detect-duplicate', async (job) => {
287
+ const entry = vault.get(job.entryId ?? '');
288
+ if (!entry) return { skipped: true, reason: 'entry not found' };
289
+ return curator.detectDuplicates(entry.id);
290
+ });
291
+ pr.registerHandler('detect-contradiction', async (job) => {
292
+ const entry = vault.get(job.entryId ?? '');
293
+ if (!entry) return { skipped: true, reason: 'entry not found' };
294
+ const contradictions = curator.detectContradictions(0.4);
295
+ const relevant = contradictions.filter(
296
+ (c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
297
+ );
298
+ return { found: relevant.length, contradictions: relevant };
299
+ });
300
+ pr.registerHandler('consolidate-duplicates', async (_job) => {
301
+ return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
302
+ });
303
+ pr.registerHandler('archive-stale', async (_job) => {
304
+ // Run consolidation with stale detection
305
+ const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
306
+ return { archived: result.staleEntries.length, result };
307
+ });
308
+ pr.registerHandler('verify-searchable', async (job) => {
309
+ const entry = vault.get(job.entryId ?? '');
310
+ if (!entry) return { skipped: true, reason: 'entry not found' };
311
+ const searchResults = vault.search(entry.title, { limit: 1 });
312
+ const found = searchResults.some((r) => r.entry.id === entry.id);
313
+ return { searchable: found, entryId: entry.id };
314
+ });
315
+ return pr;
316
+ })();
317
+
318
+ // ─── Register cleanup callbacks (LIFO: first registered = last closed) ──
319
+ // Vault manager closes last (other modules may flush to vault during close)
320
+ shutdownRegistry.register('vaultManager', () => vaultManager.close());
321
+ // Pipeline runner — clear its polling interval
322
+ shutdownRegistry.register('pipelineRunner', () => pipelineRunner.stop());
323
+ // Agency manager — close FSWatchers and debounce timers
324
+ shutdownRegistry.register('agencyManager', () => agencyManager.disable());
325
+ // Loop manager — clear accumulated state
326
+ shutdownRegistry.register('loopManager', () => {
327
+ if (loop.isActive()) {
328
+ try {
329
+ loop.cancelLoop();
330
+ } catch {
331
+ // Loop may already be inactive
332
+ }
333
+ }
334
+ });
335
+
224
336
  return {
225
337
  config,
226
338
  logger,
@@ -256,94 +368,7 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
256
368
  knowledgeSynthesizer: new KnowledgeSynthesizer(brain, llmClient),
257
369
  chainRunner: new ChainRunner(vault.getProvider()),
258
370
  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
- })(),
371
+ pipelineRunner,
347
372
  operatorProfile,
348
373
  persona: (() => {
349
374
  const p = loadPersona(agentId, config.persona ?? undefined);
@@ -355,9 +380,12 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
355
380
  return generatePersonaInstructions(p);
356
381
  })(),
357
382
  contextHealth: new ContextHealthMonitor(),
383
+ shutdownRegistry,
358
384
  createdAt: Date.now(),
359
385
  close: () => {
360
- vaultManager.close();
386
+ // Synchronous close — runs all registered callbacks in LIFO order,
387
+ // then closes the vault (registered first, so runs last).
388
+ shutdownRegistry.closeAllSync();
361
389
  },
362
390
  };
363
391
  }
@@ -40,6 +40,7 @@ function makeRuntime(overrides?: {
40
40
  },
41
41
  planner: {
42
42
  list: () => o.plans ?? [],
43
+ closeStale: () => ({ closedIds: [], closedPlans: [] }),
43
44
  },
44
45
  vault: {
45
46
  stats: () => o.vaultStats ?? { totalEntries: 50, byType: { playbook: 5 } },
@@ -78,8 +78,17 @@ export function createSessionBriefingOps(runtime: AgentRuntime): OpDefinition[]
78
78
  // Session data unavailable — skip
79
79
  }
80
80
 
81
- // 2. Active plans
81
+ // 2. Active plans — auto-close ancient ones (>24h)
82
82
  try {
83
+ // Auto-close plans stuck in non-terminal states for more than 24h
84
+ const staleResult = planner.closeStale();
85
+ if (staleResult.closedPlans.length > 0) {
86
+ sections.push({
87
+ label: 'Plans auto-closed',
88
+ content: `${staleResult.closedPlans.length} stale plan(s) closed: ${staleResult.closedPlans.map((p) => `${p.id.slice(0, 25)}… (${p.reason})`).join(', ')}`,
89
+ });
90
+ }
91
+
83
92
  const plans = planner.list();
84
93
  const active = plans.filter(
85
94
  (p) =>