@soleri/core 9.14.4 → 9.15.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 (89) hide show
  1. package/dist/brain/brain.d.ts +9 -0
  2. package/dist/brain/brain.d.ts.map +1 -1
  3. package/dist/brain/brain.js +11 -1
  4. package/dist/brain/brain.js.map +1 -1
  5. package/dist/brain/intelligence.d.ts.map +1 -1
  6. package/dist/brain/intelligence.js +24 -0
  7. package/dist/brain/intelligence.js.map +1 -1
  8. package/dist/brain/types.d.ts +1 -0
  9. package/dist/brain/types.d.ts.map +1 -1
  10. package/dist/chat/chat-session.d.ts +6 -0
  11. package/dist/chat/chat-session.d.ts.map +1 -1
  12. package/dist/chat/chat-session.js +68 -17
  13. package/dist/chat/chat-session.js.map +1 -1
  14. package/dist/curator/curator.d.ts +6 -0
  15. package/dist/curator/curator.d.ts.map +1 -1
  16. package/dist/curator/curator.js +138 -0
  17. package/dist/curator/curator.js.map +1 -1
  18. package/dist/curator/types.d.ts +10 -0
  19. package/dist/curator/types.d.ts.map +1 -1
  20. package/dist/engine/bin/soleri-engine.js +0 -0
  21. package/dist/flows/types.d.ts +16 -16
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/intake/content-classifier.d.ts +10 -4
  27. package/dist/intake/content-classifier.d.ts.map +1 -1
  28. package/dist/intake/content-classifier.js +19 -5
  29. package/dist/intake/content-classifier.js.map +1 -1
  30. package/dist/intake/text-ingester.d.ts +18 -0
  31. package/dist/intake/text-ingester.d.ts.map +1 -1
  32. package/dist/intake/text-ingester.js +37 -13
  33. package/dist/intake/text-ingester.js.map +1 -1
  34. package/dist/planning/planner.d.ts +3 -0
  35. package/dist/planning/planner.d.ts.map +1 -1
  36. package/dist/planning/planner.js +43 -4
  37. package/dist/planning/planner.js.map +1 -1
  38. package/dist/plugins/types.d.ts +2 -2
  39. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  40. package/dist/runtime/admin-setup-ops.js +59 -20
  41. package/dist/runtime/admin-setup-ops.js.map +1 -1
  42. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  43. package/dist/runtime/facades/orchestrate-facade.js +28 -1
  44. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  45. package/dist/runtime/runtime.d.ts.map +1 -1
  46. package/dist/runtime/runtime.js +16 -0
  47. package/dist/runtime/runtime.js.map +1 -1
  48. package/dist/runtime/types.d.ts +19 -0
  49. package/dist/runtime/types.d.ts.map +1 -1
  50. package/dist/skills/validate-skills.d.ts +32 -0
  51. package/dist/skills/validate-skills.d.ts.map +1 -0
  52. package/dist/skills/validate-skills.js +396 -0
  53. package/dist/skills/validate-skills.js.map +1 -0
  54. package/dist/vault/default-canonical-tags.d.ts +15 -0
  55. package/dist/vault/default-canonical-tags.d.ts.map +1 -0
  56. package/dist/vault/default-canonical-tags.js +65 -0
  57. package/dist/vault/default-canonical-tags.js.map +1 -0
  58. package/dist/vault/tag-normalizer.d.ts +42 -0
  59. package/dist/vault/tag-normalizer.d.ts.map +1 -0
  60. package/dist/vault/tag-normalizer.js +157 -0
  61. package/dist/vault/tag-normalizer.js.map +1 -0
  62. package/package.json +5 -1
  63. package/src/__tests__/embeddings.test.ts +3 -3
  64. package/src/brain/brain.ts +25 -1
  65. package/src/brain/intelligence.ts +25 -0
  66. package/src/brain/types.ts +1 -0
  67. package/src/chat/chat-session.ts +75 -17
  68. package/src/chat/chat-transport.test.ts +31 -1
  69. package/src/curator/curator.ts +180 -0
  70. package/src/curator/types.ts +10 -0
  71. package/src/index.ts +7 -0
  72. package/src/intake/content-classifier.ts +22 -4
  73. package/src/intake/text-ingester.ts +61 -12
  74. package/src/planning/planner.test.ts +86 -90
  75. package/src/planning/planner.ts +48 -4
  76. package/src/runtime/admin-setup-ops.test.ts +44 -0
  77. package/src/runtime/admin-setup-ops.ts +59 -20
  78. package/src/runtime/facades/orchestrate-facade.ts +27 -1
  79. package/src/runtime/runtime.ts +18 -0
  80. package/src/runtime/types.ts +19 -0
  81. package/src/skills/validate-skills.test.ts +205 -0
  82. package/src/skills/validate-skills.ts +470 -0
  83. package/src/vault/default-canonical-tags.ts +64 -0
  84. package/src/vault/tag-normalizer.test.ts +214 -0
  85. package/src/vault/tag-normalizer.ts +188 -0
  86. package/dist/embeddings/index.d.ts +0 -5
  87. package/dist/embeddings/index.d.ts.map +0 -1
  88. package/dist/embeddings/index.js +0 -3
  89. package/dist/embeddings/index.js.map +0 -1
