@soleri/core 9.14.0 → 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 (138) 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/sync-skills.d.ts.map +1 -1
  51. package/dist/skills/sync-skills.js +9 -3
  52. package/dist/skills/sync-skills.js.map +1 -1
  53. package/dist/skills/validate-skills.d.ts +32 -0
  54. package/dist/skills/validate-skills.d.ts.map +1 -0
  55. package/dist/skills/validate-skills.js +396 -0
  56. package/dist/skills/validate-skills.js.map +1 -0
  57. package/dist/vault/default-canonical-tags.d.ts +15 -0
  58. package/dist/vault/default-canonical-tags.d.ts.map +1 -0
  59. package/dist/vault/default-canonical-tags.js +65 -0
  60. package/dist/vault/default-canonical-tags.js.map +1 -0
  61. package/dist/vault/tag-normalizer.d.ts +42 -0
  62. package/dist/vault/tag-normalizer.d.ts.map +1 -0
  63. package/dist/vault/tag-normalizer.js +157 -0
  64. package/dist/vault/tag-normalizer.js.map +1 -0
  65. package/package.json +6 -2
  66. package/src/__tests__/embeddings.test.ts +3 -3
  67. package/src/brain/brain.ts +25 -1
  68. package/src/brain/intelligence.ts +25 -0
  69. package/src/brain/types.ts +1 -0
  70. package/src/chat/chat-session.ts +75 -17
  71. package/src/chat/chat-transport.test.ts +31 -1
  72. package/src/curator/curator.ts +180 -0
  73. package/src/curator/types.ts +10 -0
  74. package/src/index.ts +7 -0
  75. package/src/intake/content-classifier.ts +22 -4
  76. package/src/intake/text-ingester.ts +61 -12
  77. package/src/planning/planner.test.ts +86 -90
  78. package/src/planning/planner.ts +48 -4
  79. package/src/runtime/admin-setup-ops.test.ts +44 -0
  80. package/src/runtime/admin-setup-ops.ts +59 -20
  81. package/src/runtime/facades/orchestrate-facade.ts +27 -1
  82. package/src/runtime/runtime.ts +18 -0
  83. package/src/runtime/types.ts +19 -0
  84. package/src/skills/sync-skills.ts +9 -3
  85. package/src/skills/validate-skills.test.ts +205 -0
  86. package/src/skills/validate-skills.ts +470 -0
  87. package/src/vault/default-canonical-tags.ts +64 -0
  88. package/src/vault/tag-normalizer.test.ts +214 -0
  89. package/src/vault/tag-normalizer.ts +188 -0
  90. package/dist/embeddings/index.d.ts +0 -5
  91. package/dist/embeddings/index.d.ts.map +0 -1
  92. package/dist/embeddings/index.js +0 -3
  93. package/dist/embeddings/index.js.map +0 -1
  94. package/dist/knowledge-packs/knowledge-packs/community/.gitkeep +0 -0
  95. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/soleri-pack.json +0 -10
  96. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/vault/accessibility.json +0 -53
  97. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/vault/design-tokens.json +0 -26
  98. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/vault/design.json +0 -33
  99. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/vault/styling.json +0 -44
  100. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/vault/ux-laws.json +0 -36
  101. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-craft/vault/ux.json +0 -36
  102. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/soleri-pack.json +0 -10
  103. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/architecture.json +0 -143
  104. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/commercial.json +0 -16
  105. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/communication.json +0 -33
  106. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/component.json +0 -16
  107. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/express.json +0 -34
  108. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/leadership.json +0 -33
  109. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/methodology.json +0 -33
  110. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/monorepo.json +0 -33
  111. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/other.json +0 -73
  112. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/performance.json +0 -35
  113. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/prisma.json +0 -33
  114. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/product-strategy.json +0 -42
  115. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/react.json +0 -47
  116. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/security.json +0 -34
  117. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/testing.json +0 -33
  118. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/tooling.json +0 -85
  119. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/typescript.json +0 -34
  120. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-engineering/vault/workflow.json +0 -46
  121. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-uipro/soleri-pack.json +0 -10
  122. package/dist/knowledge-packs/knowledge-packs/salvador/salvador-uipro/vault/design.json +0 -2589
  123. package/dist/knowledge-packs/knowledge-packs/starter/architecture/soleri-pack.json +0 -10
  124. package/dist/knowledge-packs/knowledge-packs/starter/architecture/vault/patterns.json +0 -137
  125. package/dist/knowledge-packs/knowledge-packs/starter/design/soleri-pack.json +0 -10
  126. package/dist/knowledge-packs/knowledge-packs/starter/design/vault/patterns.json +0 -137
  127. package/dist/knowledge-packs/knowledge-packs/starter/security/soleri-pack.json +0 -10
  128. package/dist/knowledge-packs/knowledge-packs/starter/security/vault/patterns.json +0 -137
  129. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/api-design/soleri-pack.json +0 -0
  130. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/api-design/vault/patterns.json +0 -0
  131. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/nodejs/soleri-pack.json +0 -0
  132. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/nodejs/vault/patterns.json +0 -0
  133. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/react/soleri-pack.json +0 -0
  134. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/react/vault/patterns.json +0 -0
  135. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/testing/soleri-pack.json +0 -0
  136. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/testing/vault/patterns.json +0 -0
  137. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/typescript/soleri-pack.json +0 -0
  138. /package/dist/knowledge-packs/{knowledge-packs/starter → starter}/typescript/vault/patterns.json +0 -0
