@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
@@ -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().describe('ID of the executing plan to complete'),
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,9 +501,51 @@ 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
- const planId = params.planId as string;
548
+ const planId = params.planId as string | undefined;
501
549
  const sessionId = params.sessionId as string;
502
550
  const outcome = (params.outcome as string) ?? 'completed';
503
551
  const completionSummary = (params.summary as string) ?? '';
@@ -505,20 +553,26 @@ export function createOrchestrateOps(
505
553
  const filesModified = (params.filesModified as string[]) ?? [];
506
554
  const overrideRationalization = (params.overrideRationalization as boolean) ?? false;
507
555
 
508
- // Anti-rationalization gate: check completion summary before completing
509
- if (outcome === 'completed' && !overrideRationalization) {
510
- const criteria = collectAcceptanceCriteria(planner, planId);
511
- if (criteria.length > 0 && completionSummary) {
512
- const report = detectRationalizations(criteria, completionSummary);
513
- if (report.detected) {
514
- captureRationalizationAntiPattern(vault, report);
515
- return {
516
- blocked: true,
517
- reason: 'Rationalization language detected in completion summary',
518
- rationalization: report,
519
- hint: 'Address the unmet criteria, or set overrideRationalization: true to bypass this gate.',
520
- };
521
- }
556
+ // Look up plan optional for direct tasks that skipped planning
557
+ const planObj = planId ? planner.get(planId) : null;
558
+
559
+ // Anti-rationalization gate: only if we have acceptance criteria from a plan
560
+ const criteria = planObj && planId ? collectAcceptanceCriteria(planner, planId) : [];
561
+ if (
562
+ outcome === 'completed' &&
563
+ criteria.length > 0 &&
564
+ completionSummary &&
565
+ !overrideRationalization
566
+ ) {
567
+ const report = detectRationalizations(criteria, completionSummary);
568
+ if (report.detected) {
569
+ captureRationalizationAntiPattern(vault, report);
570
+ return {
571
+ blocked: true,
572
+ reason: 'Rationalization language detected in completion summary',
573
+ rationalization: report,
574
+ hint: 'Address the unmet criteria, or set overrideRationalization: true to bypass this gate.',
575
+ };
522
576
  }
523
577
  }
524
578
 
@@ -527,7 +581,6 @@ export function createOrchestrateOps(
527
581
  if (filesModified.length > 0) {
528
582
  try {
529
583
  const analyzer = new ImpactAnalyzer();
530
- const planObj = planner.get(planId);
531
584
  const scopeHints = planObj?.scope ? [planObj.scope] : undefined;
532
585
  impactReport = analyzer.analyzeImpact(
533
586
  filesModified,
@@ -549,10 +602,31 @@ export function createOrchestrateOps(
549
602
  }
550
603
  }
551
604
 
552
- // Complete the planner plan (legacy lifecycle)
553
- const plan = planner.complete(planId);
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[] = [];
609
+ let completedPlan;
610
+ if (planObj && 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
+ }
621
+ } else {
622
+ completedPlan = {
623
+ id: planId ?? `direct-${Date.now()}`,
624
+ status: 'completed',
625
+ objective: completionSummary || 'Direct execution',
626
+ };
627
+ }
554
628
 
555
- // End brain session
629
+ // End brain session — runs regardless of plan existence
556
630
  const session = brainIntelligence.lifecycle({
557
631
  action: 'end',
558
632
  sessionId,
@@ -562,7 +636,7 @@ export function createOrchestrateOps(
562
636
  filesModified,
563
637
  });
564
638
 
565
- // Extract knowledge
639
+ // Extract knowledge — runs regardless of plan existence
566
640
  let extraction = null;
567
641
  try {
568
642
  extraction = brainIntelligence.extractKnowledge(sessionId);
@@ -572,31 +646,49 @@ export function createOrchestrateOps(
572
646
 
573
647
  // Run flow-engine epilogue if we have a flow plan
574
648
  let epilogueResult = null;
575
- const entry = planStore.get(planId);
576
- if (entry) {
577
- try {
578
- const dispatch = buildDispatch(agentId, runtime, facades);
579
- const summary = `${outcome}: ${entry.plan.summary}. Tools: ${toolsUsed.join(', ') || 'none'}. Files: ${filesModified.join(', ') || 'none'}.`;
580
- epilogueResult = await runEpilogue(
581
- dispatch,
582
- entry.plan.context.probes,
583
- entry.plan.context.projectPath,
584
- summary,
585
- );
586
- } catch {
587
- // Epilogue is best-effort
649
+ if (planId) {
650
+ const entry = planStore.get(planId);
651
+ if (entry) {
652
+ try {
653
+ const dispatch = buildDispatch(agentId, runtime, facades);
654
+ const summary = `${outcome}: ${entry.plan.summary}. Tools: ${toolsUsed.join(', ') || 'none'}. Files: ${filesModified.join(', ') || 'none'}.`;
655
+ epilogueResult = await runEpilogue(
656
+ dispatch,
657
+ entry.plan.context.probes,
658
+ entry.plan.context.projectPath,
659
+ summary,
660
+ );
661
+ } catch {
662
+ // Epilogue is best-effort
663
+ }
664
+
665
+ // Clean up plan store
666
+ planStore.delete(planId);
588
667
  }
668
+ }
589
669
 
590
- // Clean up plan store
591
- planStore.delete(planId);
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
+ }
592
683
  }
593
684
 
594
685
  return {
595
- plan,
686
+ plan: completedPlan,
596
687
  session,
597
688
  extraction,
598
689
  epilogue: epilogueResult,
599
690
  ...(impactReport ? { impactAnalysis: impactReport } : {}),
691
+ ...(warnings.length > 0 ? { warnings } : {}),
600
692
  };
601
693
  },
602
694
  },
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { recordPlanFeedback } from './plan-feedback-helper.js';
3
+
4
+ // ─── Mock Factories ───────────────────────────────────────────────────
5
+
6
+ function makeBrain() {
7
+ return {
8
+ recordFeedback: vi.fn(),
9
+ };
10
+ }
11
+
12
+ function makeBrainIntelligence() {
13
+ return {
14
+ maybeAutoBuildOnFeedback: vi.fn(),
15
+ };
16
+ }
17
+
18
+ function makePlan(decisions: (string | { decision: string })[] = []) {
19
+ return {
20
+ objective: 'Test objective',
21
+ decisions,
22
+ };
23
+ }
24
+
25
+ // ─── Tests ────────────────────────────────────────────────────────────
26
+
27
+ describe('recordPlanFeedback', () => {
28
+ it('should extract entryIds from decision strings and record feedback', () => {
29
+ const brain = makeBrain();
30
+ const intelligence = makeBrainIntelligence();
31
+ const plan = makePlan([
32
+ 'Brain pattern: TDD (strength: 52.5) [entryId:method-tdd-123]',
33
+ 'Brain pattern: Vault hooks (strength: 87.5) [entryId:arch-vault-456]',
34
+ ]);
35
+
36
+ const count = recordPlanFeedback(plan, brain as unknown, intelligence as unknown);
37
+
38
+ expect(count).toBe(2);
39
+ expect(brain.recordFeedback).toHaveBeenCalledTimes(2);
40
+ expect(brain.recordFeedback).toHaveBeenCalledWith(
41
+ 'Test objective',
42
+ 'method-tdd-123',
43
+ 'accepted',
44
+ );
45
+ expect(brain.recordFeedback).toHaveBeenCalledWith(
46
+ 'Test objective',
47
+ 'arch-vault-456',
48
+ 'accepted',
49
+ );
50
+ expect(intelligence.maybeAutoBuildOnFeedback).toHaveBeenCalledOnce();
51
+ });
52
+
53
+ it('should handle decision objects with .decision property', () => {
54
+ const brain = makeBrain();
55
+ const plan = makePlan([{ decision: 'Use vault pattern [entryId:obj-entry-1]' }]);
56
+
57
+ const count = recordPlanFeedback(plan, brain as unknown);
58
+
59
+ expect(count).toBe(1);
60
+ expect(brain.recordFeedback).toHaveBeenCalledWith('Test objective', 'obj-entry-1', 'accepted');
61
+ });
62
+
63
+ it('should skip decisions without entryId markers', () => {
64
+ const brain = makeBrain();
65
+ const plan = makePlan([
66
+ 'Brain pattern: TDD (strength: 52.5)',
67
+ 'Some decision without an entry ID',
68
+ 'Brain pattern: Vault hooks (strength: 87.5) [entryId:arch-vault-456]',
69
+ ]);
70
+
71
+ const count = recordPlanFeedback(plan, brain as unknown);
72
+
73
+ expect(count).toBe(1);
74
+ expect(brain.recordFeedback).toHaveBeenCalledTimes(1);
75
+ expect(brain.recordFeedback).toHaveBeenCalledWith(
76
+ 'Test objective',
77
+ 'arch-vault-456',
78
+ 'accepted',
79
+ );
80
+ });
81
+
82
+ it('should skip malformed entryId markers gracefully', () => {
83
+ const brain = makeBrain();
84
+ const plan = makePlan(['Brain pattern: X [entryId:]', 'Brain pattern: Y [entryId:valid-id]']);
85
+
86
+ const count = recordPlanFeedback(plan, brain as unknown);
87
+
88
+ // [entryId:] won't match because the regex requires at least one char after :
89
+ // Actually the regex [^\]]+ requires 1+ chars, so empty entryId won't match
90
+ expect(count).toBe(1);
91
+ expect(brain.recordFeedback).toHaveBeenCalledWith('Test objective', 'valid-id', 'accepted');
92
+ });
93
+
94
+ it('should not double-record duplicate entryIds', () => {
95
+ const brain = makeBrain();
96
+ const plan = makePlan([
97
+ 'Decision 1 [entryId:same-entry]',
98
+ 'Decision 2 [entryId:same-entry]',
99
+ 'Decision 3 [entryId:different-entry]',
100
+ ]);
101
+
102
+ const count = recordPlanFeedback(plan, brain as unknown);
103
+
104
+ expect(count).toBe(2);
105
+ expect(brain.recordFeedback).toHaveBeenCalledTimes(2);
106
+ expect(brain.recordFeedback).toHaveBeenCalledWith('Test objective', 'same-entry', 'accepted');
107
+ expect(brain.recordFeedback).toHaveBeenCalledWith(
108
+ 'Test objective',
109
+ 'different-entry',
110
+ 'accepted',
111
+ );
112
+ });
113
+
114
+ it('should gracefully handle recordFeedback throwing', () => {
115
+ const brain = makeBrain();
116
+ brain.recordFeedback.mockImplementationOnce(() => {
117
+ throw new Error('Entry not found');
118
+ });
119
+ const plan = makePlan([
120
+ 'Decision 1 [entryId:missing-entry]',
121
+ 'Decision 2 [entryId:valid-entry]',
122
+ ]);
123
+
124
+ const count = recordPlanFeedback(plan, brain as unknown);
125
+
126
+ // First one throws, second succeeds
127
+ expect(count).toBe(1);
128
+ expect(brain.recordFeedback).toHaveBeenCalledTimes(2);
129
+ });
130
+
131
+ it('should return 0 and not call maybeAutoBuild when no entryIds found', () => {
132
+ const brain = makeBrain();
133
+ const intelligence = makeBrainIntelligence();
134
+ const plan = makePlan(['Decision without markers', 'Another plain decision']);
135
+
136
+ const count = recordPlanFeedback(plan, brain as unknown, intelligence as unknown);
137
+
138
+ expect(count).toBe(0);
139
+ expect(brain.recordFeedback).not.toHaveBeenCalled();
140
+ expect(intelligence.maybeAutoBuildOnFeedback).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it('should handle empty decisions array', () => {
144
+ const brain = makeBrain();
145
+ const plan = makePlan([]);
146
+
147
+ const count = recordPlanFeedback(plan, brain as unknown);
148
+
149
+ expect(count).toBe(0);
150
+ expect(brain.recordFeedback).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('should work without brainIntelligence (optional param)', () => {
154
+ const brain = makeBrain();
155
+ const plan = makePlan(['Decision [entryId:entry-1]']);
156
+
157
+ const count = recordPlanFeedback(plan, brain as unknown);
158
+
159
+ expect(count).toBe(1);
160
+ // No error thrown despite missing brainIntelligence
161
+ });
162
+
163
+ it('should extract multiple entryIds from a single decision string', () => {
164
+ const brain = makeBrain();
165
+ const plan = makePlan(['Combined: [entryId:first-entry] and also [entryId:second-entry]']);
166
+
167
+ const count = recordPlanFeedback(plan, brain as unknown);
168
+
169
+ expect(count).toBe(2);
170
+ expect(brain.recordFeedback).toHaveBeenCalledWith('Test objective', 'first-entry', 'accepted');
171
+ expect(brain.recordFeedback).toHaveBeenCalledWith('Test objective', 'second-entry', 'accepted');
172
+ });
173
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared helper for recording brain feedback from plan decisions and context.
3
+ *
4
+ * Used by both plan_complete_lifecycle (planning-extra-ops.ts) and
5
+ * orchestrate_complete (orchestrate-ops.ts) to close the brain learning loop.
6
+ */
7
+
8
+ import type { Brain } from '../brain/brain.js';
9
+ import type { BrainIntelligence } from '../brain/intelligence.js';
10
+
11
+ /** Regex to extract vault entry IDs embedded in decision/context strings. */
12
+ const ENTRY_ID_REGEX = /\[entryId:([^\]]+)\]/g;
13
+
14
+ /**
15
+ * Extract entry IDs from an array of decision or context strings,
16
+ * record positive feedback for each, and optionally trigger auto-rebuild.
17
+ *
18
+ * @returns Number of feedback entries recorded.
19
+ */
20
+ export function recordPlanFeedback(
21
+ plan: { objective: string; decisions: (string | { decision: string })[] },
22
+ brain: Brain,
23
+ brainIntelligence?: BrainIntelligence,
24
+ ): number {
25
+ let feedbackRecorded = 0;
26
+ const seen = new Set<string>();
27
+
28
+ // Collect all strings to scan: decisions + any context strings
29
+ const strings: string[] = [];
30
+
31
+ for (const d of plan.decisions) {
32
+ const str = typeof d === 'string' ? d : d.decision;
33
+ strings.push(str);
34
+ }
35
+
36
+ for (const str of strings) {
37
+ // Use matchAll to find all entryId markers in each string
38
+ for (const match of str.matchAll(ENTRY_ID_REGEX)) {
39
+ const entryId = match[1];
40
+ // Skip duplicates within the same plan
41
+ if (seen.has(entryId)) continue;
42
+ seen.add(entryId);
43
+
44
+ try {
45
+ brain.recordFeedback(plan.objective, entryId, 'accepted');
46
+ feedbackRecorded++;
47
+ } catch {
48
+ // Graceful degradation — skip if entry not found or already recorded
49
+ }
50
+ }
51
+ }
52
+
53
+ // Trigger auto-rebuild check after recording feedback
54
+ if (feedbackRecorded > 0 && brainIntelligence) {
55
+ try {
56
+ brainIntelligence.maybeAutoBuildOnFeedback();
57
+ } catch {
58
+ // Auto-rebuild is best-effort
59
+ }
60
+ }
61
+
62
+ return feedbackRecorded;
63
+ }
@@ -69,7 +69,7 @@ function createMockRuntime(): AgentRuntime {
69
69
 
70
70
  return {
71
71
  planner: {
72
- iterate: vi.fn(() => plan),
72
+ iterate: vi.fn(() => ({ plan, mutated: 1 })),
73
73
  splitTasks: vi.fn(() => ({
74
74
  ...plan,
75
75
  tasks: [plan.tasks[0], { id: 'task-2', title: 'Task 2' }],
@@ -159,6 +159,48 @@ describe('createPlanningExtraOps', () => {
159
159
  );
160
160
  });
161
161
 
162
+ it('returns iterated: false when no changes detected', async () => {
163
+ vi.mocked(runtime.planner.iterate).mockReturnValue({
164
+ plan: makePlan() as unknown,
165
+ mutated: 0,
166
+ } as unknown);
167
+ const result = (await findOp(ops, 'plan_iterate').handler({
168
+ planId: 'plan-1',
169
+ })) as Record<string, unknown>;
170
+ expect(result.iterated).toBe(false);
171
+ expect(result.reason).toBe('no changes detected');
172
+ });
173
+
174
+ it('passes alternatives to planner.iterate', async () => {
175
+ const result = (await findOp(ops, 'plan_iterate').handler({
176
+ planId: 'plan-1',
177
+ alternatives: [
178
+ { approach: 'Alt A', pros: ['fast'], cons: ['fragile'], rejected_reason: 'Too risky' },
179
+ ],
180
+ })) as Record<string, unknown>;
181
+ expect(result.iterated).toBe(true);
182
+ expect(runtime.planner.iterate).toHaveBeenCalledWith(
183
+ 'plan-1',
184
+ expect.objectContaining({
185
+ alternatives: [expect.objectContaining({ approach: 'Alt A' })],
186
+ }),
187
+ );
188
+ });
189
+
190
+ it('passes decisions to planner.iterate', async () => {
191
+ const result = (await findOp(ops, 'plan_iterate').handler({
192
+ planId: 'plan-1',
193
+ decisions: [{ decision: 'Use FTS5', rationale: 'Performance' }],
194
+ })) as Record<string, unknown>;
195
+ expect(result.iterated).toBe(true);
196
+ expect(runtime.planner.iterate).toHaveBeenCalledWith(
197
+ 'plan-1',
198
+ expect.objectContaining({
199
+ decisions: [{ decision: 'Use FTS5', rationale: 'Performance' }],
200
+ }),
201
+ );
202
+ });
203
+
162
204
  it('returns error on failure', async () => {
163
205
  vi.mocked(runtime.planner.iterate).mockImplementation(() => {
164
206
  throw new Error('Not a draft');