@soleri/core 9.7.2 → 9.9.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 (91) hide show
  1. package/dist/brain/intelligence.d.ts.map +1 -1
  2. package/dist/brain/intelligence.js +11 -2
  3. package/dist/brain/intelligence.js.map +1 -1
  4. package/dist/brain/types.d.ts +1 -0
  5. package/dist/brain/types.d.ts.map +1 -1
  6. package/dist/enforcement/adapters/index.d.ts +15 -0
  7. package/dist/enforcement/adapters/index.d.ts.map +1 -1
  8. package/dist/enforcement/adapters/index.js +38 -0
  9. package/dist/enforcement/adapters/index.js.map +1 -1
  10. package/dist/enforcement/adapters/opencode.d.ts +21 -0
  11. package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
  12. package/dist/enforcement/adapters/opencode.js +115 -0
  13. package/dist/enforcement/adapters/opencode.js.map +1 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/paths.d.ts +2 -0
  19. package/dist/paths.d.ts.map +1 -1
  20. package/dist/paths.js +4 -0
  21. package/dist/paths.js.map +1 -1
  22. package/dist/planning/evidence-collector.d.ts +2 -0
  23. package/dist/planning/evidence-collector.d.ts.map +1 -1
  24. package/dist/planning/evidence-collector.js +7 -2
  25. package/dist/planning/evidence-collector.js.map +1 -1
  26. package/dist/planning/gap-patterns.d.ts.map +1 -1
  27. package/dist/planning/gap-patterns.js +4 -1
  28. package/dist/planning/gap-patterns.js.map +1 -1
  29. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  30. package/dist/planning/plan-lifecycle.js +5 -0
  31. package/dist/planning/plan-lifecycle.js.map +1 -1
  32. package/dist/planning/planner-types.d.ts +2 -0
  33. package/dist/planning/planner-types.d.ts.map +1 -1
  34. package/dist/runtime/capture-ops.d.ts.map +1 -1
  35. package/dist/runtime/capture-ops.js +14 -6
  36. package/dist/runtime/capture-ops.js.map +1 -1
  37. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  38. package/dist/runtime/facades/curator-facade.js +52 -4
  39. package/dist/runtime/facades/curator-facade.js.map +1 -1
  40. package/dist/runtime/orchestrate-ops.d.ts +12 -0
  41. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  42. package/dist/runtime/orchestrate-ops.js +141 -1
  43. package/dist/runtime/orchestrate-ops.js.map +1 -1
  44. package/dist/runtime/quality-signals.d.ts +42 -0
  45. package/dist/runtime/quality-signals.d.ts.map +1 -0
  46. package/dist/runtime/quality-signals.js +124 -0
  47. package/dist/runtime/quality-signals.js.map +1 -0
  48. package/dist/skills/trust-classifier.js +1 -1
  49. package/dist/skills/trust-classifier.js.map +1 -1
  50. package/dist/vault/vault-markdown-sync.d.ts +5 -2
  51. package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
  52. package/dist/vault/vault-markdown-sync.js +13 -2
  53. package/dist/vault/vault-markdown-sync.js.map +1 -1
  54. package/dist/workflows/index.d.ts +6 -0
  55. package/dist/workflows/index.d.ts.map +1 -0
  56. package/dist/workflows/index.js +5 -0
  57. package/dist/workflows/index.js.map +1 -0
  58. package/dist/workflows/workflow-loader.d.ts +83 -0
  59. package/dist/workflows/workflow-loader.d.ts.map +1 -0
  60. package/dist/workflows/workflow-loader.js +207 -0
  61. package/dist/workflows/workflow-loader.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/brain/intelligence.ts +15 -2
  64. package/src/brain/types.ts +1 -0
  65. package/src/enforcement/adapters/index.ts +45 -0
  66. package/src/enforcement/adapters/opencode.test.ts +406 -0
  67. package/src/enforcement/adapters/opencode.ts +153 -0
  68. package/src/index.ts +19 -0
  69. package/src/paths.ts +5 -0
  70. package/src/planning/evidence-collector.test.ts +95 -0
  71. package/src/planning/evidence-collector.ts +11 -0
  72. package/src/planning/gap-patterns.ts +7 -3
  73. package/src/planning/plan-lifecycle.test.ts +49 -0
  74. package/src/planning/plan-lifecycle.ts +5 -0
  75. package/src/planning/planner-types.ts +2 -0
  76. package/src/runtime/capture-ops.test.ts +58 -1
  77. package/src/runtime/capture-ops.ts +15 -4
  78. package/src/runtime/facades/curator-facade.test.ts +87 -9
  79. package/src/runtime/facades/curator-facade.ts +60 -4
  80. package/src/runtime/orchestrate-ops.test.ts +78 -1
  81. package/src/runtime/orchestrate-ops.ts +175 -1
  82. package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
  83. package/src/runtime/quality-signals.test.ts +312 -0
  84. package/src/runtime/quality-signals.ts +169 -0
  85. package/src/skills/trust-classifier.ts +1 -1
  86. package/src/vault/vault-markdown-sync.test.ts +40 -0
  87. package/src/vault/vault-markdown-sync.ts +16 -3
  88. package/src/workflows/index.ts +12 -0
  89. package/src/workflows/orchestrate-integration.test.ts +166 -0
  90. package/src/workflows/workflow-loader.test.ts +149 -0
  91. package/src/workflows/workflow-loader.ts +238 -0