@@ -74,6 +74,26 @@ describe('Planner', () => {
74
74
  const planner2 = new Planner(join(tempDir, 'plans.json'));
75
75
  expect(planner2.list()).toHaveLength(1);
76
76
  });
77
+
78
+ it('should make newly created plans visible to other planner instances', () => {
79
+ const planner2 = new Planner(join(tempDir, 'plans.json'));
80
+ const plan = planner.create({ objective: 'Shared plan', scope: 'sync' });
81
+
82
+ expect(planner2.get(plan.id)?.objective).toBe('Shared plan');
83
+ });
84
+
85
+ it('should merge plans created by multiple planner instances', () => {
86
+ const planner2 = new Planner(join(tempDir, 'plans.json'));
87
+ const planA = planner.create({ objective: 'Plan A', scope: 'sync-a' });
88
+ const planB = planner2.create({ objective: 'Plan B', scope: 'sync-b' });
89
+
90
+ const reloaded = new Planner(join(tempDir, 'plans.json'));
91
+ const ids = reloaded.list().map((plan) => plan.id);
92
+
93
+ expect(ids).toContain(planA.id);
94
+ expect(ids).toContain(planB.id);
95
+ expect(ids).toHaveLength(2);
96
+ });
77
97
  });
78
98
 
79
99
  describe('get', () => {
@@ -120,26 +140,36 @@ describe('Planner', () => {
120
140
 
121
141
  it('should approve a plan with A+ grade', () => {
122
142
  const plan = planner.create({
123
- objective: 'Well-graded plan',
124
- scope: 'test',
143
+ objective: 'Implement a Redis caching layer for the API to reduce DB load by 50%',
144
+ scope: 'Backend API services only. Does not include frontend caching or CDN.',
125
145
  tasks: [
126
- { title: 'Task 1', description: 'A well-described task for implementation' },
127
- { title: 'Task 2', description: 'Another well-described task for testing' },
146
+ {
147
+ title: 'Set up Redis client',
148
+ description: 'Install and configure Redis connection pool',
149
+ },
150
+ {
151
+ title: 'Add cache middleware',
152
+ description: 'Express middleware for transparent caching',
153
+ },
154
+ {
155
+ title: 'Add invalidation logic',
156
+ description: 'Purge cache on write operations to ensure consistency',
157
+ },
158
+ {
159
+ title: 'Write integration tests',
160
+ description: 'Test cache hit/miss scenarios with Redis',
161
+ },
162
+ { title: 'Add monitoring', description: 'Track and verify cache hit rate metrics' },
128
163
  ],
129
- decisions: ['Use TypeScript for type safety'],
130
- });
131
- // Grade the plan set an A+ grade check
132
- const p = planner.get(plan.id)!;
133
- p.latestCheck = {
134
- checkId: 'chk-test-aplus',
135
- planId: plan.id,
136
- grade: 'A+',
137
- score: 98,
138
- gaps: [],
139
- iteration: 1,
140
- checkedAt: Date.now(),
141
- };
142
- p.checks = [p.latestCheck];
164
+ decisions: [
165
+ 'Use Redis because it provides sub-millisecond latency and supports TTL natively',
166
+ 'Set TTL to 5 minutes since average data freshness requirement is 10 minutes',
167
+ ],
168
+ alternatives: TWO_ALTERNATIVES,
169
+ });
170
+ const check = planner.grade(plan.id);
171
+ expect(check.score).toBeGreaterThanOrEqual(95);
172
+ expect(check.grade).toMatch(/^A/);
143
173
  const approved = planner.approve(plan.id);
144
174
  expect(approved.status).toBe('approved');
145
175
  });
@@ -149,27 +179,8 @@ describe('Planner', () => {
149
179
  objective: 'Bad plan',
150
180
  scope: 'test',
151
181
  });
152
- // Manually set a B grade check on the plan
153
- const p = planner.get(plan.id)!;
154
- p.latestCheck = {
155
- checkId: 'chk-test',
156
- planId: plan.id,
157
- grade: 'B',
158
- score: 82,
159
- gaps: [
160
- {
161
- id: 'gap-1',
162
- severity: 'major',
163
- category: 'completeness',
164
- description: 'Missing tasks',
165
- recommendation: 'Add tasks',
166
- location: 'tasks',
167
- },
168
- ],
169
- iteration: 1,
170
- checkedAt: Date.now(),
171
- };
172
- p.checks = [p.latestCheck];
182
+ const check = planner.grade(plan.id);
183
+ expect(check.score).toBeLessThan(90);
173
184
  expect(() => planner.approve(plan.id)).toThrow(PlanGradeRejectionError);
174
185
  });
