@soleri/core 9.3.1 → 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 (172) 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/index.d.ts +4 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +3 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/operator/operator-context-store.d.ts +54 -0
  41. package/dist/operator/operator-context-store.d.ts.map +1 -0
  42. package/dist/operator/operator-context-store.js +434 -0
  43. package/dist/operator/operator-context-store.js.map +1 -0
  44. package/dist/operator/operator-context-types.d.ts +101 -0
  45. package/dist/operator/operator-context-types.d.ts.map +1 -0
  46. package/dist/operator/operator-context-types.js +27 -0
  47. package/dist/operator/operator-context-types.js.map +1 -0
  48. package/dist/packs/index.d.ts +2 -2
  49. package/dist/packs/index.d.ts.map +1 -1
  50. package/dist/packs/index.js +1 -1
  51. package/dist/packs/index.js.map +1 -1
  52. package/dist/packs/lockfile.d.ts +3 -0
  53. package/dist/packs/lockfile.d.ts.map +1 -1
  54. package/dist/packs/lockfile.js.map +1 -1
  55. package/dist/packs/types.d.ts +8 -2
  56. package/dist/packs/types.d.ts.map +1 -1
  57. package/dist/packs/types.js +6 -0
  58. package/dist/packs/types.js.map +1 -1
  59. package/dist/planning/plan-lifecycle.d.ts +12 -1
  60. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  61. package/dist/planning/plan-lifecycle.js +52 -19
  62. package/dist/planning/plan-lifecycle.js.map +1 -1
  63. package/dist/planning/planner-types.d.ts +6 -0
  64. package/dist/planning/planner-types.d.ts.map +1 -1
  65. package/dist/planning/planner.d.ts +21 -1
  66. package/dist/planning/planner.d.ts.map +1 -1
  67. package/dist/planning/planner.js +62 -3
  68. package/dist/planning/planner.js.map +1 -1
  69. package/dist/planning/task-complexity-assessor.d.ts.map +1 -1
  70. package/dist/planning/task-complexity-assessor.js.map +1 -1
  71. package/dist/plugins/types.d.ts +18 -18
  72. package/dist/runtime/admin-ops.d.ts +1 -1
  73. package/dist/runtime/admin-ops.d.ts.map +1 -1
  74. package/dist/runtime/admin-ops.js +100 -3
  75. package/dist/runtime/admin-ops.js.map +1 -1
  76. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  77. package/dist/runtime/admin-setup-ops.js +19 -9
  78. package/dist/runtime/admin-setup-ops.js.map +1 -1
  79. package/dist/runtime/capture-ops.d.ts.map +1 -1
  80. package/dist/runtime/capture-ops.js +35 -7
  81. package/dist/runtime/capture-ops.js.map +1 -1
  82. package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
  83. package/dist/runtime/facades/brain-facade.js +4 -2
  84. package/dist/runtime/facades/brain-facade.js.map +1 -1
  85. package/dist/runtime/facades/control-facade.d.ts.map +1 -1
  86. package/dist/runtime/facades/control-facade.js +8 -2
  87. package/dist/runtime/facades/control-facade.js.map +1 -1
  88. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  89. package/dist/runtime/facades/curator-facade.js +13 -0
  90. package/dist/runtime/facades/curator-facade.js.map +1 -1
  91. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  92. package/dist/runtime/facades/memory-facade.js +10 -12
  93. package/dist/runtime/facades/memory-facade.js.map +1 -1
  94. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  95. package/dist/runtime/facades/orchestrate-facade.js +36 -1
  96. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  97. package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
  98. package/dist/runtime/facades/plan-facade.js +20 -4
  99. package/dist/runtime/facades/plan-facade.js.map +1 -1
  100. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  101. package/dist/runtime/orchestrate-ops.js +71 -4
  102. package/dist/runtime/orchestrate-ops.js.map +1 -1
  103. package/dist/runtime/plan-feedback-helper.d.ts +21 -0
  104. package/dist/runtime/plan-feedback-helper.d.ts.map +1 -0
  105. package/dist/runtime/plan-feedback-helper.js +52 -0
  106. package/dist/runtime/plan-feedback-helper.js.map +1 -0
  107. package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
  108. package/dist/runtime/planning-extra-ops.js +73 -34
  109. package/dist/runtime/planning-extra-ops.js.map +1 -1
  110. package/dist/runtime/session-briefing.d.ts.map +1 -1
  111. package/dist/runtime/session-briefing.js +9 -1
  112. package/dist/runtime/session-briefing.js.map +1 -1
  113. package/dist/runtime/types.d.ts +3 -0
  114. package/dist/runtime/types.d.ts.map +1 -1
  115. package/dist/skills/sync-skills.d.ts.map +1 -1
  116. package/dist/skills/sync-skills.js +13 -7
  117. package/dist/skills/sync-skills.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/brain/brain-intelligence.test.ts +30 -0
  120. package/src/brain/extraction-quality.test.ts +323 -0
  121. package/src/brain/intelligence.ts +133 -30
  122. package/src/brain/learning-radar.ts +8 -5
  123. package/src/brain/second-brain-features.test.ts +1 -1
  124. package/src/control/intent-router.test.ts +73 -3
  125. package/src/control/intent-router.ts +38 -1
  126. package/src/control/types.ts +13 -2
  127. package/src/curator/curator.test.ts +92 -0
  128. package/src/curator/curator.ts +29 -1
  129. package/src/curator/schema.ts +8 -0
  130. package/src/domain-packs/types.ts +8 -0
  131. package/src/engine/module-manifest.test.ts +8 -2
  132. package/src/engine/module-manifest.ts +3 -3
  133. package/src/engine/register-engine.test.ts +73 -1
  134. package/src/engine/register-engine.ts +61 -1
  135. package/src/facades/types.ts +5 -0
  136. package/src/index.ts +22 -0
  137. package/src/operator/operator-context-store.test.ts +698 -0
  138. package/src/operator/operator-context-store.ts +569 -0
  139. package/src/operator/operator-context-types.ts +139 -0
  140. package/src/packs/index.ts +3 -1
  141. package/src/packs/lockfile.ts +3 -0
  142. package/src/packs/types.ts +9 -0
  143. package/src/planning/plan-lifecycle.ts +80 -22
  144. package/src/planning/planner-types.ts +6 -0
  145. package/src/planning/planner.ts +74 -4
  146. package/src/planning/task-complexity-assessor.test.ts +6 -2
  147. package/src/planning/task-complexity-assessor.ts +1 -4
  148. package/src/runtime/admin-ops.test.ts +139 -6
  149. package/src/runtime/admin-ops.ts +104 -3
  150. package/src/runtime/admin-setup-ops.ts +30 -10
  151. package/src/runtime/capture-ops.test.ts +84 -0
  152. package/src/runtime/capture-ops.ts +35 -7
  153. package/src/runtime/facades/admin-facade.test.ts +1 -1
  154. package/src/runtime/facades/brain-facade.ts +6 -3
  155. package/src/runtime/facades/control-facade.ts +10 -2
  156. package/src/runtime/facades/curator-facade.ts +18 -0
  157. package/src/runtime/facades/memory-facade.test.ts +14 -12
  158. package/src/runtime/facades/memory-facade.ts +10 -12
  159. package/src/runtime/facades/orchestrate-facade.ts +33 -1
  160. package/src/runtime/facades/plan-facade.test.ts +213 -0
  161. package/src/runtime/facades/plan-facade.ts +23 -4
  162. package/src/runtime/orchestrate-ops.test.ts +202 -2
  163. package/src/runtime/orchestrate-ops.ts +85 -4
  164. package/src/runtime/plan-feedback-helper.test.ts +173 -0
  165. package/src/runtime/plan-feedback-helper.ts +63 -0
  166. package/src/runtime/planning-extra-ops.test.ts +43 -1
  167. package/src/runtime/planning-extra-ops.ts +96 -33
  168. package/src/runtime/session-briefing.test.ts +1 -0
  169. package/src/runtime/session-briefing.ts +10 -1
  170. package/src/runtime/types.ts +3 -0
  171. package/src/skills/sync-skills.ts +14 -7
  172. package/vitest.config.ts +1 -0
