@soleri/core 9.3.0 → 9.4.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 (177) 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.d.ts +2 -0
  28. package/dist/engine/module-manifest.d.ts.map +1 -1
  29. package/dist/engine/module-manifest.js +117 -2
  30. package/dist/engine/module-manifest.js.map +1 -1
  31. package/dist/engine/register-engine.d.ts +9 -0
  32. package/dist/engine/register-engine.d.ts.map +1 -1
  33. package/dist/engine/register-engine.js +59 -1
  34. package/dist/engine/register-engine.js.map +1 -1
  35. package/dist/facades/types.d.ts +5 -1
  36. package/dist/facades/types.d.ts.map +1 -1
  37. package/dist/facades/types.js.map +1 -1
  38. package/dist/index.d.ts +6 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +5 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/operator/operator-context-store.d.ts +54 -0
  43. package/dist/operator/operator-context-store.d.ts.map +1 -0
  44. package/dist/operator/operator-context-store.js +434 -0
  45. package/dist/operator/operator-context-store.js.map +1 -0
  46. package/dist/operator/operator-context-types.d.ts +101 -0
  47. package/dist/operator/operator-context-types.d.ts.map +1 -0
  48. package/dist/operator/operator-context-types.js +27 -0
  49. package/dist/operator/operator-context-types.js.map +1 -0
  50. package/dist/packs/index.d.ts +2 -2
  51. package/dist/packs/index.d.ts.map +1 -1
  52. package/dist/packs/index.js +1 -1
  53. package/dist/packs/index.js.map +1 -1
  54. package/dist/packs/lockfile.d.ts +3 -0
  55. package/dist/packs/lockfile.d.ts.map +1 -1
  56. package/dist/packs/lockfile.js.map +1 -1
  57. package/dist/packs/types.d.ts +8 -2
  58. package/dist/packs/types.d.ts.map +1 -1
  59. package/dist/packs/types.js +6 -0
  60. package/dist/packs/types.js.map +1 -1
  61. package/dist/planning/plan-lifecycle.d.ts +12 -1
  62. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  63. package/dist/planning/plan-lifecycle.js +52 -19
  64. package/dist/planning/plan-lifecycle.js.map +1 -1
  65. package/dist/planning/planner-types.d.ts +6 -0
  66. package/dist/planning/planner-types.d.ts.map +1 -1
  67. package/dist/planning/planner.d.ts +21 -1
  68. package/dist/planning/planner.d.ts.map +1 -1
  69. package/dist/planning/planner.js +62 -3
  70. package/dist/planning/planner.js.map +1 -1
  71. package/dist/planning/task-complexity-assessor.d.ts +42 -0
  72. package/dist/planning/task-complexity-assessor.d.ts.map +1 -0
  73. package/dist/planning/task-complexity-assessor.js +132 -0
  74. package/dist/planning/task-complexity-assessor.js.map +1 -0
  75. package/dist/plugins/types.d.ts +18 -18
  76. package/dist/runtime/admin-ops.d.ts +1 -1
  77. package/dist/runtime/admin-ops.d.ts.map +1 -1
  78. package/dist/runtime/admin-ops.js +118 -3
  79. package/dist/runtime/admin-ops.js.map +1 -1
  80. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  81. package/dist/runtime/admin-setup-ops.js +19 -9
  82. package/dist/runtime/admin-setup-ops.js.map +1 -1
  83. package/dist/runtime/capture-ops.d.ts.map +1 -1
  84. package/dist/runtime/capture-ops.js +35 -7
  85. package/dist/runtime/capture-ops.js.map +1 -1
  86. package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
  87. package/dist/runtime/facades/brain-facade.js +4 -2
  88. package/dist/runtime/facades/brain-facade.js.map +1 -1
  89. package/dist/runtime/facades/control-facade.d.ts.map +1 -1
  90. package/dist/runtime/facades/control-facade.js +8 -2
  91. package/dist/runtime/facades/control-facade.js.map +1 -1
  92. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  93. package/dist/runtime/facades/curator-facade.js +13 -0
  94. package/dist/runtime/facades/curator-facade.js.map +1 -1
  95. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  96. package/dist/runtime/facades/memory-facade.js +10 -12
  97. package/dist/runtime/facades/memory-facade.js.map +1 -1
  98. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  99. package/dist/runtime/facades/orchestrate-facade.js +36 -1
  100. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  101. package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
  102. package/dist/runtime/facades/plan-facade.js +20 -4
  103. package/dist/runtime/facades/plan-facade.js.map +1 -1
  104. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  105. package/dist/runtime/orchestrate-ops.js +109 -31
  106. package/dist/runtime/orchestrate-ops.js.map +1 -1
  107. package/dist/runtime/plan-feedback-helper.d.ts +21 -0
  108. package/dist/runtime/plan-feedback-helper.d.ts.map +1 -0
  109. package/dist/runtime/plan-feedback-helper.js +52 -0
  110. package/dist/runtime/plan-feedback-helper.js.map +1 -0
  111. package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
  112. package/dist/runtime/planning-extra-ops.js +73 -34
  113. package/dist/runtime/planning-extra-ops.js.map +1 -1
  114. package/dist/runtime/session-briefing.d.ts.map +1 -1
  115. package/dist/runtime/session-briefing.js +9 -1
  116. package/dist/runtime/session-briefing.js.map +1 -1
  117. package/dist/runtime/types.d.ts +3 -0
  118. package/dist/runtime/types.d.ts.map +1 -1
  119. package/dist/skills/sync-skills.d.ts.map +1 -1
  120. package/dist/skills/sync-skills.js +13 -7
  121. package/dist/skills/sync-skills.js.map +1 -1
  122. package/package.json +1 -1
  123. package/src/brain/brain-intelligence.test.ts +30 -0
  124. package/src/brain/extraction-quality.test.ts +323 -0
  125. package/src/brain/intelligence.ts +133 -30
  126. package/src/brain/learning-radar.ts +8 -5
  127. package/src/brain/second-brain-features.test.ts +1 -1
  128. package/src/control/intent-router.test.ts +73 -3
  129. package/src/control/intent-router.ts +38 -1
  130. package/src/control/types.ts +13 -2
  131. package/src/curator/curator.test.ts +92 -0
  132. package/src/curator/curator.ts +29 -1
  133. package/src/curator/schema.ts +8 -0
  134. package/src/domain-packs/types.ts +8 -0
  135. package/src/engine/module-manifest.test.ts +51 -2
  136. package/src/engine/module-manifest.ts +119 -2
  137. package/src/engine/register-engine.test.ts +73 -1
  138. package/src/engine/register-engine.ts +61 -1
  139. package/src/facades/types.ts +5 -0
  140. package/src/index.ts +30 -0
  141. package/src/operator/operator-context-store.test.ts +698 -0
  142. package/src/operator/operator-context-store.ts +569 -0
  143. package/src/operator/operator-context-types.ts +139 -0
  144. package/src/packs/index.ts +3 -1
  145. package/src/packs/lockfile.ts +3 -0
  146. package/src/packs/types.ts +9 -0
  147. package/src/planning/plan-lifecycle.ts +80 -22
  148. package/src/planning/planner-types.ts +6 -0
  149. package/src/planning/planner.ts +74 -4
  150. package/src/planning/task-complexity-assessor.test.ts +302 -0
  151. package/src/planning/task-complexity-assessor.ts +180 -0
  152. package/src/runtime/admin-ops.test.ts +159 -3
  153. package/src/runtime/admin-ops.ts +123 -3
  154. package/src/runtime/admin-setup-ops.ts +30 -10
  155. package/src/runtime/capture-ops.test.ts +84 -0
  156. package/src/runtime/capture-ops.ts +35 -7
  157. package/src/runtime/facades/admin-facade.test.ts +1 -1
  158. package/src/runtime/facades/brain-facade.ts +6 -3
  159. package/src/runtime/facades/control-facade.ts +10 -2
  160. package/src/runtime/facades/curator-facade.ts +18 -0
  161. package/src/runtime/facades/memory-facade.test.ts +14 -12
  162. package/src/runtime/facades/memory-facade.ts +10 -12
  163. package/src/runtime/facades/orchestrate-facade.ts +33 -1
  164. package/src/runtime/facades/plan-facade.test.ts +213 -0
  165. package/src/runtime/facades/plan-facade.ts +23 -4
  166. package/src/runtime/orchestrate-ops.test.ts +404 -0
  167. package/src/runtime/orchestrate-ops.ts +129 -37
  168. package/src/runtime/plan-feedback-helper.test.ts +173 -0
  169. package/src/runtime/plan-feedback-helper.ts +63 -0
  170. package/src/runtime/planning-extra-ops.test.ts +43 -1
  171. package/src/runtime/planning-extra-ops.ts +96 -33
  172. package/src/runtime/session-briefing.test.ts +1 -0
  173. package/src/runtime/session-briefing.ts +10 -1
  174. package/src/runtime/types.ts +3 -0
  175. package/src/skills/sync-skills.ts +14 -7
  176. package/src/vault/vault-scaling.test.ts +5 -5
  177. package/vitest.config.ts +1 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * TDD tests for brain extraction quality (issue #359).
3
+ *
4
+ * These tests define the DESIRED behavior of extractKnowledge().
5
+ * They are expected to FAIL against the current implementation.
6
+ * Implementation fixes come in issues #360-#366.
7
+ *
8
+ * What's wrong today:
9
+ * - plan_completed rule produces generic "Successful plan: {id}" titles
10
+ * - Extraction rules never read session.context (objective, scope, decisions)
11
+ * - No dedup: same rule + sessionId can produce duplicate proposals
12
+ * - long_session rule fires with low-value noise (to be removed in #360)
13
+ * - No drift_detected rule exists yet (to be added in #366)
14
+ * - Confidence is not adjusted based on context richness
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import { mkdirSync, rmSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { createAgentRuntime } from '../runtime/runtime.js';
22
+ import type { AgentRuntime } from '../runtime/types.js';
23
+
24
+ describe('Extraction Quality', () => {
25
+ let runtime: AgentRuntime;
26
+ let plannerDir: string;
27
+
28
+ beforeEach(() => {
29
+ plannerDir = join(tmpdir(), 'extraction-quality-test-' + Date.now());
30
+ mkdirSync(plannerDir, { recursive: true });
31
+ runtime = createAgentRuntime({
32
+ agentId: 'test-extraction-quality',
33
+ vaultPath: ':memory:',
34
+ plansPath: join(plannerDir, 'plans.json'),
35
+ });
36
+ });
37
+
38
+ afterEach(() => {
39
+ runtime.close();
40
+ rmSync(plannerDir, { recursive: true, force: true });
41
+ });
42
+
43
+ // ─── Helper ────────────────────────────────────────────────────────
44
+
45
+ function createSessionWithContext(
46
+ sessionId: string,
47
+ context: string,
48
+ overrides: {
49
+ planId?: string;
50
+ planOutcome?: string;
51
+ toolsUsed?: string[];
52
+ filesModified?: string[];
53
+ domain?: string;
54
+ } = {},
55
+ ) {
56
+ runtime.brainIntelligence.lifecycle({
57
+ action: 'start',
58
+ sessionId,
59
+ domain: overrides.domain ?? 'testing',
60
+ context,
61
+ toolsUsed: overrides.toolsUsed ?? [],
62
+ filesModified: overrides.filesModified ?? [],
63
+ planId: overrides.planId,
64
+ });
65
+ runtime.brainIntelligence.lifecycle({
66
+ action: 'end',
67
+ sessionId,
68
+ planOutcome: overrides.planOutcome,
69
+ toolsUsed: overrides.toolsUsed,
70
+ filesModified: overrides.filesModified,
71
+ });
72
+ }
73
+
74
+ // ─── 1. Actionable titles from rich context ───────────────────────
75
+
76
+ describe('actionable proposals from session context', () => {
77
+ it('should use session context objective in plan_completed proposal title', () => {
78
+ const richContext = JSON.stringify({
79
+ objective: 'Add OAuth2 authentication to the API gateway',
80
+ scope: { included: ['auth module', 'gateway routes'], excluded: ['frontend'] },
81
+ decisions: ['Use passport.js for OAuth2 strategy'],
82
+ });
83
+
84
+ createSessionWithContext('rich-ctx-1', richContext, {
85
+ planId: 'plan-oauth',
86
+ planOutcome: 'completed',
87
+ });
88
+
89
+ const result = runtime.brainIntelligence.extractKnowledge('rich-ctx-1');
90
+ const planProposal = result.proposals.find((p) => p.rule === 'plan_completed');
91
+
92
+ expect(planProposal).toBeDefined();
93
+ // The title should reference the objective, not just the plan ID
94
+ expect(planProposal!.title).not.toContain('Successful plan:');
95
+ expect(planProposal!.title.toLowerCase()).toContain('oauth');
96
+ });
97
+
98
+ it('should use session context objective in plan_abandoned proposal title', () => {
99
+ const richContext = JSON.stringify({
100
+ objective: 'Migrate database from Postgres to CockroachDB',
101
+ scope: { included: ['migration scripts', 'connection pool'] },
102
+ });
103
+
104
+ createSessionWithContext('rich-ctx-2', richContext, {
105
+ planId: 'plan-migrate',
106
+ planOutcome: 'abandoned',
107
+ });
108
+
109
+ const result = runtime.brainIntelligence.extractKnowledge('rich-ctx-2');
110
+ const abandonedProposal = result.proposals.find((p) => p.rule === 'plan_abandoned');
111
+
112
+ expect(abandonedProposal).toBeDefined();
113
+ // The title should reference what was abandoned, not just the plan ID
114
+ expect(abandonedProposal!.title).not.toContain('Abandoned plan:');
115
+ expect(abandonedProposal!.title.toLowerCase()).toContain('migrate');
116
+ });
117
+
118
+ it('should include scope details in proposal description when context has scope', () => {
119
+ const richContext = JSON.stringify({
120
+ objective: 'Refactor the billing reconciliation module',
121
+ scope: {
122
+ included: ['stripe-adapter', 'webhook-handler', 'ledger-service'],
123
+ excluded: ['billing-ui', 'invoice-generator'],
124
+ },
125
+ });
126
+
127
+ createSessionWithContext('rich-ctx-3', richContext, {
128
+ planId: 'plan-abc',
129
+ planOutcome: 'completed',
130
+ });
131
+
132
+ const result = runtime.brainIntelligence.extractKnowledge('rich-ctx-3');
133
+ const planProposal = result.proposals.find((p) => p.rule === 'plan_completed');
134
+
135
+ expect(planProposal).toBeDefined();
136
+ // Description should mention scope components, not just "can be reused for similar tasks"
137
+ expect(planProposal!.description.toLowerCase()).toMatch(/stripe|webhook|ledger/);
138
+ });
139
+ });
140
+
141
+ // ─── 2. Dedup: same rule + sessionId = 1 proposal ────────────────
142
+
143
+ describe('proposal deduplication', () => {
144
+ it('should produce exactly 1 proposal per rule per session', () => {
145
+ createSessionWithContext('dedup-1', 'some context', {
146
+ planId: 'plan-dedup',
147
+ planOutcome: 'completed',
148
+ });
149
+
150
+ // Extract twice on same session (reset extractedAt in between)
151
+ runtime.brainIntelligence.extractKnowledge('dedup-1');
152
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'dedup-1' });
153
+ runtime.brainIntelligence.extractKnowledge('dedup-1');
154
+
155
+ // Query all proposals for this session
156
+ const proposals = runtime.brainIntelligence.getProposals({
157
+ sessionId: 'dedup-1',
158
+ });
159
+
160
+ // Count proposals per rule
161
+ const ruleCounts = new Map<string, number>();
162
+ for (const p of proposals) {
163
+ ruleCounts.set(p.rule, (ruleCounts.get(p.rule) ?? 0) + 1);
164
+ }
165
+
166
+ // Each rule should appear at most once per session
167
+ for (const [rule, count] of ruleCounts) {
168
+ expect(count, `rule "${rule}" should appear exactly once`).toBe(1);
169
+ }
170
+ });
171
+ });
172
+
173
+ // ─── 3. long_session rule should not fire ─────────────────────────
174
+
175
+ describe('long_session rule removal', () => {
176
+ it('should NOT produce a long_session proposal', () => {
177
+ // Create session manually with backdated start time to simulate >30 min duration.
178
+ // SQLite datetime('now') uses 'YYYY-MM-DD HH:MM:SS' format (no T/Z), so match that.
179
+ const d = new Date(Date.now() - 35 * 60 * 1000);
180
+ const thirtyFiveMinAgo = d
181
+ .toISOString()
182
+ .replace('T', ' ')
183
+ .replace(/\.\d{3}Z$/, '');
184
+ const provider = runtime.vault.getProvider();
185
+
186
+ // Insert session directly with backdated started_at so auto-extract sees the long duration
187
+ provider.run(
188
+ `INSERT INTO brain_sessions (id, started_at, domain, context, tools_used, files_modified)
189
+ VALUES (?, ?, ?, ?, ?, ?)`,
190
+ ['long-sess-1', thirtyFiveMinAgo, 'testing', null, '[]', '[]'],
191
+ );
192
+
193
+ // End the session — this sets ended_at to now(), creating a >30 min gap
194
+ runtime.brainIntelligence.lifecycle({
195
+ action: 'end',
196
+ sessionId: 'long-sess-1',
197
+ toolsUsed: ['search'], // need at least 1 tool for auto-extract gate
198
+ });
199
+
200
+ // Reset extracted_at so we can manually extract and inspect
201
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'long-sess-1' });
202
+
203
+ const result = runtime.brainIntelligence.extractKnowledge('long-sess-1');
204
+
205
+ // long_session rule should no longer exist (removal in #360)
206
+ expect(result.rulesApplied).not.toContain('long_session');
207
+ expect(result.proposals.find((p) => p.rule === 'long_session')).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ // ─── 4. drift_detected rule ───────────────────────────────────────
212
+
213
+ describe('drift_detected rule', () => {
214
+ it('should fire when session context contains drift indicators', () => {
215
+ const contextWithDrift = JSON.stringify({
216
+ objective: 'Implement caching layer for API responses',
217
+ drift: {
218
+ items: [
219
+ {
220
+ type: 'added',
221
+ description: 'Added Redis fallback to in-memory cache',
222
+ impact: 'medium',
223
+ },
224
+ {
225
+ type: 'skipped',
226
+ description: 'Skipped cache invalidation webhooks',
227
+ impact: 'high',
228
+ },
229
+ ],
230
+ accuracyScore: 65,
231
+ },
232
+ });
233
+
234
+ createSessionWithContext('drift-1', contextWithDrift, {
235
+ planId: 'plan-cache',
236
+ planOutcome: 'completed',
237
+ });
238
+
239
+ const result = runtime.brainIntelligence.extractKnowledge('drift-1');
240
+
241
+ // A drift_detected rule should fire (to be added in #366)
242
+ expect(result.rulesApplied).toContain('drift_detected');
243
+ const driftProposal = result.proposals.find((p) => p.rule === 'drift_detected');
244
+ expect(driftProposal).toBeDefined();
245
+ expect(driftProposal!.type).toBe('anti-pattern');
246
+ expect(driftProposal!.description.toLowerCase()).toMatch(/drift|skipped|deviation/);
247
+ });
248
+
249
+ it('should NOT fire drift_detected when context has no drift', () => {
250
+ const cleanContext = JSON.stringify({
251
+ objective: 'Add unit tests for auth module',
252
+ scope: { included: ['auth'] },
253
+ });
254
+
255
+ createSessionWithContext('no-drift-1', cleanContext, {
256
+ planId: 'plan-tests',
257
+ planOutcome: 'completed',
258
+ });
259
+
260
+ const result = runtime.brainIntelligence.extractKnowledge('no-drift-1');
261
+ expect(result.rulesApplied).not.toContain('drift_detected');
262
+ });
263
+ });
264
+
265
+ // ─── 5. Context richness affects confidence ───────────────────────
266
+
267
+ describe('confidence based on context richness', () => {
268
+ it('should assign higher confidence to proposals with rich session context', () => {
269
+ // Session with rich context
270
+ const richContext = JSON.stringify({
271
+ objective: 'Build notification service',
272
+ scope: { included: ['notifications', 'email-adapter', 'push-adapter'] },
273
+ decisions: ['Use event-driven architecture', 'SNS for push notifications'],
274
+ });
275
+
276
+ createSessionWithContext('conf-rich', richContext, {
277
+ planId: 'plan-notify-rich',
278
+ planOutcome: 'completed',
279
+ });
280
+
281
+ // Session with no context
282
+ createSessionWithContext('conf-empty', '', {
283
+ planId: 'plan-notify-empty',
284
+ planOutcome: 'completed',
285
+ });
286
+
287
+ const richResult = runtime.brainIntelligence.extractKnowledge('conf-rich');
288
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'conf-empty' });
289
+ const emptyResult = runtime.brainIntelligence.extractKnowledge('conf-empty');
290
+
291
+ const richPlanProposal = richResult.proposals.find((p) => p.rule === 'plan_completed');
292
+ const emptyPlanProposal = emptyResult.proposals.find((p) => p.rule === 'plan_completed');
293
+
294
+ expect(richPlanProposal).toBeDefined();
295
+ expect(emptyPlanProposal).toBeDefined();
296
+
297
+ // Rich context should produce higher confidence than empty context
298
+ expect(richPlanProposal!.confidence).toBeGreaterThan(emptyPlanProposal!.confidence);
299
+ });
300
+
301
+ it('should assign lower confidence when session context is null', () => {
302
+ // Session with null context (no context field at all)
303
+ runtime.brainIntelligence.lifecycle({
304
+ action: 'start',
305
+ sessionId: 'conf-null',
306
+ planId: 'plan-null-ctx',
307
+ });
308
+ runtime.brainIntelligence.lifecycle({
309
+ action: 'end',
310
+ sessionId: 'conf-null',
311
+ planOutcome: 'completed',
312
+ });
313
+
314
+ runtime.brainIntelligence.resetExtracted({ sessionId: 'conf-null' });
315
+ const result = runtime.brainIntelligence.extractKnowledge('conf-null');
316
+ const planProposal = result.proposals.find((p) => p.rule === 'plan_completed');
317
+
318
+ expect(planProposal).toBeDefined();
319
+ // Without context, confidence should be below the current hardcoded 0.65
320
+ expect(planProposal!.confidence).toBeLessThan(0.65);
321
+ });
322
+ });
323
+ });
@@ -38,7 +38,6 @@ const SPREAD_MAX = 5;
38
38
  const RECENCY_DECAY_DAYS = 30;
39
39
  const EXTRACTION_TOOL_THRESHOLD = 3;
40
40
  const EXTRACTION_FILE_THRESHOLD = 3;
41
- const EXTRACTION_LONG_SESSION_MINUTES = 30;
42
41
  const EXTRACTION_HIGH_FEEDBACK_RATIO = 0.8;
43
42
  const AUTO_PROMOTE_THRESHOLD = 0.8;
44
43
  const AUTO_PROMOTE_PENDING_MIN = 0.4;
@@ -739,10 +738,18 @@ export class BrainIntelligence {
739
738
  for (const [tool, count] of toolCounts) {
740
739
  if (count >= EXTRACTION_TOOL_THRESHOLD) {
741
740
  rulesApplied.push('repeated_tool_usage');
741
+ const ctx = session.context ?? '';
742
+ const objective = this.extractObjective(ctx);
743
+ const toolTitle = objective
744
+ ? `Tool pattern: ${tool} (${count}x) during ${objective.slice(0, 60)}`
745
+ : `Frequent use of ${tool} (${count}x)`;
746
+ const toolDescription = objective
747
+ ? `Tool ${tool} used ${count} times while working on: ${objective}. This tool-task pairing may indicate a reusable workflow.`
748
+ : `Tool ${tool} was used ${count} times in session. Consider automating or abstracting this workflow.`;
742
749
  proposals.push(
743
750
  this.createProposal(sessionId, 'repeated_tool_usage', 'pattern', {
744
- title: `Frequent use of ${tool}`,
745
- description: `Tool ${tool} was used ${count} times in session. Consider automating or abstracting this workflow.`,
751
+ title: toolTitle,
752
+ description: toolDescription,
746
753
  confidence: Math.min(0.9, 0.5 + count * 0.1),
747
754
  }),
748
755
  );
@@ -766,57 +773,107 @@ export class BrainIntelligence {
766
773
  if (significantDirs.length > 0) {
767
774
  const [topDir, topFiles] = significantDirs.sort((a, b) => b[1].length - a[1].length)[0];
768
775
  rulesApplied.push('multi_file_edit');
776
+ const ctx = session.context ?? '';
777
+ const objective = this.extractObjective(ctx);
778
+ const isRefactor = /refactor|rename|move|extract|consolidat/i.test(ctx);
779
+ const isFeature = /feat|add|implement|create|new/i.test(ctx);
780
+ const inferredPattern = isRefactor
781
+ ? 'Refactoring'
782
+ : isFeature
783
+ ? 'Feature'
784
+ : 'Cross-cutting change';
785
+ const mfeTitle = objective
786
+ ? `${inferredPattern}: ${objective.slice(0, 70)}`
787
+ : `${inferredPattern} in ${topDir} (${topFiles.length} files)`;
788
+ const mfeDescription = objective
789
+ ? `${inferredPattern} across ${topFiles.length} files in ${topDir}: ${objective}`
790
+ : `Session modified ${topFiles.length} files in ${topDir}: ${topFiles.slice(0, 5).join(', ')}${topFiles.length > 5 ? '...' : ''}.`;
769
791
  proposals.push(
770
792
  this.createProposal(sessionId, 'multi_file_edit', 'pattern', {
771
- title: `Multi-file change pattern in ${topDir} (${topFiles.length} files)`,
772
- description: `Session modified ${topFiles.length} files in ${topDir}: ${topFiles.slice(0, 5).join(', ')}${topFiles.length > 5 ? '...' : ''}. This may indicate an architectural pattern.`,
793
+ title: mfeTitle,
794
+ description: mfeDescription,
773
795
  confidence: Math.min(0.8, 0.4 + topFiles.length * 0.05),
774
796
  }),
775
797
  );
776
798
  }
777
799
  }
778
800
 
779
- // Rule 3: Long session (>30min) neutral observation, not anti-pattern
780
- if (session.endedAt && session.startedAt) {
781
- const durationMs =
782
- new Date(session.endedAt).getTime() - new Date(session.startedAt).getTime();
783
- const durationMin = durationMs / 60000;
784
- if (durationMin > EXTRACTION_LONG_SESSION_MINUTES) {
785
- rulesApplied.push('long_session');
786
- proposals.push(
787
- this.createProposal(sessionId, 'long_session', 'pattern', {
788
- title: `Long session (${Math.round(durationMin)} minutes)`,
789
- description: `Session lasted ${Math.round(durationMin)} minutes. Deep work session — review if this duration was productive or indicates a need for better tooling.`,
790
- confidence: 0.3,
791
- }),
792
- );
793
- }
794
- }
795
-
796
- // Rule 4: Plan completed — moderate confidence to avoid auto-promoting generic entries
801
+ // Rule 3: Plan completed — parse session.context for actionable title + dynamic confidence
797
802
  if (session.planId && session.planOutcome === 'completed') {
798
803
  rulesApplied.push('plan_completed');
804
+ const ctx = session.context ?? '';
805
+ const objective = this.extractObjective(ctx);
806
+ const hasScope = /scope|included|excluded/i.test(ctx);
807
+ const hasCriteria = /criteria|acceptance|verification/i.test(ctx);
808
+ const confidence =
809
+ ctx.length > 0
810
+ ? hasScope && hasCriteria
811
+ ? 0.85
812
+ : hasScope || hasCriteria
813
+ ? 0.8
814
+ : 0.75
815
+ : 0.5;
816
+ const title = objective
817
+ ? `Workflow: ${objective.slice(0, 80)}`
818
+ : `Successful plan: ${session.planId}`;
819
+ const description = objective
820
+ ? `Completed: ${objective}${hasScope ? '. Scope and constraints documented in session context.' : ''}`
821
+ : `Plan ${session.planId} completed successfully. This workflow can be reused for similar tasks.`;
799
822
  proposals.push(
800
823
  this.createProposal(sessionId, 'plan_completed', 'workflow', {
801
- title: `Successful plan: ${session.planId}`,
802
- description: `Plan ${session.planId} completed successfully. This workflow can be reused for similar tasks.`,
803
- confidence: 0.65,
824
+ title,
825
+ description,
826
+ confidence,
804
827
  }),
805
828
  );
806
829
  }
807
830
 
808
- // Rule 5: Plan abandoned
831
+ // Rule 4: Plan abandoned — parse context for failure reason
809
832
  if (session.planId && session.planOutcome === 'abandoned') {
810
833
  rulesApplied.push('plan_abandoned');
834
+ const ctx = session.context ?? '';
835
+ const objective = this.extractObjective(ctx);
836
+ const hasFailureReason = /blocked|failed|wrong|mistake|abandoned|reverted|conflict/i.test(
837
+ ctx,
838
+ );
839
+ const confidence = ctx.length > 0 ? (hasFailureReason ? 0.85 : 0.75) : 0.5;
840
+ const title = objective
841
+ ? `Anti-pattern: ${objective.slice(0, 80)}`
842
+ : `Abandoned plan: ${session.planId}`;
843
+ const description = objective
844
+ ? `Abandoned: ${objective}${hasFailureReason ? '. Failure indicators found in session context — review for root cause.' : '. Review what went wrong to avoid repeating.'}`
845
+ : `Plan ${session.planId} was abandoned. Review what went wrong to avoid repeating in future sessions.`;
811
846
  proposals.push(
812
847
  this.createProposal(sessionId, 'plan_abandoned', 'anti-pattern', {
813
- title: `Abandoned plan: ${session.planId}`,
814
- description: `Plan ${session.planId} was abandoned. Review what went wrong to avoid repeating in future sessions.`,
815
- confidence: 0.7,
848
+ title,
849
+ description,
850
+ confidence,
816
851
  }),
817
852
  );
818
853
  }
819
854
 
855
+ // Rule 5: Drift detected — fires when plan completed but context contains drift indicators
856
+ if (session.planId && session.planOutcome === 'completed' && session.context) {
857
+ const driftPattern =
858
+ /drift|skipped|added.*unplanned|changed scope|out of scope|deviat|unplanned/i;
859
+ if (driftPattern.test(session.context)) {
860
+ rulesApplied.push('drift_detected');
861
+ const objective = this.extractObjective(session.context);
862
+ const driftMatch =
863
+ session.context.match(/drift[:\s]+(.{1,120})/i) ??
864
+ session.context.match(/skipped[:\s]+(.{1,120})/i) ??
865
+ session.context.match(/unplanned[:\s]+(.{1,120})/i);
866
+ const driftDetail = driftMatch ? driftMatch[1].trim() : 'scope changed during execution';
867
+ proposals.push(
868
+ this.createProposal(sessionId, 'drift_detected', 'anti-pattern', {
869
+ title: `Plan drift: ${objective ? objective.slice(0, 60) : session.planId} — ${driftDetail.slice(0, 40)}`,
870
+ description: `Plan ${objective ?? session.planId} completed with drift: ${driftDetail}. Review scope controls for future planning.`,
871
+ confidence: 0.8,
872
+ }),
873
+ );
874
+ }
875
+ }
876
+
820
877
  // Rule 6: High feedback ratio (>80% accept or dismiss)
821
878
  const feedbackRow = this.provider.get<{
822
879
  total: number;
@@ -1369,12 +1426,58 @@ export class BrainIntelligence {
1369
1426
  };
1370
1427
  }
1371
1428
 
1429
+ /**
1430
+ * Extract the objective from session context — first meaningful sentence or line.
1431
+ * Returns empty string if context is empty or unparseable.
1432
+ */
1433
+ private extractObjective(context: string): string {
1434
+ if (!context || context.trim().length === 0) return '';
1435
+ // Try to find an "Objective:" line
1436
+ const objMatch = context.match(/objective[:\s]+(.+)/i);
1437
+ if (objMatch) return objMatch[1].trim().replace(/\s+/g, ' ');
1438
+ // Fall back to first non-empty line
1439
+ const firstLine = context
1440
+ .split('\n')
1441
+ .map((l) => l.trim())
1442
+ .find((l) => l.length > 0);
1443
+ return firstLine ? firstLine.replace(/\s+/g, ' ') : '';
1444
+ }
1445
+
1372
1446
  private createProposal(
1373
1447
  sessionId: string,
1374
1448
  rule: string,
1375
1449
  type: 'pattern' | 'anti-pattern' | 'workflow',
1376
1450
  data: { title: string; description: string; confidence: number },
1377
1451
  ): KnowledgeProposal {
1452
+ // Dedup guard: skip if a proposal with the same rule + sessionId already exists
1453
+ const existing = this.provider.get<{
1454
+ id: string;
1455
+ session_id: string;
1456
+ rule: string;
1457
+ type: string;
1458
+ title: string;
1459
+ description: string;
1460
+ confidence: number;
1461
+ promoted: number;
1462
+ created_at: string;
1463
+ }>('SELECT * FROM brain_proposals WHERE session_id = ? AND rule = ? LIMIT 1', [
1464
+ sessionId,
1465
+ rule,
1466
+ ]);
1467
+ if (existing) {
1468
+ return {
1469
+ id: existing.id,
1470
+ sessionId: existing.session_id,
1471
+ rule: existing.rule,
1472
+ type: existing.type as 'pattern' | 'anti-pattern' | 'workflow',
1473
+ title: existing.title,
1474
+ description: existing.description,
1475
+ confidence: existing.confidence,
1476
+ promoted: existing.promoted === 1,
1477
+ createdAt: existing.created_at,
1478
+ };
1479
+ }
1480
+
1378
1481
  const id = randomUUID();
1379
1482
  this.provider.run(
1380
1483
  `INSERT INTO brain_proposals (id, session_id, rule, type, title, description, confidence)
@@ -229,14 +229,17 @@ export class LearningRadar {
229
229
  }
230
230
 
231
231
  /**
232
- * Dismiss a pending candidate — mark it as not worth capturing.
232
+ * Dismiss one or more pending candidates — mark them as not worth capturing.
233
233
  */
234
- dismiss(candidateId: number): { dismissed: boolean } {
234
+ dismiss(candidateIds: number | number[]): { dismissed: number } {
235
+ const ids = Array.isArray(candidateIds) ? candidateIds : [candidateIds];
236
+ if (ids.length === 0) return { dismissed: 0 };
237
+ const placeholders = ids.map(() => '?').join(',');
235
238
  const result = this.provider.run(
236
- "UPDATE radar_candidates SET status = 'dismissed' WHERE id = ? AND status = 'pending'",
237
- [candidateId],
239
+ `UPDATE radar_candidates SET status = 'dismissed' WHERE id IN (${placeholders}) AND status = 'pending'`,
240
+ ids,
238
241
  );
239
- return { dismissed: result.changes > 0 };
242
+ return { dismissed: result.changes };
240
243
  }
241
244
 
242
245
  /**
@@ -335,7 +335,7 @@ describe('Ambient learning radar (#208)', () => {
335
335
  const pending = candidates.find((c) => c.title.includes('stale cache'));
336
336
  expect(pending).toBeDefined();
337
337
  const result = learningRadar.dismiss(pending!.id);
338
- expect(result.dismissed).toBe(true);
338
+ expect(result.dismissed).toBe(1);
339
339
  });
340
340
 
341
341
  it('getStats returns radar statistics', () => {
@@ -35,7 +35,7 @@ describe('IntentRouter', () => {
35
35
  describe('construction', () => {
36
36
  it('seeds 10 default modes on first creation', () => {
37
37
  const modes = router.getModes();
38
- expect(modes.length).toBe(10);
38
+ expect(modes.length).toBe(11);
39
39
  });
40
40
 
41
41
  it('starts in GENERAL-MODE', () => {
@@ -44,7 +44,7 @@ describe('IntentRouter', () => {
44
44
 
45
45
  it('is idempotent — second instance does not duplicate modes', () => {
46
46
  const router2 = new IntentRouter(vault);
47
- expect(router2.getModes().length).toBe(10);
47
+ expect(router2.getModes().length).toBe(11);
48
48
  });
49
49
  });
50
50
 
@@ -199,7 +199,7 @@ describe('IntentRouter', () => {
199
199
  it('adds a new mode to the database', () => {
200
200
  router.registerMode(customMode);
201
201
  const modes = router.getModes();
202
- expect(modes.length).toBe(11);
202
+ expect(modes.length).toBe(12);
203
203
  const found = modes.find((m) => m.mode === 'CUSTOM-MODE');
204
204
  expect(found).toBeDefined();
205
205
  expect(found!.keywords).toEqual(['custom', 'special']);
@@ -338,6 +338,76 @@ describe('IntentRouter', () => {
338
338
  });
339
339
  });
340
340
 
341
+ // ─── YOLO-MODE ───────────────────────────────────────────────
342
+
343
+ describe('YOLO-MODE', () => {
344
+ it('route_intent with "yolo" returns YOLO-MODE', () => {
345
+ const result = router.routeIntent('go yolo on this task');
346
+ expect(result.intent).toBe('yolo');
347
+ expect(result.mode).toBe('YOLO-MODE');
348
+ expect(result.matchedKeywords).toContain('yolo');
349
+ });
350
+
351
+ it('morph to YOLO-MODE succeeds when hook pack is installed', () => {
352
+ const result = router.morph('YOLO-MODE', { hookPackInstalled: true });
353
+ expect(result.previousMode).toBe('GENERAL-MODE');
354
+ expect(result.currentMode).toBe('YOLO-MODE');
355
+ expect(result.behaviorRules.length).toBe(5);
356
+ expect(result.blocked).toBeUndefined();
357
+ expect(result.error).toBeUndefined();
358
+ });
359
+
360
+ it('morph to YOLO-MODE fails when hook pack is missing', () => {
361
+ const result = router.morph('YOLO-MODE');
362
+ expect(result.blocked).toBe(true);
363
+ expect(result.error).toContain('yolo-safety hook pack');
364
+ expect(result.error).toContain('soleri hooks add-pack yolo-safety');
365
+ expect(result.currentMode).toBe('GENERAL-MODE'); // unchanged
366
+ expect(router.getCurrentMode()).toBe('GENERAL-MODE'); // not switched
367
+ });
368
+
369
+ it('morph to YOLO-MODE fails when hookPackInstalled is explicitly false', () => {
370
+ const result = router.morph('YOLO-MODE', { hookPackInstalled: false });
371
+ expect(result.blocked).toBe(true);
372
+ expect(result.error).toContain('yolo-safety hook pack');
373
+ expect(router.getCurrentMode()).toBe('GENERAL-MODE');
374
+ });
375
+
376
+ it('morph to other modes is unaffected by the gate', () => {
377
+ const result = router.morph('BUILD-MODE');
378
+ expect(result.currentMode).toBe('BUILD-MODE');
379
+ expect(result.blocked).toBeUndefined();
380
+ expect(result.error).toBeUndefined();
381
+ expect(router.getCurrentMode()).toBe('BUILD-MODE');
382
+ });
383
+
384
+ it('get_behavior_rules returns 5 rules', () => {
385
+ const rules = router.getBehaviorRules('YOLO-MODE');
386
+ expect(rules).toHaveLength(5);
387
+ expect(rules[0]).toContain('Skip plan approval gates');
388
+ expect(rules[1]).toContain('orchestrate_complete');
389
+ expect(rules[2]).toContain('vault gather-before-execute');
390
+ expect(rules[3]).toContain('Hook pack must be installed');
391
+ expect(rules[4]).toContain('exit YOLO');
392
+ });
393
+
394
+ it('all keywords route to YOLO-MODE', () => {
395
+ const keywords = [
396
+ 'yolo',
397
+ 'autonomous',
398
+ 'fire-and-forget',
399
+ 'hands-off',
400
+ 'no-approval',
401
+ 'skip-gates',
402
+ 'full-auto',
403
+ ];
404
+ for (const kw of keywords) {
405
+ const result = router.routeIntent(kw);
406
+ expect(result.mode).toBe('YOLO-MODE');
407
+ }
408
+ });
409
+ });
410
+
341
411
  // ─── getModes ─────────────────────────────────────────────────
342
412
 
343
413
  describe('getModes', () => {