175
186
 
@@ -184,68 +195,52 @@ describe('Planner', () => {
184
195
  const lenientPlanner = new Planner(join(tempDir, 'lenient-plans.json'), {
185
196
  minGradeForApproval: 'B',
186
197
  });
187
- const plan = lenientPlanner.create({ objective: 'B-grade plan', scope: 'test' });
188
- // Set a B grade check
189
- const p = lenientPlanner.get(plan.id)!;
190
- p.latestCheck = {
191
- checkId: 'chk-test-b',
192
- planId: plan.id,
193
- grade: 'B',
194
- score: 82,
195
- gaps: [],
196
- iteration: 1,
197
- checkedAt: Date.now(),
198
- };
199
- p.checks = [p.latestCheck];
200
- // B grade should pass with B threshold
198
+ const plan = lenientPlanner.create({
199
+ objective: 'B-grade plan with enough detail',
200
+ scope: 'Test scope with clear boundary',
201
+ decisions: ['Use TypeScript for compile-time safety'],
202
+ tasks: [
203
+ { title: 'Task 1', description: 'Implement the first part of the change' },
204
+ { title: 'Task 2', description: 'Validate the second part of the change' },
205
+ ],
206
+ });
207
+ const check = lenientPlanner.grade(plan.id);
208
+ expect(check.grade).toBe('B');
201
209
  const approved = lenientPlanner.approve(plan.id);
202
210
  expect(approved.status).toBe('approved');
203
211
  });
204
212
 
205
213
  it('should reject with PlanGradeRejectionError containing gap details', () => {
206
- const plan = planner.create({ objective: 'Gap details', scope: 'test' });
207
- const p = planner.get(plan.id)!;
208
- const testGaps = [
209
- {
210
- id: 'gap-crit',
211
- severity: 'critical' as const,
212
- category: 'structure',
213
- description: 'Missing critical structure',
214
- recommendation: 'Fix structure',
215
- location: 'tasks',
216
- },
217
- {
218
- id: 'gap-maj',
219
- severity: 'major' as const,
220
- category: 'completeness',
221
- description: 'Incomplete scope',
222
- recommendation: 'Add scope details',
223
- location: 'scope',
224
- },
225
- ];
226
- p.latestCheck = {
227
- checkId: 'chk-test-gaps',
228
- planId: plan.id,
229
- grade: 'C',
230
- score: 65,
231
- gaps: testGaps,
232
- iteration: 1,
233
- checkedAt: Date.now(),
234
- };
235
- p.checks = [p.latestCheck];
214
+ const plan = planner.create({ objective: '', scope: '' });
215
+ const check = planner.grade(plan.id);
236
216
  try {
237
217
  planner.approve(plan.id);
238
218
  expect.unreachable('Should have thrown');
239
219
  } catch (err) {
240
220
  expect(err).toBeInstanceOf(PlanGradeRejectionError);
241
221
  const rejection = err as PlanGradeRejectionError;
242
- expect(rejection.grade).toBe('C');
243
- expect(rejection.score).toBe(65);
222
+ expect(rejection.grade).toBe(check.grade);
223
+ expect(rejection.score).toBe(check.score);
244
224
  expect(rejection.minGrade).toBe('A');
245
- expect(rejection.gaps).toHaveLength(2);
225
+ expect(rejection.gaps).toEqual(check.gaps);
246
226
  expect(rejection.message).toContain('below the minimum required grade A');
247
227
  }
248
228
  });