@@ -239,6 +239,66 @@ describe('plan-facade', () => {
239
239
  expect(data.iterated).toBe(true);
240
240
  });
241
241
 
242
+ it('plan_iterate with decisions persists them', async () => {
243
+ const createResult = await executeOp(ops, 'create_plan', {
244
+ objective: 'Decisions test',
245
+ scope: 'test',
246
+ });
247
+ const planId = ((createResult.data as Record<string, unknown>).plan as Record<string, unknown>)
248
+ .id as string;
249
+
250
+ const result = await executeOp(ops, 'plan_iterate', {
251
+ planId,
252
+ decisions: [{ decision: 'Use FTS5', rationale: 'Performance' }],
253
+ });
254
+ expect(result.success).toBe(true);
255
+ const data = result.data as { iterated: boolean; plan: Record<string, unknown> };
256
+ expect(data.iterated).toBe(true);
257
+ const decisions = data.plan.decisions as Array<Record<string, string>>;
258
+ expect(decisions).toHaveLength(1);
259
+ expect(decisions[0].decision).toBe('Use FTS5');
260
+ });
261
+
262
+ it('plan_iterate with alternatives persists them', async () => {
263
+ const createResult = await executeOp(ops, 'create_plan', {
264
+ objective: 'Alternatives test',
265
+ scope: 'test',
266
+ });
267
+ const planId = ((createResult.data as Record<string, unknown>).plan as Record<string, unknown>)
268
+ .id as string;
269
+
270
+ const result = await executeOp(ops, 'plan_iterate', {
271
+ planId,
272
+ alternatives: [
273
+ { approach: 'Alt A', pros: ['fast'], cons: ['fragile'], rejected_reason: 'Too risky' },
274
+ { approach: 'Alt B', pros: ['safe'], cons: ['slow'], rejected_reason: 'Too slow' },
275
+ ],
276
+ });
277
+ expect(result.success).toBe(true);
278
+ const data = result.data as { iterated: boolean; plan: Record<string, unknown> };
279
+ expect(data.iterated).toBe(true);
280
+ const alternatives = data.plan.alternatives as Array<Record<string, unknown>>;
281
+ expect(alternatives).toHaveLength(2);
282
+ expect(alternatives[0].approach).toBe('Alt A');
283
+ });
284
+
285
+ it('plan_iterate with no effective changes returns iterated: false', async () => {
286
+ const createResult = await executeOp(ops, 'create_plan', {
287
+ objective: 'No-op test',
288
+ scope: 'test',
289
+ });
290
+ const planId = ((createResult.data as Record<string, unknown>).plan as Record<string, unknown>)
291
+ .id as string;
292
+
293
+ const result = await executeOp(ops, 'plan_iterate', {
294
+ planId,
295
+ });
296
+ expect(result.success).toBe(true);
297
+ const data = result.data as { iterated: boolean; reason?: string };
298
+ expect(data.iterated).toBe(false);
299
+ expect(data.reason).toBe('no changes detected');
300
+ });
301
+
242
302
  // ─── plan_stats ────────────────────────────────────────────────