@@ -340,6 +340,101 @@ describe('collectGitEvidence', () => {
340
340
  });
341
341
  });
342
342
 
343
+ describe('collectGitEvidence — rework tracking', () => {
344
+ it('includes fixIterations in task evidence when present', () => {
345
+ mockExecFileSync
346
+ .mockReturnValueOnce('feature/auth\n')
347
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
348
+
349
+ const plan = makePlan({
350
+ tasks: [
351
+ {
352
+ id: 'task-1',
353
+ title: 'Add auth middleware',
354
+ description: 'Auth middleware',
355
+ status: 'completed',
356
+ fixIterations: 2,
357
+ updatedAt: Date.now(),
358
+ },
359
+ ],
360
+ });
361
+ const report = collectGitEvidence(plan, '/project', 'main');
362
+
363
+ expect(report.taskEvidence[0].fixIterations).toBe(2);
364
+ });
365
+
366
+ it('omits fixIterations when task has zero rework', () => {
367
+ mockExecFileSync
368
+ .mockReturnValueOnce('feature/auth\n')
369
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
370
+
371
+ const plan = makePlan({
372
+ tasks: [
373
+ {
374
+ id: 'task-1',
375
+ title: 'Add auth middleware',
376
+ description: 'Auth middleware',
377
+ status: 'completed',
378
+ updatedAt: Date.now(),
379
+ },
380
+ ],
381
+ });
382
+ const report = collectGitEvidence(plan, '/project', 'main');
383
+
384
+ expect(report.taskEvidence[0].fixIterations).toBeUndefined();
385
+ });
386
+
387
+ it('includes rework count in summary when tasks were reworked', () => {
388
+ mockExecFileSync
389
+ .mockReturnValueOnce('feature/auth\n')
390
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\nM\tsrc/auth/login.ts\n');
391
+
392
+ const plan = makePlan({
393
+ tasks: [
394
+ {
395
+ id: 'task-1',
396
+ title: 'Add auth middleware',
397
+ description: 'Auth middleware',
398
+ status: 'completed',
399
+ fixIterations: 1,
400
+ updatedAt: Date.now(),
401
+ },
402
+ {
403
+ id: 'task-2',
404
+ title: 'Add login endpoint',
405
+ description: 'Login endpoint',
406
+ status: 'completed',
407
+ updatedAt: Date.now(),
408
+ },
409
+ ],
410
+ });
411
+ const report = collectGitEvidence(plan, '/project', 'main');
412
+
413
+ expect(report.summary).toContain('1 required rework');
414
+ });
415
+
416
+ it('does not include rework in summary when no tasks were reworked', () => {
417
+ mockExecFileSync
418
+ .mockReturnValueOnce('feature/auth\n')
419
+ .mockReturnValueOnce('M\tsrc/auth/middleware.ts\n');
420
+
421
+ const plan = makePlan({
422
+ tasks: [
423
+ {
424
+ id: 'task-1',
425
+ title: 'Add auth middleware',
426
+ description: 'Auth middleware',
427
+ status: 'completed',
428
+ updatedAt: Date.now(),
429
+ },
430
+ ],
431
+ });
432
+ const report = collectGitEvidence(plan, '/project', 'main');
433
+
434
+ expect(report.summary).not.toContain('rework');
435
+ });
436
+ });
437
+
343
438
  describe('collectVerificationGaps', () => {
344
439
  it('returns empty array when no tasks have verification', () => {
345
440
  const tasks: PlanTask[] = [makeTask()];
@@ -19,6 +19,8 @@ export interface GitTaskEvidence {
19
19
  plannedStatus: string;
20
20
  matchedFiles: FileChange[];
21
21
  verdict: 'DONE' | 'PARTIAL' | 'MISSING' | 'SKIPPED';
22
+ /** Number of rework cycles this task went through (0 = first pass). */
23
+ fixIterations?: number;
22
24
  }
23
25
 
24
26
  export interface UnplannedChange {
@@ -72,6 +74,8 @@ export function collectGitEvidence(
72
74
  plannedStatus: task.status,
73
75
  matchedFiles: matches,
74
76
  verdict,
77
+ ...(task.fixIterations !== undefined &&
78
+ task.fixIterations > 0 && { fixIterations: task.fixIterations }),
75
79
  });
76
80
  }
77
81
 
@@ -93,12 +97,17 @@ export function collectGitEvidence(
93
97
  ? Math.round(((doneTasks + partialTasks * 0.5 + skippedTasks * 0.25) / totalTasks) * 100)
94
98
  : 100;
95
99
 
100
+ const reworkedTasks = taskEvidence.filter(
101
+ (te) => te.fixIterations && te.fixIterations > 0,
102
+ ).length;
103
+
96
104
  const summary = buildSummary(
97
105
  totalTasks,
98
106
  doneTasks,
99
107
  partialTasks,
100
108
  missingWork.length,
101
109
  unplannedChanges.length,
110
+ reworkedTasks,
102
111
  );
103
112
 
104
113
  const verificationGaps = collectVerificationGaps(plan.tasks, taskEvidence);
@@ -284,11 +293,13 @@ function buildSummary(
284
293
  partial: number,
285
294
  missing: number,
286
295
  unplanned: number,
296
+ reworked: number = 0,
287
297
  ): string {
288
298
  const parts: string[] = [];
289
299
  parts.push(`${done}/${total} tasks verified by git evidence`);
290
300
  if (partial > 0) parts.push(`${partial} partially done`);
291
301
  if (missing > 0) parts.push(`${missing} with no file evidence`);
292
302
  if (unplanned > 0) parts.push(`${unplanned} unplanned file changes`);
303
+ if (reworked > 0) parts.push(`${reworked} required rework`);
293
304
  return parts.join(', ');
294
305
  }
@@ -172,12 +172,16 @@ export function analyzeStructure(plan: Plan): PlanGap[] {
172
172
  }
173
173
 
174
174
  if (plan.tasks.length === 0) {
175
+ const hasApproachSteps =
176
+ plan.approach && /(?:step\s+\d|task\s+\d|\d\.\s|\d\)\s)/i.test(plan.approach);
175
177
  gaps.push(
176
178
  gap(
177
- 'critical',
179
+ hasApproachSteps ? 'major' : 'critical',
178
180
  'structure',
179
- 'Plan has no tasks.',
180
- 'Add at least one task to make the plan actionable.',
181
+ hasApproachSteps
182
+ ? 'Plan has no tasks but approach contains steps. Use `addTasks` in `plan_iterate` or pass `tasks` in `create_plan` to promote them.'
183
+ : 'Plan has no tasks.',
184
+ 'Add tasks via `create_plan` (tasks param) or `plan_iterate` (addTasks param).',
181
185
  'tasks',
182
186
  'no_tasks',
183
187
  ),
@@ -342,6 +342,55 @@ describe('plan-lifecycle', () => {
342
342
  expect(task.completedAt).toBeGreaterThan(0);
343
343
  expect(task.status).toBe('failed');
344
344
  });
345
+
346
+ it('increments fixIterations on completed → in_progress rework', () => {
347
+ const task = makeTask();
348
+ applyTaskStatusUpdate(task, 'in_progress');
349
+ applyTaskStatusUpdate(task, 'completed');
350
+ expect(task.fixIterations).toBeUndefined();
351
+ // Rework: send back from completed to in_progress
352
+ applyTaskStatusUpdate(task, 'in_progress');
353
+ expect(task.fixIterations).toBe(1);
354
+ expect(task.completedAt).toBeUndefined();
355
+ });
356
+
357
+ it('increments fixIterations on failed → in_progress rework', () => {
358
+ const task = makeTask();
359
+ applyTaskStatusUpdate(task, 'in_progress');
360
+ applyTaskStatusUpdate(task, 'failed');
361
+ // Rework from failed
362
+ applyTaskStatusUpdate(task, 'in_progress');
363
+ expect(task.fixIterations).toBe(1);
364
+ expect(task.completedAt).toBeUndefined();
365
+ });
366
+
367
+ it('accumulates fixIterations across multiple rework cycles', () => {
368
+ const task = makeTask();
369
+ applyTaskStatusUpdate(task, 'in_progress');
370
+ applyTaskStatusUpdate(task, 'completed');
371
+ applyTaskStatusUpdate(task, 'in_progress'); // rework 1
372
+ applyTaskStatusUpdate(task, 'completed');
373
+ applyTaskStatusUpdate(task, 'in_progress'); // rework 2
374
+ expect(task.fixIterations).toBe(2);
375
+ });
376
+
377
+ it('does not increment fixIterations on pending → in_progress', () => {
378
+ const task = makeTask();
379
+ applyTaskStatusUpdate(task, 'in_progress');
380
+ expect(task.fixIterations).toBeUndefined();
381
+ });
382
+
383
+ it('resets completedAt on rework but preserves startedAt', () => {
384
+ const task = makeTask();
385
+ applyTaskStatusUpdate(task, 'in_progress');
386
+ const originalStartedAt = task.startedAt;
387
+ applyTaskStatusUpdate(task, 'completed');
388
+ expect(task.completedAt).toBeGreaterThan(0);
389
+ // Rework
390
+ applyTaskStatusUpdate(task, 'in_progress');
391
+ expect(task.completedAt).toBeUndefined();
392
+ expect(task.startedAt).toBe(originalStartedAt);
393
+ });
345
394
  });
346
395
 
347
396
  describe('createPlanObject', () => {
@@ -338,6 +338,11 @@ export function applyIteration(plan: Plan, changes: IterateChanges): number {
338
338
  */
339
339
  export function applyTaskStatusUpdate(task: PlanTask, status: TaskStatus): void {
340
340
  const now = Date.now();
341
+ // Rework detection: completed/failed → in_progress means a fix iteration
342
+ if (status === 'in_progress' && (task.status === 'completed' || task.status === 'failed')) {
343
+ task.fixIterations = (task.fixIterations ?? 0) + 1;
344
+ task.completedAt = undefined;
345
+ }
341
346
  if (status === 'in_progress' && !task.startedAt) task.startedAt = now;
342
347
  if (status === 'completed' || status === 'skipped' || status === 'failed') {
343
348
  task.completedAt = now;
@@ -88,6 +88,8 @@ export interface PlanTask {
88
88
  deliverables?: TaskDeliverable[];
89
89
  /** Verification findings for tasks that modify existing code. Advisory only. */
90
90
  verification?: TaskVerification;
91
+ /** Number of times this task was sent back for rework (completed → in_progress). */
92
+ fixIterations?: number;
91
93
  updatedAt: number;
92
94
  }
93
95
 
@@ -14,12 +14,14 @@ vi.mock('../vault/scope-detector.js', () => ({
14
14
  })),
15
15
  }));
16
16
 
17
+ const mockSyncEntryToMarkdown = vi.fn(() => Promise.resolve({ written: true }));
17
18
  vi.mock('../vault/vault-markdown-sync.js', () => ({
18
- syncEntryToMarkdown: vi.fn(() => Promise.resolve()),
19
+ syncEntryToMarkdown: (...args: unknown[]) => mockSyncEntryToMarkdown(...args),
19
20
  }));
20
21
 
21
22
  vi.mock('../paths.js', () => ({
22
23
  agentKnowledgeDir: vi.fn(() => '/mock/knowledge'),
24
+ projectKnowledgeDir: vi.fn((p: string) => `/mock/project/${p}/knowledge`),
23
25
  }));
24
26
 
25
27
  // ─── Mock Runtime Factory ──────────────────────────────────────────────
@@ -213,6 +215,32 @@ describe('createCaptureOps', () => {
213
215
  const results = result.results as Array<Record<string, unknown>>;
214
216
  expect((results[0].scope as Record<string, unknown>).tier).toBe('agent');
215
217
  });
218
+
219
+ it('triggers markdown file write on successful capture', async () => {
220
+ mockSyncEntryToMarkdown.mockClear();
221
+ await findOp(ops, 'capture_knowledge').handler({
222
+ entries: [
223
+ { type: 'pattern', domain: 'testing', title: 'Sync Test', description: 'test', tags: [] },
224
+ ],
225
+ });
226
+ // fire-and-forget: allow microtask to flush
227
+ await new Promise((r) => setTimeout(r, 10));
228
+ expect(mockSyncEntryToMarkdown).toHaveBeenCalledWith(
229
+ expect.objectContaining({ domain: 'testing', title: 'Sync Test' }),
230
+ '/mock/knowledge',
231
+ );
232
+ });
233
+
234
+ it('does not block capture response when sync fails', async () => {
235
+ mockSyncEntryToMarkdown.mockClear();
236
+ mockSyncEntryToMarkdown.mockRejectedValueOnce(new Error('disk full'));
237
+ const result = (await findOp(ops, 'capture_knowledge').handler({
238
+ entries: [{ type: 'pattern', domain: 'a', title: 'A', description: 'a', tags: [] }],
239
+ })) as Record<string, unknown>;
240
+ // Capture should still succeed despite sync error
241
+ expect(result.captured).toBe(1);
242
+ expect(result.rejected).toBe(0);
243
+ });
216
244
  });
217
245
 
218
246
  describe('capture_quick', () => {
@@ -323,6 +351,35 @@ describe('createCaptureOps', () => {
323
351
  expect(result.captured).toBe(false);
324
352
  expect((result.governance as Record<string, unknown>).action).toBe('error');
325
353
  });
354
+
355
+ it('triggers markdown file write on successful capture', async () => {
356
+ mockSyncEntryToMarkdown.mockClear();
357
+ await findOp(ops, 'capture_quick').handler({
358
+ type: 'pattern',
359
+ domain: 'testing',
360
+ title: 'Quick Sync Test',
361
+ description: 'quick test',
362
+ });
363
+ // fire-and-forget: allow microtask to flush
364
+ await new Promise((r) => setTimeout(r, 10));
365
+ expect(mockSyncEntryToMarkdown).toHaveBeenCalledWith(
366
+ expect.objectContaining({ domain: 'testing', title: 'Quick Sync Test' }),
367
+ '/mock/knowledge',
368
+ );
369
+ });
370
+
371
+ it('does not block capture response when sync fails', async () => {
372
+ mockSyncEntryToMarkdown.mockClear();
373
+ mockSyncEntryToMarkdown.mockRejectedValueOnce(new Error('disk full'));
374
+ const result = (await findOp(ops, 'capture_quick').handler({
375
+ type: 'pattern',
376
+ domain: 'testing',
377
+ title: 'Quick Fail',
378
+ description: 'test',
379
+ })) as Record<string, unknown>;
380
+ // Capture should still succeed despite sync error
381
+ expect(result.captured).toBe(true);
382
+ });
326
383
  });
327
384
 
328
385
  describe('search_intelligent', () => {
@@ -11,7 +11,7 @@ import type { AgentRuntime } from './types.js';
11
11
  import { detectScope } from '../vault/scope-detector.js';
12
12
  import type { ScopeTier, ScopeDetectionResult } from '../vault/scope-detector.js';
13
13
  import { syncEntryToMarkdown } from '../vault/vault-markdown-sync.js';
14
- import { agentKnowledgeDir } from '../paths.js';
14
+ import { agentKnowledgeDir, projectKnowledgeDir } from '../paths.js';
15
15
  import type { IntelligenceEntry } from '../intelligence/types.js';
16
16
 
17
17
  /**
@@ -189,6 +189,7 @@ export function createCaptureOps(runtime: AgentRuntime): OpDefinition[] {
189
189
  origin: 'user',
190
190
  },
191
191
  config.agentId,
192
+ projectPath,
192
193
  );
193
194
  }
194
195
  } catch (err) {
@@ -417,6 +418,7 @@ export function createCaptureOps(runtime: AgentRuntime): OpDefinition[] {
417
418
  origin: 'user',
418
419
  },
419
420
  config.agentId,
421
+ projectPath,
420
422
  );
421
423
  return result;
422
424
  } catch (err) {
@@ -629,9 +631,18 @@ function mapType(type: string): 'pattern' | 'anti-pattern' | 'rule' | 'playbook'
629
631
  }
630
632
 
631
633
  /** Fire-and-forget markdown sync — never blocks capture, logs errors silently. */
632
- function fireAndForgetSync(entry: IntelligenceEntry, agentId: string): void {
633
- const knowledgeDir = agentKnowledgeDir(agentId);
634
- syncEntryToMarkdown(entry, knowledgeDir).catch(() => {
634
+ function fireAndForgetSync(entry: IntelligenceEntry, agentId: string, projectPath?: string): void {
635
+ // Always sync to agent home dir
636
+ const agentDir = agentKnowledgeDir(agentId);
637
+ syncEntryToMarkdown(entry, agentDir).catch(() => {
635
638
  /* non-blocking — markdown sync is best-effort */
636
639
  });
640
+
641
+ // Also sync to project-local knowledge dir if a real project path is provided
642
+ if (projectPath && projectPath !== '.') {
643
+ const projDir = projectKnowledgeDir(projectPath);
644
+ syncEntryToMarkdown(entry, projDir).catch(() => {
645
+ /* non-blocking — markdown sync is best-effort */
646
+ });
647
+ }
637
648
  }
@@ -3,24 +3,43 @@ import { createCuratorFacadeOps } from './curator-facade.js';
3
3
  import type { OpDefinition } from '../../facades/types.js';
4
4
  import type { AgentRuntime } from '../types.js';
5
5
 
6
+ interface MockLinkManager {
7
+ backfillLinks: ReturnType<typeof vi.fn>;
8
+ getOrphans: ReturnType<typeof vi.fn>;
9
+ }
10
+
11
+ function getLinkManager(rt: AgentRuntime): MockLinkManager {
12
+ return (rt as unknown as { linkManager: MockLinkManager }).linkManager;
13
+ }
14
+
6
15
  function mockRuntime(): AgentRuntime {
7
16
  return {
8
17
  curator: {
9
- getStatus: vi.fn().mockReturnValue({ initialized: true, entriesGroomed: 5 }),
18
+ getStatus: vi
19
+ .fn()
20
+ .mockReturnValue({ initialized: true, entriesGroomed: 5, tables: { entries: 100 } }),
10
21
  detectDuplicates: vi.fn().mockReturnValue([]),
11
22
  detectContradictions: vi.fn(),
12
23
  getContradictions: vi.fn().mockReturnValue([]),
13
24
  resolveContradiction: vi.fn().mockReturnValue({ resolved: true }),
14
25
  groomEntry: vi.fn().mockReturnValue({ groomed: true }),
15
26
  groomAll: vi.fn().mockReturnValue({ groomed: 10 }),
16
- consolidate: vi.fn().mockReturnValue({ duplicates: 0, stale: 0 }),
17
- healthAudit: vi.fn().mockReturnValue({ score: 85 }),
27
+ consolidate: vi.fn().mockReturnValue({ duplicates: 0, stale: 0, durationMs: 10 }),
28
+ healthAudit: vi.fn().mockReturnValue({
29
+ score: 85,
30
+ metrics: { coverage: 1, freshness: 1, quality: 1, tagHealth: 1 },
31
+ recommendations: [],
32
+ }),
18
33
  getVersionHistory: vi.fn().mockReturnValue([]),
19
34
  recordSnapshot: vi.fn().mockReturnValue({ recorded: true }),
20
35
  getQueueStats: vi.fn().mockReturnValue({ total: 20, groomed: 15 }),
21
36
  enrichMetadata: vi.fn().mockReturnValue({ enriched: true }),
22
37
  detectContradictionsHybrid: vi.fn().mockReturnValue([]),
23
38
  },
39
+ linkManager: {
40
+ backfillLinks: vi.fn().mockReturnValue({ processed: 5, linksCreated: 3, durationMs: 50 }),
41
+ getOrphans: vi.fn().mockReturnValue([]),
42
+ },
24
43
  jobQueue: {
25
44
  enqueue: vi.fn().mockImplementation((_type, _params) => `job-${Date.now()}`),
26
45
  getStats: vi.fn().mockReturnValue({ pending: 0, running: 0 }),
@@ -91,7 +110,7 @@ describe('createCuratorFacadeOps', () => {
91
110
  describe('curator_status', () => {
92
111
  it('returns curator status', async () => {
93
112
  const result = await findOp(ops, 'curator_status').handler({});
94
- expect(result).toEqual({ initialized: true, entriesGroomed: 5 });
113
+ expect(result).toEqual({ initialized: true, entriesGroomed: 5, tables: { entries: 100 } });
95
114
  });
96
115
  });
97
116
 
@@ -168,15 +187,18 @@ describe('createCuratorFacadeOps', () => {
168
187
  });
169
188
 
170
189
  describe('curator_consolidate', () => {
171
- it('consolidates with default params', async () => {
190
+ it('consolidates with default params and includes linksCreated', async () => {
172
191
  const result = await findOp(ops, 'curator_consolidate').handler({});
173
- expect(result).toEqual({ duplicates: 0, stale: 0 });
192
+ expect(result).toEqual({ duplicates: 0, stale: 0, durationMs: 10, linksCreated: 3 });
174
193
  expect(runtime.curator.consolidate).toHaveBeenCalledWith({
175
194
  dryRun: undefined,
176
195
  staleDaysThreshold: undefined,
177
196
  duplicateThreshold: undefined,
178
197
  contradictionThreshold: undefined,
179
198
  });
199
+ expect(getLinkManager(runtime).backfillLinks).toHaveBeenCalledWith({
200
+ dryRun: undefined,
201
+ });
180
202
  });
181
203
 
182
204
  it('consolidates with custom params', async () => {
@@ -193,12 +215,68 @@ describe('createCuratorFacadeOps', () => {
193
215
  contradictionThreshold: 0.3,
194
216
  });
195
217
  });
218
+
219
+ it('returns linksCreated: 0 when linkManager throws', async () => {
220
+ const lm = getLinkManager(runtime);
221
+ vi.mocked(lm.backfillLinks).mockImplementation(() => {
222
+ throw new Error('link module unavailable');
223
+ });
224
+ const result = (await findOp(ops, 'curator_consolidate').handler({})) as Record<
225
+ string,
226
+ unknown
227
+ >;
228
+ expect(result.linksCreated).toBe(0);
229
+ });
196
230
  });
197
231
 
198
232
  describe('curator_health_audit', () => {
199
- it('returns audit result', async () => {
200
- const result = await findOp(ops, 'curator_health_audit').handler({});
201
- expect(result).toEqual({ score: 85 });
233
+ it('returns audit result with orphan metrics', async () => {
234
+ const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
235
+ string,
236
+ unknown
237
+ >;
238
+ expect(result.score).toBe(85);
239
+ expect(result.orphanCount).toBe(0);
240
+ expect(result.orphanPercentage).toBe(0);
241
+ });
242
+
243
+ it('reduces quality when orphan percentage > 10%', async () => {
244
+ const lm = getLinkManager(runtime);
245
+ // 15 orphans out of 100 entries = 15%
246
+ vi.mocked(lm.getOrphans).mockReturnValue(
247
+ Array.from({ length: 15 }, (_, i) => ({
248
+ id: `orphan-${i}`,
249
+ title: `o${i}`,
250
+ type: 'pattern',
251
+ domain: 'test',
252
+ })),
253
+ );
254
+ const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
255
+ string,
256
+ unknown
257
+ >;
258
+ expect(result.orphanCount).toBe(15);
259
+ expect(result.orphanPercentage).toBe(15);
260
+ const metrics = result.metrics as Record<string, number>;
261
+ expect(metrics.quality).toBe(0.7); // 1 * 0.7
262
+ expect(
263
+ (result.recommendations as string[]).some(
264
+ (r: string) => r.includes('orphan entries') && r.includes('no links'),
265
+ ),
266
+ ).toBe(true);
267
+ });
268
+
269
+ it('succeeds when linkManager throws', async () => {
270
+ const lm = getLinkManager(runtime);
271
+ vi.mocked(lm.getOrphans).mockImplementation(() => {
272
+ throw new Error('link module unavailable');
273
+ });
274
+ const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
275
+ string,
276
+ unknown
277
+ >;
278
+ expect(result.orphanCount).toBe(0);
279
+ expect(result.orphanPercentage).toBe(0);
202
280
  });
203
281
  });
204
282
 
@@ -108,7 +108,7 @@ export function createCuratorFacadeOps(runtime: AgentRuntime): OpDefinition[] {
108
108
  {
109
109
  name: 'curator_consolidate',
110
110
  description:
111
- 'Consolidate vault — find duplicates, stale entries, contradictions. Dry-run by default.',
111
+ 'Consolidate vault — find duplicates, stale entries, contradictions, and backfill Zettelkasten links for orphan entries. Dry-run by default.',
112
112
  auth: 'write',
113
113
  schema: z.object({
114
114
  dryRun: z.boolean().optional().describe('Default true. Set false to apply mutations.'),
@@ -126,21 +126,77 @@ export function createCuratorFacadeOps(runtime: AgentRuntime): OpDefinition[] {
126
126
  .describe('Contradiction threshold. Default 0.4.'),
127
127
  }),
128
128
  handler: async (params) => {
129
- return curator.consolidate({
129
+ const result = curator.consolidate({
130
130
  dryRun: params.dryRun as boolean | undefined,
131
131
  staleDaysThreshold: params.staleDaysThreshold as number | undefined,
132
132
  duplicateThreshold: params.duplicateThreshold as number | undefined,
133
133
  contradictionThreshold: params.contradictionThreshold as number | undefined,
134
134
  });
135
+
136
+ // Backfill Zettelkasten links for orphan entries
137
+ let linksCreated = 0;
138
+ try {
139
+ const { linkManager } = runtime;
140
+ if (linkManager) {
141
+ const backfillResult = linkManager.backfillLinks({
142
+ dryRun: params.dryRun as boolean | undefined,
143
+ });
144
+ linksCreated = backfillResult.linksCreated;
145
+ }
146
+ } catch {
147
+ // Link module unavailable — degrade gracefully
148
+ }
149
+
150
+ return { ...result, linksCreated };
135
151
  },
136
152
  },
137
153
  {
138
154
  name: 'curator_health_audit',
139
155
  description:
140
- 'Audit vault health — score (0-100), coverage, freshness, quality, tag health, recommendations.',
156
+ 'Audit vault health — score (0-100), coverage, freshness, quality, tag health, orphan count, recommendations.',
141
157
  auth: 'read',
142
158
  handler: async () => {
143
- return curator.healthAudit();
159
+ const result = curator.healthAudit();
160
+
161
+ // Enrich with orphan statistics from link manager
162
+ let orphanCount = 0;
163
+ let orphanPercentage = 0;
164
+ try {
165
+ const { linkManager } = runtime;
166
+ if (linkManager) {
167
+ // getOrphans returns up to limit entries; use a high limit to count all
168
+ const orphans = linkManager.getOrphans(10000);
169
+ orphanCount = orphans.length;
170
+ // Compute percentage against total entries via curator status
171
+ const status = curator.getStatus();
172
+ const totalEntries = Object.values(status.tables).reduce(
173
+ (sum, count) => sum + count,
174
+ 0,
175
+ );
176
+ orphanPercentage =
177
+ totalEntries > 0 ? Math.round((orphanCount / totalEntries) * 100) : 0;
178
+ }
179
+ } catch {
180
+ // Link module unavailable — degrade gracefully
181
+ }
182
+
183
+ // Apply quality penalty if orphan percentage > 10%
184
+ const metrics = { ...result.metrics };
185
+ const recommendations = [...result.recommendations];
186
+ if (orphanPercentage > 10) {
187
+ metrics.quality = Math.round(metrics.quality * 0.7 * 100) / 100;
188
+ recommendations.push(
189
+ `${orphanCount} orphan entries (${orphanPercentage}%) have no links — run consolidation to backfill.`,
190
+ );
191
+ }
192
+
193
+ return {
194
+ ...result,
195
+ metrics,
196
+ recommendations,
197
+ orphanCount,
198
+ orphanPercentage,
199
+ };
144
200
  },
145
201
  },
146
202