229
+
230
+ it('should respect grading performed by another planner instance', () => {
231
+ const strictPlanner = new Planner(join(tempDir, 'shared-plans.json'), {
232
+ minGradeForApproval: 'A',
233
+ });
234
+ const gradingPlanner = new Planner(join(tempDir, 'shared-plans.json'), {
235
+ minGradeForApproval: 'A',
236
+ });
237
+ const plan = strictPlanner.create({ objective: '', scope: '' });
238
+
239
+ const check = gradingPlanner.grade(plan.id);
240
+
241
+ expect(check.grade).toBe('F');
242
+ expect(() => strictPlanner.approve(plan.id)).toThrow(PlanGradeRejectionError);
243
+ });
249
244
  });
250
245
 
251
246
  describe('startExecution', () => {
@@ -592,10 +587,11 @@ describe('Planner', () => {
592
587
  { title: 'Task C', description: 'Third task (not in cycle)' },
593
588
  ],
594
589
  });
595
- // Manually create circular deps
596
- const p = planner.get(plan.id)!;
597
- p.tasks[0].dependsOn = ['task-2'];
598
- p.tasks[1].dependsOn = ['task-1'];
590
+ planner.splitTasks(plan.id, [
591
+ { title: 'Task A', description: 'First task in the cycle', dependsOn: ['task-2'] },
592
+ { title: 'Task B', description: 'Second task in the cycle', dependsOn: ['task-1'] },
593
+ { title: 'Task C', description: 'Third task (not in cycle)' },
594
+ ]);
599
595
  const check = planner.grade(plan.id);
600
596
  const circGap = check.gaps.find((g) => g.description.includes('Circular'));
601
597
  expect(circGap).toBeDefined();
@@ -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
  /**
@@ -257,20 +257,26 @@ export function syncSkillsToClaudeCode(
257
257
  }
258
258
 
259
259
  /**
260
- * Remove existing `{agent}-soleri-*` entries from ~/.claude/skills/
260
+ * Remove ALL `{agent}-soleri-*` entries from ~/.claude/skills/
261
261
  * to clean up duplicates left by the old global-install behavior.
262
+ *
263
+ * Cleans entries from ALL agents, not just the current one — any
264
+ * `*-soleri-*` entry in the global dir is a stale copy from a previous
265
+ * global install. Canonical skills now live in project-local .claude/skills/.
262
266
  */
263
267
  function cleanStaleGlobalSkills(agentName: string, result: SyncResult): void {
264
268
  const globalSkillsDir = join(homedir(), '.claude', 'skills');
265
269
  if (!existsSync(globalSkillsDir)) return;
266
270
 
267
- const prefix = `${agentName.toLowerCase().replace(/\s+/g, '-')}-soleri-`;
271
+ // Match any agent-prefixed soleri skill: <anything>-soleri-<skillname>
272
+ // Canonical project-local names look like "soleri-*" (no agent prefix).
273
+ const stalePattern = /^.+-soleri-.+$/;
268
274
 
269
275
  try {
270
276
  const entries = readdirSync(globalSkillsDir, { withFileTypes: true });
271
277
  for (const entry of entries) {
272
278
  if (!entry.isDirectory()) continue;
273
- if (!entry.name.startsWith(prefix)) continue;
279
+ if (!stalePattern.test(entry.name)) continue;
274
280
 
275
281
  const staleDir = join(globalSkillsDir, entry.name);
276
282
  try {