243
303
 
244
304
  it('plan_stats returns statistics', async () => {
@@ -280,4 +340,157 @@ describe('plan-facade', () => {
280
340
  expect(result.success).toBe(true);
281
341
  expect((result.data as Record<string, unknown>).error).toContain('not found');
282
342
  });
343
+
344
+ // ─── create_plan vault enrichment ─────────────────────────────
345
+
346
+ it('create_plan enriches decisions with vault patterns when matches exist', async () => {
347
+ // Seed vault with a relevant entry
348
+ vault.add({
349
+ title: 'SQLite FTS5 search pattern',
350
+ description: 'Use FTS5 with porter tokenizer for all text search',
351
+ content: 'Use FTS5 with porter tokenizer for all text search',
352
+ type: 'pattern',
353
+ domain: 'architecture',
354
+ severity: 'suggestion',
355
+ tags: ['sqlite', 'search'],
356
+ });
357
+
358
+ const result = await executeOp(ops, 'create_plan', {
359
+ objective: 'Implement text search with SQLite FTS5',
360
+ scope: 'packages/core/src/vault',
361
+ });
362
+ expect(result.success).toBe(true);
363
+ const data = result.data as {
364
+ created: boolean;
365
+ plan: Record<string, unknown>;
366
+ vaultEntryIds: string[];
367
+ };
368
+ expect(data.created).toBe(true);
369
+ expect(data.vaultEntryIds.length).toBeGreaterThan(0);
370
+ // Decisions should contain vault pattern references with entryId markers
371
+ const decisions = data.plan.decisions as string[];
372
+ const vaultDecisions = decisions.filter((d) => d.startsWith('Vault pattern:'));
373
+ expect(vaultDecisions.length).toBeGreaterThan(0);
374
+ // Each vault decision should have an [entryId:...] marker for brain feedback
375
+ for (const vd of vaultDecisions) {
376
+ expect(vd).toMatch(/\[entryId:[^\]]+\]/);
377
+ }
378
+ });
379
+
380
+ it('create_plan works without vault matches (empty vault)', async () => {
381
+ // Fresh vault, no entries
382
+ const result = await executeOp(ops, 'create_plan', {
383
+ objective: 'Something completely unrelated xyz123',
384
+ scope: 'test',
385
+ });
386
+ expect(result.success).toBe(true);
387
+ const data = result.data as {
388
+ created: boolean;
389
+ vaultEntryIds: string[];
390
+ };
391
+ expect(data.created).toBe(true);
392
+ expect(data.vaultEntryIds).toEqual([]);
393
+ });
394
+
395
+ it('create_plan preserves user decisions alongside vault enrichment', async () => {
396
+ vault.add({
397
+ title: 'Testing pattern',
398
+ description: 'Always write tests before implementation',
399
+ content: 'Always write tests before implementation',
400
+ type: 'pattern',
401
+ domain: 'testing',
402
+ severity: 'suggestion',
403
+ tags: ['tdd'],
404
+ });
405
+
406
+ const result = await executeOp(ops, 'create_plan', {
407
+ objective: 'Add testing patterns to the project',
408
+ scope: 'packages/core',
409
+ decisions: ['Use vitest as test runner'],
410
+ });
411
+ expect(result.success).toBe(true);
412
+ const data = result.data as { plan: Record<string, unknown>; vaultEntryIds: string[] };
413
+ const decisions = data.plan.decisions as string[];
414
+ // User decision preserved
415
+ expect(decisions).toContain('Use vitest as test runner');
416
+ // Vault enrichment added
417
+ if (data.vaultEntryIds.length > 0) {
418
+ const vaultDecisions = decisions.filter((d) => d.startsWith('Vault pattern:'));
419
+ expect(vaultDecisions.length).toBeGreaterThan(0);
420
+ }
421
+ });
422
+
423
+ // ─── plan_close_stale ─────────────────────────────────────────
424
+
425
+ it('plan_close_stale op is registered', () => {
426
+ expect([...ops.keys()]).toContain('plan_close_stale');
427
+ });
428
+
429
+ it('plan_close_stale returns no plans when none are stale', async () => {
430
+ const result = await executeOp(ops, 'plan_close_stale', {});
431
+ expect(result.success).toBe(true);
432
+ const data = result.data as { closed: number; plans: unknown[] };
433
+ expect(data.closed).toBe(0);
434
+ expect(data.plans).toHaveLength(0);
435
+ });
436
+
437
+ it('plan_close_stale with olderThanMs: 0 closes all non-terminal plans', async () => {
438
+ // Create a draft plan
439
+ await executeOp(ops, 'create_plan', { objective: 'Stale test', scope: 'test' });
440
+
441
+ // Close immediately (olderThanMs: 0 means close everything)
442
+ const result = await executeOp(ops, 'plan_close_stale', { olderThanMs: 0 });
443
+ expect(result.success).toBe(true);
444
+ const data = result.data as { closed: number; plans: Array<{ id: string; reason: string }> };
445
+ expect(data.closed).toBeGreaterThanOrEqual(1);
446
+ expect(data.plans[0].reason).toContain('ttl-expired');
447
+ });
448
+
449
+ // ─── Planner.closeStale() ─────────────────────────────────────
450
+
451
+ it('closeStale closes draft plans older than TTL', () => {
452
+ const runtime = makeRuntime(vault);
453
+ const planner = runtime.planner;
454
+
455
+ // Create a plan — it's immediately a draft
456
+ planner.create({ objective: 'Old draft', scope: 'test' });
457
+
458
+ // Close with olderThanMs: 0 to force-close regardless of age
459
+ const result = planner.closeStale(0);
460
+ expect(result.closedPlans.length).toBeGreaterThanOrEqual(1);
461
+ expect(result.closedPlans[0].previousStatus).toBe('draft');
462
+ expect(result.closedPlans[0].reason).toContain('ttl-expired');
463
+ });
464
+
465
+ it('closeStale does not close completed plans', () => {
466
+ const runtime = makeRuntime(vault);
467
+ const planner = runtime.planner;
468
+
469
+ // Create and complete a plan
470
+ const plan = planner.create({
471
+ objective: 'Completed plan',
472
+ scope: 'test',
473
+ decisions: ['d1', 'd2'],
474
+ tasks: [{ title: 'T1', description: 'd1' }],
475
+ });
476
+ planner.approve(plan.id);
477
+ planner.startExecution(plan.id);
478
+ planner.complete(plan.id);
479
+
480
+ // closeStale should not touch it
481
+ const result = planner.closeStale(0);
482
+ expect(result.closedPlans.filter((p) => p.id === plan.id)).toHaveLength(0);
483
+ });
484
+
485
+ it('closeStale respects default TTL — does not close fresh drafts', () => {
486
+ const runtime = makeRuntime(vault);
487
+ const planner = runtime.planner;
488
+
489
+ // Create a fresh plan
490
+ planner.create({ objective: 'Fresh draft', scope: 'test' });
491
+
492
+ // Close with default TTL (30 min) — fresh plan should NOT be closed
493
+ const result = planner.closeStale();
494
+ expect(result.closedPlans).toHaveLength(0);
495
+ });
283
496
  });