@@ -77,8 +77,38 @@ export class Planner {
77
77
  }
78
78
  }
79
79
 
80
- private save(): void {
80
+ private refresh(): void {
81
+ this.store = this.load();
82
+ }
83
+
84
+ private mergeLatestStore(deletedPlanIds: string[] = []): void {
85
+ const deleted = new Set(deletedPlanIds);
86
+ const latest = this.load();
87
+ const merged = new Map<string, Plan>();
88
+
89
+ for (const plan of latest.plans) {
90
+ if (!deleted.has(plan.id)) {
91
+ merged.set(plan.id, plan);
92
+ }
93
+ }
94
+
95
+ for (const plan of this.store.plans) {
96
+ if (deleted.has(plan.id)) continue;
97
+ const existing = merged.get(plan.id);
98
+ if (!existing || plan.updatedAt >= existing.updatedAt) {
99
+ merged.set(plan.id, plan);
100
+ }
101
+ }
102
+
103
+ this.store = {
104
+ version: latest.version ?? this.store.version ?? '1.0',
105
+ plans: [...merged.values()],
106
+ };
107
+ }
108
+
109
+ private save(deletedPlanIds: string[] = []): void {
81
110
  mkdirSync(dirname(this.filePath), { recursive: true });
111
+ this.mergeLatestStore(deletedPlanIds);
82
112
  writeFileSync(this.filePath, JSON.stringify(this.store, null, 2), 'utf-8');
83
113
  }
84
114
 
@@ -88,8 +118,13 @@ export class Planner {
88
118
  plan.updatedAt = r.updatedAt;
89
119
  }
90
120
 
121
+ private findPlan(planId: string): Plan | null {
122
+ return this.store.plans.find((p) => p.id === planId) ?? null;
123
+ }
124
+
91
125
  private requirePlan(planId: string): Plan {
92
- const plan = this.get(planId);
126
+ this.refresh();
127
+ const plan = this.findPlan(planId);
93
128
  if (!plan) throw new Error(`Plan not found: ${planId}`);
94
129
  return plan;
95
130
  }
@@ -101,6 +136,7 @@ export class Planner {
101
136
  }
102
137
 
103
138
  create(params: Parameters<typeof createPlanObject>[0]): Plan {
139
+ this.refresh();
104
140
  const plan = createPlanObject(params);
105
141
  this.store.plans.push(plan);
106
142
  this.save();
@@ -108,18 +144,21 @@ export class Planner {
108
144
  }
109
145
 
110
146
  get(planId: string): Plan | null {
111
- return this.store.plans.find((p) => p.id === planId) ?? null;
147
+ this.refresh();
148
+ return this.findPlan(planId);
112
149
  }
113
150
 
114
151
  list(): Plan[] {
152
+ this.refresh();
115
153
  return [...this.store.plans];
116
154
  }
117
155
 
118
156
  remove(planId: string): boolean {
157
+ this.refresh();
119
158
  const idx = this.store.plans.findIndex((p) => p.id === planId);
120
159
  if (idx < 0) return false;
121
160
  this.store.plans.splice(idx, 1);
122
- this.save();
161
+ this.save([planId]);
123
162
  return true;
124
163
  }
125
164
 
@@ -220,10 +259,12 @@ export class Planner {
220
259
  }
221
260
 
222
261
  getExecuting(): Plan[] {
262
+ this.refresh();
223
263
  return this.store.plans.filter((p) => p.status === 'executing' || p.status === 'validating');
224
264
  }
225
265
 
226
266
  getActive(): Plan[] {
267
+ this.refresh();
227
268
  return this.store.plans.filter(
228
269
  (p) =>
229
270
  p.status === 'brainstorming' ||
@@ -435,6 +476,7 @@ export class Planner {
435
476
  }
436
477
 
437
478
  archive(olderThanDays?: number): Plan[] {
479
+ this.refresh();
438
480
  const cutoff =
439
481
  olderThanDays !== undefined
440
482
  ? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
@@ -460,6 +502,7 @@ export class Planner {
460
502
  closedIds: string[];
461
503
  closedPlans: Array<{ id: string; previousStatus: string; reason: string }>;
462
504
  } {
505
+ this.refresh();
463
506
  const now = Date.now();
464
507
  const forceAll = olderThanMs === 0;
465
508
  const defaultTtl = forceAll ? 0 : 30 * 60 * 1000; // 30 minutes for draft/approved
@@ -522,6 +565,7 @@ export class Planner {
522
565
  totalTasks: number;
523
566
  tasksByStatus: Record<TaskStatus, number>;
524
567
  } {
568
+ this.refresh();
525
569
  const plans = this.store.plans;
526
570
  const byStatus = {
527
571
  brainstorming: 0,
@@ -46,6 +46,11 @@ vi.mock('./claude-md-helpers.js', () => ({
46
46
  injectEngineRulesBlock: vi.fn((content: string) => content),
47
47
  }));
48
48
 
49
+ vi.mock('../paths.js', () => ({
50
+ agentPlansPath: vi.fn(() => '/mock-home/.soleri/test-agent/plans.json'),
51
+ agentVaultPath: vi.fn(() => '/mock-home/.soleri/test-agent/vault.db'),
52
+ }));
53
+
49
54
  vi.mock('../skills/sync-skills.js', () => ({
50
55
  discoverSkills: vi.fn(() => [{ name: 'skill-1', path: '/mock/skills/skill-1' }]),
51
56
  syncSkillsToClaudeCode: vi.fn(() => ({
@@ -330,5 +335,44 @@ describe('createAdminSetupOps', () => {
330
335
  expect(activePlans[0].status).toBe('executing');
331
336
  expect(result.recommendation).toContain('need attention');
332
337
  });
338
+
339
+ it('uses configured or resolved .soleri plan paths and understands planner stores', async () => {
340
+ runtime = {
341
+ ...createMockRuntime(),
342
+ config: {
343
+ agentId: 'test-agent',
344
+ dataDir: '/mock/agent-data',
345
+ agentDir: '/mock/agent-dir',
346
+ },
347
+ } as unknown as AgentRuntime;
348
+ ops = createAdminSetupOps(runtime);
349
+
350
+ mockDirs.add('/mock-home/.soleri/test-agent');
351
+ mockFs['/mock-home/.soleri/test-agent/vault.db'] = 'binary';
352
+ mockFs['/mock-home/.soleri/test-agent/plans.json'] = JSON.stringify({
353
+ version: '1.0',
354
+ plans: [
355
+ { id: 'plan-1', status: 'executing' },
356
+ { id: 'plan-2', status: 'completed' },
357
+ ],
358
+ });
359
+
360
+ const result = (await findOp(ops, 'admin_check_persistence').handler({})) as Record<
361
+ string,
362
+ unknown
363
+ >;
364
+
365
+ expect((result.storageDirectory as Record<string, unknown>).path).toBe(
366
+ '/mock-home/.soleri/test-agent',
367
+ );
368
+ expect(
369
+ ((result.files as Record<string, unknown>).plans as Record<string, unknown>).path,
370
+ ).toBe('/mock-home/.soleri/test-agent/plans.json');
371
+ expect(
372
+ ((result.files as Record<string, unknown>).plans as Record<string, unknown>).items,
373
+ ).toBe(2);
374
+ expect(result.status).toBe('PERSISTENCE_ACTIVE');
375
+ expect(result.activePlans).toEqual([{ id: 'plan-1', status: 'executing' }]);
376
+ });
333
377
  });
334
378
  });
@@ -27,6 +27,10 @@ import { join, resolve, dirname } from 'node:path';
27
27
  import { homedir } from 'node:os';
28
28
  import type { OpDefinition } from '../facades/types.js';
29
29
  import type { AgentRuntime } from './types.js';
30
+ import {
31
+ agentPlansPath as getAgentPlansPath,
32
+ agentVaultPath as getAgentVaultPath,
33
+ } from '../paths.js';
30
34
  import {
31
35
  hasSections,
32
36
  removeSections,
@@ -74,19 +78,63 @@ function getFileInfo(path: string): { exists: boolean; size: number; items: numb
74
78
  try {
75
79
  const stat = statSync(path);
76
80
  const content = JSON.parse(readFileSync(path, 'utf-8'));
77
- const items = content.items
78
- ? Object.keys(content.items).length
79
- : content.contexts
80
- ? content.contexts.length
81
- : Array.isArray(content)
82
- ? content.length
83
- : 0;
81
+ const items = countPersistedItems(content);
84
82
  return { exists: true, size: stat.size, items };
85
83
  } catch {
86
84
  return { exists: true, size: 0, items: -1 };
87
85
  }
88
86
  }
89
87
 
88
+ function countPersistedItems(content: unknown): number {
89
+ if (Array.isArray(content)) return content.length;
90
+ if (!content || typeof content !== 'object') return 0;
91
+
92
+ const data = content as Record<string, unknown>;
93
+ if (Array.isArray(data.plans)) return data.plans.length;
94
+ if (data.items && typeof data.items === 'object') return Object.keys(data.items).length;
95
+ if (Array.isArray(data.contexts)) return data.contexts.length;
96
+ return 0;
97
+ }
98
+
99
+ function extractActivePlans(content: unknown): Array<{ id: string; status: string }> {
100
+ if (!content || typeof content !== 'object') return [];
101
+
102
+ const plans = Array.isArray((content as Record<string, unknown>).plans)
103
+ ? ((content as Record<string, unknown>).plans as unknown[])
104
+ : null;
105
+ if (plans) {
106
+ return plans.flatMap((plan) => {
107
+ if (!plan || typeof plan !== 'object') return [];
108
+ const p = plan as Record<string, unknown>;
109
+ const id = typeof p.id === 'string' ? p.id : null;
110
+ const lifecycle =
111
+ typeof p.lifecycleStatus === 'string'
112
+ ? p.lifecycleStatus
113
+ : typeof p.status === 'string'
114
+ ? p.status
115
+ : null;
116
+ if (!id || (lifecycle !== 'executing' && lifecycle !== 'reconciling')) return [];
117
+ return [{ id, status: lifecycle }];
118
+ });
119
+ }
120
+
121
+ const items = (content as Record<string, unknown>).items;
122
+ if (!items || typeof items !== 'object') return [];
123
+
124
+ return Object.entries(items).flatMap(([id, plan]) => {
125
+ if (!plan || typeof plan !== 'object') return [];
126
+ const p = plan as Record<string, unknown>;
127
+ const lifecycle =
128
+ typeof p.lifecycleStatus === 'string'
129
+ ? p.lifecycleStatus
130
+ : typeof p.status === 'string'
131
+ ? p.status
132
+ : null;
133
+ if (lifecycle !== 'executing' && lifecycle !== 'reconciling') return [];
134
+ return [{ id, status: lifecycle }];
135
+ });
136
+ }
137
+
90
138
  /** Discover hookify rule files in a directory */
91
139
  function discoverHookifyFiles(dir: string): Array<{ name: string; path: string }> {
92
140
  if (!existsSync(dir)) return [];
@@ -621,15 +669,15 @@ export function createAdminSetupOps(runtime: AgentRuntime): OpDefinition[] {
621
669
  auth: 'read',
622
670
  handler: async () => {
623
671
  const { agentId, plansPath, vaultPath } = config;
624
- const storageDir = join(homedir(), `.${agentId}`);
672
+ const plansFile = plansPath ?? getAgentPlansPath(agentId);
673
+ const vaultFile = vaultPath ?? getAgentVaultPath(agentId);
674
+ const storageDir = dirname(plansFile);
625
675
  const storageDirExists = existsSync(storageDir);
626
676
 
627
677
  // Check plan storage
628
- const plansFile = plansPath ?? join(storageDir, 'plans.json');
629
678
  const plansInfo = getFileInfo(plansFile);
630
679
 
631
680
  // Check vault
632
- const vaultFile = vaultPath ?? join(storageDir, 'vault.db');
633
681
  const vaultExists = existsSync(vaultFile);
634
682
  let vaultSize = 0;
635
683
  if (vaultExists) {
@@ -655,16 +703,7 @@ export function createAdminSetupOps(runtime: AgentRuntime): OpDefinition[] {
655
703
  if (plansInfo.exists) {
656
704
  try {
657
705
  const plansData = JSON.parse(readFileSync(plansFile, 'utf-8'));
658
- const items = plansData.items ?? plansData;
659
- if (typeof items === 'object' && items !== null) {
660
- for (const [id, plan] of Object.entries(items)) {
661
- const p = plan as Record<string, unknown>;
662
- const lifecycle = (p.lifecycleStatus ?? p.status) as string | undefined;
663
- if (lifecycle === 'executing' || lifecycle === 'reconciling') {
664
- activePlans.push({ id, status: lifecycle });
665
- }
666
- }
667
- }
706
+ activePlans.push(...extractActivePlans(plansData));
668
707
  } catch {
669
708
  // Parse error — not critical
670
709
  }
@@ -24,7 +24,7 @@ import {
24
24
  import type { SkillStep, EvidenceType } from '../../skills/step-tracker.js';
25
25
 
26
26
  export function createOrchestrateFacadeOps(runtime: AgentRuntime): OpDefinition[] {
27
- const { vault, governance, projectRegistry } = runtime;
27
+ const { vault, governance, projectRegistry, brainIntelligence } = runtime;
28
28
 
29
29
  return [
30
30
  // ─── Session Start (inline from core-ops.ts) ─────────────────────
@@ -150,6 +150,31 @@ export function createOrchestrateFacadeOps(runtime: AgentRuntime): OpDefinition[
150
150
  vaultStats: stats,
151
151
  });
152
152
 
153
+ // Auto-close orphaned brain sessions (endedAt IS NULL, startedAt < now - 2h)
154
+ let orphansClosed = 0;
155
+ try {
156
+ const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
157
+ const cutoff = new Date(Date.now() - TWO_HOURS_MS);
158
+ const activeSessions = brainIntelligence.listSessions({ active: true, limit: 1000 });
159
+ for (const s of activeSessions) {
160
+ if (new Date(s.startedAt) < cutoff) {
161
+ try {
162
+ brainIntelligence.lifecycle({
163
+ action: 'end',
164
+ sessionId: s.id,
165
+ planOutcome: 'abandoned',
166
+ context: 'auto-closed: orphan from previous conversation',
167
+ });
168
+ orphansClosed++;
169
+ } catch {
170
+ // Best-effort per session — never let one failure abort the rest
171
+ }
172
+ }
173
+ }
174
+ } catch {
175
+ // Non-critical — don't fail session start over orphan cleanup
176
+ }
177
+
153
178
  return {
154
179
  project,
155
180
  is_new: isNew,
@@ -167,6 +192,7 @@ export function createOrchestrateFacadeOps(runtime: AgentRuntime): OpDefinition[
167
192
  expiredThisSession: expired,
168
193
  },
169
194
  preflight,
195
+ orphansClosed,
170
196
  ...(stagingWarning ? { stagingWarning } : {}),
171
197
  ...(dreamInfo ? { dream: dreamInfo } : {}),
172
198
  };
@@ -148,6 +148,15 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
148
148
  // Pass embeddingProvider for hybrid FTS5+vector search when available
149
149
  const brain = new Brain(vault, vaultManager, embeddingProvider);
150
150
 
151
+ // Wire canonical tag config if provided
152
+ if (config.canonicalTags && config.canonicalTags.length > 0) {
153
+ brain.setCanonicalTagConfig({
154
+ canonicalTags: config.canonicalTags,
155
+ tagConstraintMode: config.tagConstraintMode ?? 'suggest',
156
+ metadataTagPrefixes: config.metadataTagPrefixes ?? ['source:'],
157
+ });
158
+ }
159
+
151
160
  // Brain Intelligence — pattern strengths, session knowledge, intelligence pipeline
152
161
  const brainIntelligence = new BrainIntelligence(vault, brain);
153
162
 
@@ -199,6 +208,15 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
199
208
  const intakePipeline = new IntakePipeline(vault.getProvider(), vault, llmClient);
200
209
  const textIngester = new TextIngester(vault, llmClient);
201
210
 
211
+ // Wire canonical tag config into TextIngester if provided
212
+ if (config.canonicalTags && config.canonicalTags.length > 0) {
213
+ textIngester.setCanonicalTagConfig({
214
+ canonicalTags: config.canonicalTags,
215
+ tagConstraintMode: config.tagConstraintMode ?? 'suggest',
216
+ metadataTagPrefixes: config.metadataTagPrefixes ?? ['source:'],
217
+ });
218
+ }
219
+
202
220
  // Playbook Executor — in-memory step-by-step workflow sessions
203
221
  const playbookExecutor = new PlaybookExecutor();
204
222
 
@@ -79,6 +79,25 @@ export interface AgentRuntimeConfig {
79
79
  persona?: Partial<import('../persona/types.js').PersonaConfig>;
80
80
  /** Embedding provider configuration. If omitted, embeddings are disabled. */
81
81
  embedding?: EmbeddingConfig;
82
+ /**
83
+ * Canonical tag taxonomy configuration.
84
+ * When set, tags are normalized against this list during capture and ingestion.
85
+ */
86
+ canonicalTags?: string[];
87
+ /**
88
+ * Tag constraint mode.
89
+ * - 'enforce': tags not matching canonical list are dropped (unless within edit-distance 3).
90
+ * - 'suggest': tags are mapped to nearest canonical if within edit-distance 2 (default).
91
+ * - 'off': no normalization — behavior unchanged from pre-taxonomy.
92
+ * Default: 'suggest'
93
+ */
94
+ tagConstraintMode?: 'enforce' | 'suggest' | 'off';
95
+ /**
96
+ * Metadata tag prefixes — tags with these prefixes (e.g. 'source:') are treated as metadata
97
+ * and are exempt from canonical normalization.
98
+ * Default: ['source:']
99
+ */
100
+ metadataTagPrefixes?: string[];
82
101
  }
83
102
 
84
103
  /**
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Unit tests for validate-skills — the user-installed SKILL.md validator.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { validateSkillDocs } from './validate-skills.js';
10
+
11
+ // ── Helpers ──────────────────────────────────────────────────────────────
12
+
13
+ function createSkillsDir(): string {
14
+ return mkdtempSync(join(tmpdir(), 'soleri-validate-skills-test-'));
15
+ }
16
+
17
+ function addSkill(skillsDir: string, skillName: string, content: string): void {
18
+ const skillDir = join(skillsDir, skillName);
19
+ mkdirSync(skillDir, { recursive: true });
20
+ writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf-8');
21
+ }
22
+
23
+ // ── Tests ────────────────────────────────────────────────────────────────
24
+
25
+ describe('validateSkillDocs', () => {
26
+ let skillsDir: string;
27
+
28
+ beforeEach(() => {
29
+ skillsDir = createSkillsDir();
30
+ });
31
+
32
+ afterEach(() => {
33
+ rmSync(skillsDir, { recursive: true, force: true });
34
+ });
35
+
36
+ it('returns valid=true and no errors when the skills directory is empty', () => {
37
+ const result = validateSkillDocs(skillsDir);
38
+ expect(result.valid).toBe(true);
39
+ expect(result.errors).toHaveLength(0);
40
+ expect(result.totalFiles).toBe(0);
41
+ expect(result.totalExamples).toBe(0);
42
+ });
43
+
44
+ it('returns valid=true for a SKILL.md with no op-call examples', () => {
45
+ addSkill(
46
+ skillsDir,
47
+ 'my-skill',
48
+ `# My Skill
49
+
50
+ This skill does something useful.
51
+
52
+ ## Usage
53
+
54
+ Just invoke it.
55
+ `,
56
+ );
57
+
58
+ const result = validateSkillDocs(skillsDir);
59
+ expect(result.valid).toBe(true);
60
+ expect(result.errors).toHaveLength(0);
61
+ expect(result.totalFiles).toBe(1);
62
+ expect(result.totalExamples).toBe(0);
63
+ });
64
+
65
+ it('returns valid=true when op-call params match the schema', () => {
66
+ addSkill(
67
+ skillsDir,
68
+ 'capture-skill',
69
+ `# Capture Skill
70
+
71
+ Captures knowledge to the vault.
72
+
73
+ \`\`\`
74
+ YOUR_AGENT_core op:capture_knowledge params: { projectPath: ".", entries: [{ type: "pattern", domain: "testing", title: "Use vitest", description: "Prefer vitest for unit tests", severity: "info" }] }
75
+ \`\`\`
76
+ `,
77
+ );
78
+
79
+ const result = validateSkillDocs(skillsDir);
80
+ expect(result.valid).toBe(true);
81
+ expect(result.errors).toHaveLength(0);
82
+ });
83
+
84
+ it('reports an error when severity has an invalid enum value', () => {
85
+ // "suggestion" is not in the capture_knowledge severity enum (valid: critical, warning, info)
86
+ addSkill(
87
+ skillsDir,
88
+ 'bad-severity-skill',
89
+ `# Bad Skill
90
+
91
+ Example with wrong severity enum:
92
+
93
+ \`\`\`
94
+ YOUR_AGENT_core op:capture_knowledge params: { entries: [{ type: "pattern", domain: "testing", title: "Test", description: "A test", severity: "suggestion" }] }
95
+ \`\`\`
96
+ `,
97
+ );
98
+
99
+ const result = validateSkillDocs(skillsDir);
100
+ expect(result.valid).toBe(false);
101
+ expect(result.errors.length).toBeGreaterThan(0);
102
+
103
+ const severityError = result.errors.find(
104
+ (e) => e.op === 'capture_knowledge' && e.message.toLowerCase().includes('invalid'),
105
+ );
106
+ expect(severityError).toBeDefined();
107
+ expect(severityError!.file).toContain('bad-severity-skill');
108
+ });
109
+
110
+ it('reports an error when scope receives an object instead of a string', () => {
111
+ // create_plan scope expects z.string() but we pass an object
112
+ addSkill(
113
+ skillsDir,
114
+ 'bad-scope-skill',
115
+ `# Bad Scope Skill
116
+
117
+ Example with wrong scope type:
118
+
119
+ \`\`\`
120
+ YOUR_AGENT_core op:create_plan params: { title: "My Plan", objective: "Do something", scope: { included: [] } }
121
+ \`\`\`
122
+ `,
123
+ );
124
+
125
+ const result = validateSkillDocs(skillsDir);
126
+ expect(result.valid).toBe(false);
127
+ expect(result.errors.length).toBeGreaterThan(0);
128
+
129
+ const scopeError = result.errors.find(
130
+ (e) => e.op === 'create_plan' && e.message.includes('scope'),
131
+ );
132
+ expect(scopeError).toBeDefined();
133
+ expect(scopeError!.message).toContain('Expected string');
134
+ });
135
+
136
+ it('returns structured error objects with required fields', () => {
137
+ addSkill(
138
+ skillsDir,
139
+ 'structured-error-skill',
140
+ `# Structured Error Skill
141
+
142
+ \`\`\`
143
+ YOUR_AGENT_core op:capture_knowledge params: { entries: [{ type: "pattern", domain: "testing", title: "Test", description: "A test", severity: "suggestion" }] }
144
+ \`\`\`
145
+ `,
146
+ );
147
+
148
+ const result = validateSkillDocs(skillsDir);
149
+
150
+ if (result.errors.length > 0) {
151
+ const err = result.errors[0];
152
+ expect(err).toHaveProperty('file');
153
+ expect(err).toHaveProperty('op');
154
+ expect(err).toHaveProperty('message');
155
+ expect(typeof err.file).toBe('string');
156
+ expect(typeof err.op).toBe('string');
157
+ expect(typeof err.message).toBe('string');
158
+ }
159
+ });
160
+
161
+ it('includes the file path and op name in each error', () => {
162
+ addSkill(
163
+ skillsDir,
164
+ 'named-skill',
165
+ `# Named Skill
166
+
167
+ \`\`\`
168
+ YOUR_AGENT_core op:capture_knowledge params: { entries: [{ type: "pattern", domain: "testing", title: "Test", description: "A test", severity: "suggestion" }] }
169
+ \`\`\`
170
+ `,
171
+ );
172
+
173
+ const result = validateSkillDocs(skillsDir);
174
+ expect(result.errors.length).toBeGreaterThan(0);
175
+
176
+ const err = result.errors[0];
177
+ expect(err.file).toContain('named-skill');
178
+ expect(err.op).toBe('capture_knowledge');
179
+ });
180
+
181
+ it('builds a non-empty schema registry', () => {
182
+ const result = validateSkillDocs(skillsDir);
183
+ expect(result.registrySize).toBeGreaterThan(50);
184
+ });
185
+
186
+ it('handles a skills directory that does not exist', () => {
187
+ const nonExistentDir = join(skillsDir, 'does-not-exist');
188
+ const result = validateSkillDocs(nonExistentDir);
189
+ expect(result.valid).toBe(true);
190
+ expect(result.totalFiles).toBe(0);
191
+ expect(result.errors).toHaveLength(0);
192
+ });
193
+
194
+ it('counts multiple skill files correctly', () => {
195
+ addSkill(
196
+ skillsDir,
197
+ 'skill-one',
198
+ `# Skill One\n\n\`\`\`\nYOUR_AGENT_core op:capture_quick params: { title: "Test", content: "Content" }\n\`\`\`\n`,
199
+ );
200
+ addSkill(skillsDir, 'skill-two', `# Skill Two\n\nNo examples here.\n`);
201
+
202
+ const result = validateSkillDocs(skillsDir);
203
+ expect(result.totalFiles).toBe(2);
204
+ });
205
+ });