@@ -12,7 +12,7 @@ import { createChainOps } from '../chain-ops.js';
12
12
  import { PlanGradeRejectionError } from '../../planning/planner.js';
13
13
 
14
14
  export function createPlanFacadeOps(runtime: AgentRuntime): OpDefinition[] {
15
- const { planner } = runtime;
15
+ const { planner, vault } = runtime;
16
16
 
17
17
  return [
18
18
  // ─── Planning (inline from core-ops.ts) ─────────────────────
@@ -45,16 +45,35 @@ export function createPlanFacadeOps(runtime: AgentRuntime): OpDefinition[] {
45
45
  .describe('Rejected alternative approaches — plans with 2+ alternatives score higher'),
46
46
  }),
47
47
  handler: async (params) => {
48
+ const objective = params.objective as string;
49
+ const decisions = ((params.decisions as string[]) ?? []).slice();
50
+
51
+ // Vault enrichment: search for patterns matching the objective
52
+ let vaultEntryIds: string[] = [];
53
+ try {
54
+ const results = vault.search(objective, { limit: 5 });
55
+ if (results.length > 0) {
56
+ vaultEntryIds = results.map((r) => r.entry.id);
57
+ for (const r of results) {
58
+ decisions.push(
59
+ `Vault pattern: ${r.entry.title ?? r.entry.id} (score: ${r.score.toFixed(2)}) [entryId:${r.entry.id}]`,
60
+ );
61
+ }
62
+ }
63
+ } catch {
64
+ // Vault search failed — proceed without enrichment
65
+ }
66
+
48
67
  const plan = planner.create({
49
- objective: params.objective as string,
68
+ objective,
50
69
  scope: params.scope as string,
51
- decisions: (params.decisions as string[]) ?? [],
70
+ decisions,
52
71
  tasks: (params.tasks as Array<{ title: string; description: string }>) ?? [],
53
72
  alternatives: params.alternatives as
54
73
  | Array<{ approach: string; pros: string[]; cons: string[]; rejected_reason: string }>
55
74
  | undefined,
56
75
  });
57
- return { created: true, plan };
76
+ return { created: true, plan, vaultEntryIds };
58
77
  },
59
78
  },
60
79
  {
@@ -1,8 +1,17 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
4
  import { createOrchestrateOps } from './orchestrate-ops.js';
3
5
  import { assessTaskComplexity } from '../planning/task-complexity-assessor.js';
4
6
  import type { AgentRuntime } from './types.js';
5
7
 
8
+ vi.mock('node:fs', () => ({
9
+ default: {
10
+ mkdirSync: vi.fn(),
11
+ writeFileSync: vi.fn(),
12
+ },
13
+ }));
14
+
6
15
  // ---------------------------------------------------------------------------
7
16
  // Mocks for external modules
8
17
  // ---------------------------------------------------------------------------
@@ -441,8 +450,6 @@ describe('createOrchestrateOps', () => {
441
450
  });
442
451
 
443
452
  it('orchestrate_complete captures knowledge in both paths', async () => {
444
- const completeOp = findOp(ops, 'orchestrate_complete');
445
-
446
453
  // ── Simple path (no planId) ──
447
454
  vi.clearAllMocks();
448
455
  rt = mockRuntime();
@@ -492,6 +499,199 @@ describe('createOrchestrateOps', () => {
492
499
  expect(result.reasoning.length).toBeGreaterThan(0);
493
500
  });
494
501
 
502
+ it('orchestrate_complete compounds operator signals when provided', async () => {
503
+ const compoundSignalsMock = vi.fn();
504
+ (rt as Record<string, unknown>).operatorContextStore = {
505
+ compoundSignals: compoundSignalsMock,
506
+ hasDrifted: vi.fn().mockReturnValue(false),
507
+ renderContextFile: vi.fn(),
508
+ };
509
+ ops = createOrchestrateOps(rt);
510
+
511
+ const op = findOp(ops, 'orchestrate_complete');
512
+ await op.handler({
513
+ sessionId: 'session-1',
514
+ outcome: 'completed',
515
+ operatorSignals: {
516
+ expertise: [{ topic: 'typescript', level: 'expert', confidence: 0.9 }],
517
+ corrections: [{ rule: 'use conventional commits', scope: 'global' }],
518
+ interests: [{ tag: 'coffee' }],
519
+ patterns: [{ pattern: 'prefers small PRs', frequency: 'frequent' }],
520
+ },
521
+ });
522
+
523
+ expect(compoundSignalsMock).toHaveBeenCalledWith(
524
+ {
525
+ expertise: [{ topic: 'typescript', level: 'expert', confidence: 0.9 }],
526
+ corrections: [{ rule: 'use conventional commits', scope: 'global' }],
527
+ interests: [{ tag: 'coffee' }],
528
+ patterns: [{ pattern: 'prefers small PRs', frequency: 'frequent' }],
529
+ },
530
+ 'session-1',
531
+ );
532
+ });
533
+
534
+ it('orchestrate_complete handles empty operator signals gracefully', async () => {
535
+ const compoundSignalsMock = vi.fn();
536
+ (rt as Record<string, unknown>).operatorContextStore = {
537
+ compoundSignals: compoundSignalsMock,
538
+ hasDrifted: vi.fn().mockReturnValue(false),
539
+ renderContextFile: vi.fn(),
540
+ };
541
+ ops = createOrchestrateOps(rt);
542
+
543
+ const op = findOp(ops, 'orchestrate_complete');
544
+ await op.handler({
545
+ sessionId: 'session-1',
546
+ outcome: 'completed',
547
+ operatorSignals: {},
548
+ });
549
+
550
+ // Empty object with default arrays should be passed through
551
+ expect(compoundSignalsMock).toHaveBeenCalledTimes(1);
552
+ const [passedSignals, passedSessionId] = compoundSignalsMock.mock.calls[0];
553
+ expect(passedSessionId).toBe('session-1');
554
+ // Zod defaults produce empty arrays for each field
555
+ expect(passedSignals).toBeDefined();
556
+ expect(Array.isArray(passedSignals.expertise ?? [])).toBe(true);
557
+ expect(Array.isArray(passedSignals.corrections ?? [])).toBe(true);
558
+ expect(Array.isArray(passedSignals.interests ?? [])).toBe(true);
559
+ expect(Array.isArray(passedSignals.patterns ?? [])).toBe(true);
560
+ });
561
+
562
+ it('orchestrate_complete works when operatorContextStore not available', async () => {
563
+ // Ensure no operatorContextStore on runtime (backward compat)
564
+ delete (rt as Record<string, unknown>).operatorContextStore;
565
+ ops = createOrchestrateOps(rt);
566
+
567
+ const op = findOp(ops, 'orchestrate_complete');
568
+ const result = (await op.handler({
569
+ sessionId: 'session-1',
570
+ outcome: 'completed',
571
+ operatorSignals: {
572
+ expertise: [{ topic: 'react', level: 'intermediate' }],
573
+ corrections: [],
574
+ interests: [],
575
+ patterns: [],
576
+ },
577
+ })) as Record<string, unknown>;
578
+
579
+ // Should complete normally without errors
580
+ expect(result).toHaveProperty('plan');
581
+ expect(result).toHaveProperty('session');
582
+ });
583
+
584
+ it('orchestrate_complete re-renders context file when drift detected', async () => {
585
+ const compoundSignalsMock = vi.fn();
586
+ const hasDriftedMock = vi.fn().mockReturnValue(true);
587
+ const renderContextFileMock = vi
588
+ .fn()
589
+ .mockReturnValue(
590
+ '# Operator Context\n\n**Expertise:** typescript (expert, 1 sessions, confidence 0.90).',
591
+ );
592
+ (rt as Record<string, unknown>).operatorContextStore = {
593
+ compoundSignals: compoundSignalsMock,
594
+ hasDrifted: hasDriftedMock,
595
+ renderContextFile: renderContextFileMock,
596
+ };
597
+ rt.config.agentDir = '/tmp/test-agent';
598
+ ops = createOrchestrateOps(rt);
599
+
600
+ const op = findOp(ops, 'orchestrate_complete');
601
+ await op.handler({
602
+ sessionId: 'session-1',
603
+ outcome: 'completed',
604
+ operatorSignals: {
605
+ expertise: [{ topic: 'typescript', level: 'expert', confidence: 0.9 }],
606
+ corrections: [],
607
+ interests: [],
608
+ patterns: [],
609
+ },
610
+ });
611
+
612
+ expect(compoundSignalsMock).toHaveBeenCalled();
613
+ expect(hasDriftedMock).toHaveBeenCalled();
614
+ expect(renderContextFileMock).toHaveBeenCalled();
615
+ expect(fs.mkdirSync).toHaveBeenCalledWith(path.join('/tmp/test-agent', 'instructions'), {
616
+ recursive: true,
617
+ });
618
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
619
+ path.join('/tmp/test-agent', 'instructions', 'operator-context.md'),
620
+ '# Operator Context\n\n**Expertise:** typescript (expert, 1 sessions, confidence 0.90).',
621
+ 'utf-8',
622
+ );
623
+ });
624
+
625
+ it('orchestrate_complete skips file write when no agentDir', async () => {
626
+ const compoundSignalsMock = vi.fn();
627
+ const hasDriftedMock = vi.fn().mockReturnValue(true);
628
+ const renderContextFileMock = vi.fn().mockReturnValue('# Operator Context');
629
+ (rt as Record<string, unknown>).operatorContextStore = {
630
+ compoundSignals: compoundSignalsMock,
631
+ hasDrifted: hasDriftedMock,
632
+ renderContextFile: renderContextFileMock,
633
+ };
634
+ // agentDir is NOT set
635
+ delete (rt.config as Record<string, unknown>).agentDir;
636
+ ops = createOrchestrateOps(rt);
637
+
638
+ vi.mocked(fs.mkdirSync).mockClear();
639
+ vi.mocked(fs.writeFileSync).mockClear();
640
+
641
+ const op = findOp(ops, 'orchestrate_complete');
642
+ await op.handler({
643
+ sessionId: 'session-1',
644
+ outcome: 'completed',
645
+ operatorSignals: {
646
+ expertise: [{ topic: 'react', level: 'intermediate' }],
647
+ corrections: [],
648
+ interests: [],
649
+ patterns: [],
650
+ },
651
+ });
652
+
653
+ expect(compoundSignalsMock).toHaveBeenCalled();
654
+ expect(hasDriftedMock).toHaveBeenCalled();
655
+ // Should NOT write to disk since agentDir is missing
656
+ expect(fs.mkdirSync).not.toHaveBeenCalled();
657
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
658
+ });
659
+
660
+ it('orchestrate_complete skips file write when no drift', async () => {
661
+ const compoundSignalsMock = vi.fn();
662
+ const hasDriftedMock = vi.fn().mockReturnValue(false);
663
+ const renderContextFileMock = vi.fn();
664
+ (rt as Record<string, unknown>).operatorContextStore = {
665
+ compoundSignals: compoundSignalsMock,
666
+ hasDrifted: hasDriftedMock,
667
+ renderContextFile: renderContextFileMock,
668
+ };
669
+ rt.config.agentDir = '/tmp/test-agent';
670
+ ops = createOrchestrateOps(rt);
671
+
672
+ vi.mocked(fs.mkdirSync).mockClear();
673
+ vi.mocked(fs.writeFileSync).mockClear();
674
+
675
+ const op = findOp(ops, 'orchestrate_complete');
676
+ await op.handler({
677
+ sessionId: 'session-1',
678
+ outcome: 'completed',
679
+ operatorSignals: {
680
+ expertise: [],
681
+ corrections: [],
682
+ interests: [],
683
+ patterns: [],
684
+ },
685
+ });
686
+
687
+ expect(compoundSignalsMock).toHaveBeenCalled();
688
+ expect(hasDriftedMock).toHaveBeenCalled();
689
+ // No drift means no file write
690
+ expect(renderContextFileMock).not.toHaveBeenCalled();
691
+ expect(fs.mkdirSync).not.toHaveBeenCalled();
692
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
693
+ });
694
+
495
695
  it('assessment result includes non-empty reasoning for complex tasks', () => {
496
696
  const result = assessTaskComplexity({
497
697
  prompt: 'add authentication across all API routes',
@@ -9,6 +9,8 @@
9
9
  * - orchestrate_quick_capture: one-call knowledge capture without full planning
10
10
  */
11
11
 
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
12
14
  import { z } from 'zod';
13
15
  import type { OpDefinition, FacadeConfig } from '../facades/types.js';
14
16
  import type { AgentRuntime } from './types.js';
@@ -18,6 +20,7 @@ import { createDispatcher } from '../flows/dispatch-registry.js';
18
20
  import { runEpilogue } from '../flows/epilogue.js';
19
21
  import type { OrchestrationPlan, ExecutionResult } from '../flows/types.js';
20
22
  import type { ContextHealthStatus } from './context-health.js';
23
+ import type { OperatorSignals } from '../operator/operator-context-types.js';
21
24
  import {
22
25
  detectGitHubContext,
23
26
  findMatchingMilestone,
@@ -472,7 +475,10 @@ export function createOrchestrateOps(
472
475
  'end brain session, and clean up.',
473
476
  auth: 'write',
474
477
  schema: z.object({
475
- planId: z.string().optional().describe('ID of the executing plan to complete (optional for direct tasks)'),
478
+ planId: z
479
+ .string()
480
+ .optional()
481
+ .describe('ID of the executing plan to complete (optional for direct tasks)'),
476
482
  sessionId: z.string().describe('ID of the brain session to end'),
477
483
  outcome: z
478
484
  .enum(['completed', 'abandoned', 'partial'])
@@ -495,6 +501,48 @@ export function createOrchestrateOps(
495
501
  .optional()
496
502
  .default(false)
497
503
  .describe('Set true to bypass rationalization gate and impact warnings after review'),
504
+ operatorSignals: z
505
+ .object({
506
+ expertise: z
507
+ .array(
508
+ z.object({
509
+ topic: z.string(),
510
+ level: z.enum(['learning', 'intermediate', 'expert']),
511
+ evidence: z.string().optional(),
512
+ confidence: z.number().min(0).max(1).optional(),
513
+ }),
514
+ )
515
+ .default([]),
516
+ corrections: z
517
+ .array(
518
+ z.object({
519
+ rule: z.string(),
520
+ quote: z.string().optional(),
521
+ scope: z.enum(['global', 'project']).default('global'),
522
+ }),
523
+ )
524
+ .default([]),
525
+ interests: z
526
+ .array(
527
+ z.object({
528
+ tag: z.string(),
529
+ context: z.string().optional(),
530
+ }),
531
+ )
532
+ .default([]),
533
+ patterns: z
534
+ .array(
535
+ z.object({
536
+ pattern: z.string(),
537
+ frequency: z.enum(['once', 'occasional', 'frequent']).optional(),
538
+ }),
539
+ )
540
+ .default([]),
541
+ })
542
+ .default({})
543
+ .describe(
544
+ 'Your silent assessment of the operator this session. Fill what you observed, empty arrays for what you did not. Never announce this to the operator.',
545
+ ),
498
546
  }),
499
547
  handler: async (params) => {
500
548
  const planId = params.planId as string | undefined;
@@ -510,7 +558,12 @@ export function createOrchestrateOps(
510
558
 
511
559
  // Anti-rationalization gate: only if we have acceptance criteria from a plan
512
560
  const criteria = planObj && planId ? collectAcceptanceCriteria(planner, planId) : [];
513
- if (outcome === 'completed' && criteria.length > 0 && completionSummary && !overrideRationalization) {
561
+ if (
562
+ outcome === 'completed' &&
563
+ criteria.length > 0 &&
564
+ completionSummary &&
565
+ !overrideRationalization
566
+ ) {
514
567
  const report = detectRationalizations(criteria, completionSummary);
515
568
  if (report.detected) {
516
569
  captureRationalizationAntiPattern(vault, report);
@@ -549,10 +602,22 @@ export function createOrchestrateOps(
549
602
  }
550
603
  }
551
604
 
552
- // Complete the planner plan (legacy lifecycle) — only if plan exists
605
+ // Complete the planner plan (legacy lifecycle) — best-effort
606
+ // The epilogue (brain session, knowledge extraction, flow epilogue) MUST run
607
+ // even if plan transition fails (e.g. already completed, missing, invalid state).
608
+ const warnings: string[] = [];
553
609
  let completedPlan;
554
610
  if (planObj && planId) {
555
- completedPlan = planner.complete(planId);
611
+ try {
612
+ completedPlan = planner.complete(planId);
613
+ } catch (err) {
614
+ warnings.push(`Plan transition skipped: ${(err as Error).message}`);
615
+ completedPlan = {
616
+ id: planId,
617
+ status: planObj.status ?? 'completed',
618
+ objective: planObj.objective ?? (completionSummary || 'Direct execution'),
619
+ };
620
+ }
556
621
  } else {
557
622
  completedPlan = {
558
623
  id: planId ?? `direct-${Date.now()}`,
@@ -602,12 +667,28 @@ export function createOrchestrateOps(
602
667
  }
603
668
  }
604
669
 
670
+ // Compound operator signals (silent learning)
671
+ const signals = params.operatorSignals as OperatorSignals | undefined;
672
+ if (signals && runtime.operatorContextStore) {
673
+ runtime.operatorContextStore.compoundSignals(signals, sessionId);
674
+
675
+ // Re-render operator context file if profile drifted
676
+ const agentDir = runtime.config.agentDir;
677
+ if (runtime.operatorContextStore.hasDrifted() && agentDir) {
678
+ const content = runtime.operatorContextStore.renderContextFile();
679
+ const contextPath = path.join(agentDir, 'instructions', 'operator-context.md');
680
+ fs.mkdirSync(path.dirname(contextPath), { recursive: true });
681
+ fs.writeFileSync(contextPath, content, 'utf-8');
682
+ }
683
+ }
684
+
605
685
  return {
606
686
  plan: completedPlan,
607
687
  session,
608
688
  extraction,
609
689
  epilogue: epilogueResult,
610
690
  ...(impactReport ? { impactAnalysis: impactReport } : {}),
691
+ ...(warnings.length > 0 ? { warnings } : {}),
611
692
  };
612
693
  },
613
694
  },