@soleri/core 9.15.0 → 9.16.7

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 (288) hide show
  1. package/data/flows/deliver.flow.yaml +11 -0
  2. package/data/flows/design.flow.yaml +4 -14
  3. package/data/flows/enhance.flow.yaml +10 -0
  4. package/data/flows/explore.flow.yaml +16 -0
  5. package/data/flows/fix.flow.yaml +1 -1
  6. package/data/flows/review.flow.yaml +13 -4
  7. package/dist/capabilities/chain-mapping.d.ts.map +1 -1
  8. package/dist/capabilities/chain-mapping.js +5 -4
  9. package/dist/capabilities/chain-mapping.js.map +1 -1
  10. package/dist/capabilities/registry.d.ts +6 -0
  11. package/dist/capabilities/registry.d.ts.map +1 -1
  12. package/dist/capabilities/registry.js +3 -2
  13. package/dist/capabilities/registry.js.map +1 -1
  14. package/dist/context/context-engine.js +1 -1
  15. package/dist/context/context-engine.js.map +1 -1
  16. package/dist/engine/core-ops.d.ts.map +1 -1
  17. package/dist/engine/core-ops.js +38 -1
  18. package/dist/engine/core-ops.js.map +1 -1
  19. package/dist/flows/epilogue.d.ts +5 -1
  20. package/dist/flows/epilogue.d.ts.map +1 -1
  21. package/dist/flows/epilogue.js +11 -3
  22. package/dist/flows/epilogue.js.map +1 -1
  23. package/dist/flows/executor.d.ts.map +1 -1
  24. package/dist/flows/executor.js +13 -5
  25. package/dist/flows/executor.js.map +1 -1
  26. package/dist/flows/index.d.ts +1 -2
  27. package/dist/flows/index.d.ts.map +1 -1
  28. package/dist/flows/index.js +1 -0
  29. package/dist/flows/index.js.map +1 -1
  30. package/dist/flows/plan-builder.d.ts +17 -1
  31. package/dist/flows/plan-builder.d.ts.map +1 -1
  32. package/dist/flows/plan-builder.js +67 -6
  33. package/dist/flows/plan-builder.js.map +1 -1
  34. package/dist/flows/probes.d.ts +1 -1
  35. package/dist/flows/probes.d.ts.map +1 -1
  36. package/dist/flows/probes.js +15 -3
  37. package/dist/flows/probes.js.map +1 -1
  38. package/dist/flows/types.d.ts +31 -4
  39. package/dist/flows/types.d.ts.map +1 -1
  40. package/dist/flows/types.js +6 -1
  41. package/dist/flows/types.js.map +1 -1
  42. package/dist/index.d.ts +8 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/packs/pack-installer.d.ts.map +1 -1
  47. package/dist/packs/pack-installer.js +28 -2
  48. package/dist/packs/pack-installer.js.map +1 -1
  49. package/dist/planning/planner-types.d.ts +2 -0
  50. package/dist/planning/planner-types.d.ts.map +1 -1
  51. package/dist/planning/planner.d.ts +1 -0
  52. package/dist/planning/planner.d.ts.map +1 -1
  53. package/dist/planning/planner.js +7 -0
  54. package/dist/planning/planner.js.map +1 -1
  55. package/dist/playbooks/playbook-executor.d.ts +10 -1
  56. package/dist/playbooks/playbook-executor.d.ts.map +1 -1
  57. package/dist/playbooks/playbook-executor.js +8 -2
  58. package/dist/playbooks/playbook-executor.js.map +1 -1
  59. package/dist/playbooks/playbook-types.d.ts +8 -0
  60. package/dist/playbooks/playbook-types.d.ts.map +1 -1
  61. package/dist/runtime/admin-extra-ops.d.ts.map +1 -1
  62. package/dist/runtime/admin-extra-ops.js +30 -0
  63. package/dist/runtime/admin-extra-ops.js.map +1 -1
  64. package/dist/runtime/admin-ops.d.ts.map +1 -1
  65. package/dist/runtime/admin-ops.js +60 -21
  66. package/dist/runtime/admin-ops.js.map +1 -1
  67. package/dist/runtime/admin-setup-ops.d.ts +11 -0
  68. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  69. package/dist/runtime/admin-setup-ops.js +87 -17
  70. package/dist/runtime/admin-setup-ops.js.map +1 -1
  71. package/dist/runtime/capture-ops.d.ts.map +1 -1
  72. package/dist/runtime/capture-ops.js +38 -12
  73. package/dist/runtime/capture-ops.js.map +1 -1
  74. package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
  75. package/dist/runtime/facades/brain-facade.js +16 -4
  76. package/dist/runtime/facades/brain-facade.js.map +1 -1
  77. package/dist/runtime/facades/context-facade.d.ts.map +1 -1
  78. package/dist/runtime/facades/context-facade.js +9 -3
  79. package/dist/runtime/facades/context-facade.js.map +1 -1
  80. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  81. package/dist/runtime/facades/memory-facade.js +20 -7
  82. package/dist/runtime/facades/memory-facade.js.map +1 -1
  83. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  84. package/dist/runtime/facades/orchestrate-facade.js +12 -0
  85. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  86. package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
  87. package/dist/runtime/facades/plan-facade.js +113 -4
  88. package/dist/runtime/facades/plan-facade.js.map +1 -1
  89. package/dist/runtime/facades/vault-facade.d.ts.map +1 -1
  90. package/dist/runtime/facades/vault-facade.js +24 -3
  91. package/dist/runtime/facades/vault-facade.js.map +1 -1
  92. package/dist/runtime/orchestrate-ops.d.ts +21 -0
  93. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  94. package/dist/runtime/orchestrate-ops.js +132 -38
  95. package/dist/runtime/orchestrate-ops.js.map +1 -1
  96. package/dist/runtime/schema-helpers.d.ts.map +1 -1
  97. package/dist/runtime/schema-helpers.js +4 -0
  98. package/dist/runtime/schema-helpers.js.map +1 -1
  99. package/dist/runtime/vault-linking-ops.d.ts.map +1 -1
  100. package/dist/runtime/vault-linking-ops.js +16 -3
  101. package/dist/runtime/vault-linking-ops.js.map +1 -1
  102. package/dist/scheduler/cron-validator.d.ts +15 -0
  103. package/dist/scheduler/cron-validator.d.ts.map +1 -0
  104. package/dist/scheduler/cron-validator.js +93 -0
  105. package/dist/scheduler/cron-validator.js.map +1 -0
  106. package/dist/scheduler/platform-linux.d.ts +14 -0
  107. package/dist/scheduler/platform-linux.d.ts.map +1 -0
  108. package/dist/scheduler/platform-linux.js +107 -0
  109. package/dist/scheduler/platform-linux.js.map +1 -0
  110. package/dist/scheduler/platform-macos.d.ts +15 -0
  111. package/dist/scheduler/platform-macos.d.ts.map +1 -0
  112. package/dist/scheduler/platform-macos.js +131 -0
  113. package/dist/scheduler/platform-macos.js.map +1 -0
  114. package/dist/scheduler/scheduler-ops.d.ts +14 -0
  115. package/dist/scheduler/scheduler-ops.d.ts.map +1 -0
  116. package/dist/scheduler/scheduler-ops.js +77 -0
  117. package/dist/scheduler/scheduler-ops.js.map +1 -0
  118. package/dist/scheduler/scheduler.d.ts +55 -0
  119. package/dist/scheduler/scheduler.d.ts.map +1 -0
  120. package/dist/scheduler/scheduler.js +144 -0
  121. package/dist/scheduler/scheduler.js.map +1 -0
  122. package/dist/scheduler/types.d.ts +48 -0
  123. package/dist/scheduler/types.d.ts.map +1 -0
  124. package/dist/scheduler/types.js +6 -0
  125. package/dist/scheduler/types.js.map +1 -0
  126. package/dist/skills/sync-skills.d.ts +11 -0
  127. package/dist/skills/sync-skills.d.ts.map +1 -1
  128. package/dist/skills/sync-skills.js +132 -38
  129. package/dist/skills/sync-skills.js.map +1 -1
  130. package/dist/utils/worktree-reaper.d.ts +38 -0
  131. package/dist/utils/worktree-reaper.d.ts.map +1 -0
  132. package/dist/utils/worktree-reaper.js +85 -0
  133. package/dist/utils/worktree-reaper.js.map +1 -0
  134. package/dist/vault/scope-detector.d.ts.map +1 -1
  135. package/dist/vault/scope-detector.js +37 -4
  136. package/dist/vault/scope-detector.js.map +1 -1
  137. package/dist/vault/vault-entries.d.ts.map +1 -1
  138. package/dist/vault/vault-entries.js +3 -1
  139. package/dist/vault/vault-entries.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/agency/agency-manager.test.ts +4 -4
  142. package/src/agency/default-rules.test.ts +0 -13
  143. package/src/brain/brain-intelligence.test.ts +0 -5
  144. package/src/brain/second-brain-features.test.ts +2 -14
  145. package/src/capabilities/chain-mapping.test.ts +1 -6
  146. package/src/capabilities/chain-mapping.ts +6 -4
  147. package/src/capabilities/registry.test.ts +1 -1
  148. package/src/capabilities/registry.ts +9 -2
  149. package/src/chat/agent-loop.test.ts +1 -1
  150. package/src/chat/chat-enhanced.test.ts +0 -8
  151. package/src/claudemd/compose.test.ts +0 -5
  152. package/src/context/context-engine.test.ts +0 -1
  153. package/src/context/context-engine.ts +1 -1
  154. package/src/control/intent-router.test.ts +2 -2
  155. package/src/curator/tag-manager.test.ts +0 -4
  156. package/src/domain-packs/types.test.ts +0 -5
  157. package/src/dream/dream.test.ts +0 -7
  158. package/src/enforcement/registry.test.ts +2 -2
  159. package/src/engine/core-ops.test.ts +4 -22
  160. package/src/engine/core-ops.ts +36 -1
  161. package/src/engine/module-manifest.test.ts +1 -31
  162. package/src/engine/register-engine.test.ts +3 -33
  163. package/src/errors/retry.test.ts +3 -1
  164. package/src/flows/chain-runner.test.ts +0 -6
  165. package/src/flows/context-router.test.ts +3 -3
  166. package/src/flows/epilogue.test.ts +40 -2
  167. package/src/flows/epilogue.ts +11 -2
  168. package/src/flows/executor.test.ts +48 -2
  169. package/src/flows/executor.ts +15 -5
  170. package/src/flows/index.ts +1 -3
  171. package/src/flows/plan-builder.test.ts +201 -0
  172. package/src/flows/plan-builder.ts +81 -5
  173. package/src/flows/probes.ts +17 -3
  174. package/src/flows/types.ts +31 -2
  175. package/src/health/health-registry.test.ts +3 -1
  176. package/src/index.ts +17 -0
  177. package/src/intake/dedup-gate.test.ts +2 -6
  178. package/src/intake/text-ingester.test.ts +3 -4
  179. package/src/llm/llm-client.test.ts +1 -1
  180. package/src/llm/utils.test.ts +1 -1
  181. package/src/migrations/migration-runner.test.ts +0 -1
  182. package/src/operator/operator-context-store.test.ts +0 -13
  183. package/src/operator/operator-profile.test.ts +2 -20
  184. package/src/packs/pack-installer.ts +28 -2
  185. package/src/packs/pack-system.test.ts +2 -2
  186. package/src/persona/defaults.test.ts +19 -19
  187. package/src/planning/gap-passes.test.ts +0 -46
  188. package/src/planning/gap-patterns.test.ts +0 -42
  189. package/src/planning/goal-ancestry.test.ts +3 -1
  190. package/src/planning/plan-lifecycle.test.ts +15 -7
  191. package/src/planning/planner-types.ts +2 -0
  192. package/src/planning/planner.ts +8 -0
  193. package/src/planning/reconciliation-engine.test.ts +3 -10
  194. package/src/planning/task-complexity-assessor.test.ts +0 -5
  195. package/src/planning/task-verifier.test.ts +3 -1
  196. package/src/playbooks/generic/generic-playbooks.test.ts +0 -28
  197. package/src/playbooks/index.test.ts +0 -55
  198. package/src/playbooks/playbook-executor.test.ts +76 -0
  199. package/src/playbooks/playbook-executor.ts +24 -3
  200. package/src/playbooks/playbook-types.ts +8 -0
  201. package/src/plugins/plugin-registry.test.ts +6 -2
  202. package/src/project/project-registry.test.ts +2 -0
  203. package/src/queue/async-infrastructure.test.ts +6 -4
  204. package/src/queue/job-queue.test.ts +13 -7
  205. package/src/runtime/admin-extra-ops.test.ts +35 -30
  206. package/src/runtime/admin-extra-ops.ts +30 -0
  207. package/src/runtime/admin-ops.test.ts +0 -4
  208. package/src/runtime/admin-ops.ts +63 -21
  209. package/src/runtime/admin-setup-ops.test.ts +185 -13
  210. package/src/runtime/admin-setup-ops.ts +86 -16
  211. package/src/runtime/archive-ops.test.ts +0 -28
  212. package/src/runtime/branching-ops.test.ts +0 -17
  213. package/src/runtime/capture-ops.test.ts +41 -16
  214. package/src/runtime/capture-ops.ts +78 -46
  215. package/src/runtime/chain-ops.test.ts +0 -21
  216. package/src/runtime/facades/admin-facade.test.ts +0 -34
  217. package/src/runtime/facades/agency-facade.test.ts +0 -39
  218. package/src/runtime/facades/archive-facade.test.ts +0 -43
  219. package/src/runtime/facades/brain-facade.test.ts +8 -99
  220. package/src/runtime/facades/brain-facade.ts +29 -12
  221. package/src/runtime/facades/branching-facade.test.ts +30 -17
  222. package/src/runtime/facades/chat-facade.test.ts +0 -91
  223. package/src/runtime/facades/chat-service-ops.test.ts +0 -24
  224. package/src/runtime/facades/chat-session-ops.test.ts +0 -12
  225. package/src/runtime/facades/chat-transport-ops.test.ts +0 -23
  226. package/src/runtime/facades/context-facade.test.ts +0 -17
  227. package/src/runtime/facades/context-facade.ts +11 -4
  228. package/src/runtime/facades/control-facade.test.ts +0 -30
  229. package/src/runtime/facades/curator-facade.test.ts +0 -33
  230. package/src/runtime/facades/intake-facade.test.ts +0 -33
  231. package/src/runtime/facades/links-facade.test.ts +0 -37
  232. package/src/runtime/facades/loop-facade.test.ts +0 -26
  233. package/src/runtime/facades/memory-facade.test.ts +0 -18
  234. package/src/runtime/facades/memory-facade.ts +27 -11
  235. package/src/runtime/facades/operator-facade.test.ts +0 -31
  236. package/src/runtime/facades/orchestrate-facade.test.ts +0 -21
  237. package/src/runtime/facades/orchestrate-facade.ts +12 -0
  238. package/src/runtime/facades/plan-facade.test.ts +7 -32
  239. package/src/runtime/facades/plan-facade.ts +137 -4
  240. package/src/runtime/facades/review-facade.test.ts +1 -49
  241. package/src/runtime/facades/sync-facade.test.ts +24 -41
  242. package/src/runtime/facades/tier-facade.test.ts +30 -22
  243. package/src/runtime/facades/vault-facade.test.ts +0 -41
  244. package/src/runtime/facades/vault-facade.ts +26 -3
  245. package/src/runtime/grading-ops.test.ts +0 -27
  246. package/src/runtime/intake-ops.test.ts +0 -19
  247. package/src/runtime/loop-ops.test.ts +0 -48
  248. package/src/runtime/memory-cross-project-ops.test.ts +0 -14
  249. package/src/runtime/memory-extra-ops.test.ts +4 -8
  250. package/src/runtime/orchestrate-ops.test.ts +238 -19
  251. package/src/runtime/orchestrate-ops.ts +166 -41
  252. package/src/runtime/pack-ops.test.ts +0 -26
  253. package/src/runtime/planning-extra-ops.test.ts +2 -14
  254. package/src/runtime/playbook-ops-execution.test.ts +9 -20
  255. package/src/runtime/playbook-ops.test.ts +4 -67
  256. package/src/runtime/review-ops.test.ts +0 -15
  257. package/src/runtime/schema-helpers.ts +4 -0
  258. package/src/runtime/sync-ops.test.ts +0 -18
  259. package/src/runtime/tier-ops.test.ts +0 -21
  260. package/src/runtime/vault-extra-ops.test.ts +0 -12
  261. package/src/runtime/vault-linking-ops.test.ts +0 -4
  262. package/src/runtime/vault-linking-ops.ts +26 -8
  263. package/src/runtime/vault-sharing-ops.test.ts +0 -9
  264. package/src/scheduler/cron-validator.ts +101 -0
  265. package/src/scheduler/platform-linux.ts +122 -0
  266. package/src/scheduler/platform-macos.ts +150 -0
  267. package/src/scheduler/scheduler-ops.ts +77 -0
  268. package/src/scheduler/scheduler.test.ts +247 -0
  269. package/src/scheduler/scheduler.ts +174 -0
  270. package/src/scheduler/types.ts +52 -0
  271. package/src/skills/__tests__/sync-skills.test.ts +6 -17
  272. package/src/skills/global-claude-md.test.ts +113 -0
  273. package/src/skills/sync-skills.ts +143 -35
  274. package/src/skills/validate-skills.test.ts +12 -11
  275. package/src/telemetry/telemetry.test.ts +1 -0
  276. package/src/transport/http-server.test.ts +3 -0
  277. package/src/transport/session-manager.test.ts +3 -1
  278. package/src/transport/token-auth.test.ts +6 -9
  279. package/src/transport/ws-server.test.ts +10 -2
  280. package/src/utils/worktree-reaper.ts +113 -0
  281. package/src/vault/__tests__/vault-characterization.test.ts +0 -108
  282. package/src/vault/linking.test.ts +0 -2
  283. package/src/vault/playbook.test.ts +4 -1
  284. package/src/vault/scope-detector.test.ts +3 -1
  285. package/src/vault/scope-detector.ts +42 -4
  286. package/src/vault/vault-connect.test.ts +1 -1
  287. package/src/vault/vault-entries.ts +3 -1
  288. package/src/vault/vault.test.ts +23 -8
@@ -5,8 +5,9 @@
5
5
  * runtime state. No new modules needed — uses existing runtime parts.
6
6
  */
7
7
 
8
- import { readFileSync, statSync } from 'node:fs';
8
+ import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
9
9
  import { join, dirname } from 'node:path';
10
+ import { homedir } from 'node:os';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import type { OpDefinition } from '../facades/types.js';
12
13
  import type { AgentRuntime } from './types.js';
@@ -147,20 +148,21 @@ export function createAdminOps(runtime: AgentRuntime): OpDefinition[] {
147
148
  };
148
149
  }
149
150
  // Fallback — just describe admin ops
151
+ const adminOps = [
152
+ 'admin_health',
153
+ 'admin_tool_list',
154
+ 'admin_config',
155
+ 'admin_vault_size',
156
+ 'admin_uptime',
157
+ 'admin_version',
158
+ 'admin_reset_cache',
159
+ 'admin_diagnostic',
160
+ ];
150
161
  return {
151
- count: 8,
152
- ops: {
153
- admin: [
154
- 'admin_health',
155
- 'admin_tool_list',
156
- 'admin_config',
157
- 'admin_vault_size',
158
- 'admin_uptime',
159
- 'admin_version',
160
- 'admin_reset_cache',
161
- 'admin_diagnostic',
162
- ],
163
- },
162
+ count: adminOps.length,
163
+ scope: 'admin-only',
164
+ note: 'Pass _allOps for full system op count',
165
+ ops: { admin: adminOps },
164
166
  routing: buildRoutingHints(),
165
167
  };
166
168
  },
@@ -380,7 +382,7 @@ export function createAdminOps(runtime: AgentRuntime): OpDefinition[] {
380
382
  });
381
383
  }
382
384
 
383
- // 7. Skills
385
+ // 7. Skills — check discovered vs registered in .claude/skills/
384
386
  try {
385
387
  const agentDir = runtime.config.agentDir;
386
388
  const skillsDirs = agentDir ? [join(agentDir, 'skills')] : [];
@@ -388,12 +390,52 @@ export function createAdminOps(runtime: AgentRuntime): OpDefinition[] {
388
390
  const installedPacks = packInstaller.list();
389
391
  const packSkillCount = installedPacks.reduce((sum, p) => sum + p.skills.length, 0);
390
392
  const totalSkills = agentSkills.length + packSkillCount;
391
- const skillStatus = totalSkills > 0 ? 'ok' : agentDir ? 'warn' : 'ok';
392
- checks.push({
393
- name: 'skills',
394
- status: skillStatus,
395
- detail: `${totalSkills} skills (${agentSkills.length} agent, ${packSkillCount} pack)`,
396
- });
393
+
394
+ // Check registration status in .claude/skills/
395
+ const claudeSkillsDir = join(homedir(), '.claude', 'skills');
396
+ let registeredCount = 0;
397
+ let brokenCount = 0;
398
+ const unregistered: string[] = [];
399
+
400
+ if (existsSync(claudeSkillsDir)) {
401
+ try {
402
+ const registered = readdirSync(claudeSkillsDir, { withFileTypes: true });
403
+ registeredCount = registered.length;
404
+ for (const entry of registered) {
405
+ if (entry.isSymbolicLink()) {
406
+ try {
407
+ statSync(join(claudeSkillsDir, entry.name));
408
+ } catch {
409
+ brokenCount++;
410
+ }
411
+ }
412
+ }
413
+ } catch {
414
+ // Can't read .claude/skills/ — skip registration check
415
+ }
416
+ }
417
+
418
+ for (const skill of agentSkills) {
419
+ const skillRegisteredDir = join(claudeSkillsDir, skill.name);
420
+ if (!existsSync(skillRegisteredDir)) {
421
+ unregistered.push(skill.name);
422
+ }
423
+ }
424
+
425
+ const hasIssues = unregistered.length > 0 || brokenCount > 0;
426
+ // Warn only when agentDir is set but no skills exist anywhere (local OR global)
427
+ const hasAnySkills = totalSkills > 0 || registeredCount > 0;
428
+ const skillStatus = !hasAnySkills && agentDir ? 'warn' : hasIssues ? 'warn' : 'ok';
429
+ const detail = [
430
+ `${totalSkills} discovered (${agentSkills.length} agent, ${packSkillCount} pack)`,
431
+ `${registeredCount} registered in .claude/skills/`,
432
+ ...(unregistered.length > 0
433
+ ? [`${unregistered.length} unregistered: ${unregistered.join(', ')}`]
434
+ : []),
435
+ ...(brokenCount > 0 ? [`${brokenCount} broken links`] : []),
436
+ ].join(' — ');
437
+
438
+ checks.push({ name: 'skills', status: skillStatus, detail });
397
439
  } catch (err) {
398
440
  checks.push({
399
441
  name: 'skills',
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { createAdminSetupOps } from './admin-setup-ops.js';
2
+ import { createAdminSetupOps, syncHooksToClaudeSettings } from './admin-setup-ops.js';
3
3
  import type { AgentRuntime } from './types.js';
4
4
  import type { OpDefinition } from '../facades/types.js';
5
5
 
@@ -106,18 +106,6 @@ describe('createAdminSetupOps', () => {
106
106
  ops = createAdminSetupOps(runtime);
107
107
  });
108
108
 
109
- it('returns 4 ops', () => {
110
- expect(ops).toHaveLength(4);
111
- });
112
-
113
- it('all ops have required fields', () => {
114
- for (const op of ops) {
115
- expect(op.name).toBeTruthy();
116
- expect(op.handler).toBeDefined();
117
- expect(['read', 'write', 'admin']).toContain(op.auth);
118
- }
119
- });
120
-
121
109
  describe('admin_inject_claude_md', () => {
122
110
  it('returns error when CLAUDE.md not found and createIfMissing is false', async () => {
123
111
  const result = (await findOp(ops, 'admin_inject_claude_md').handler({
@@ -295,6 +283,190 @@ describe('createAdminSetupOps', () => {
295
283
  });
296
284
  });
297
285
 
286
+ describe('syncHooksToClaudeSettings', () => {
287
+ it('installs SessionStart, PreCompact, and Stop hooks on fresh settings', () => {
288
+ const result = syncHooksToClaudeSettings('test-agent');
289
+ const written = mockFs['/mock-home/.claude/settings.json'];
290
+ expect(written).toBeDefined();
291
+ const settings = JSON.parse(written);
292
+ expect(settings.hooks.SessionStart).toHaveLength(2);
293
+ expect(settings.hooks.PreCompact).toHaveLength(1);
294
+ expect(settings.hooks.Stop).toHaveLength(1);
295
+ expect(result.installed).toContain('SessionStart');
296
+ expect(result.installed).toContain('PreCompact');
297
+ expect(result.installed).toContain('Stop');
298
+ expect(result.updated).toHaveLength(0);
299
+ expect(result.skipped).toHaveLength(0);
300
+ expect(result.error).toBeUndefined();
301
+ });
302
+
303
+ it('includes the {agentId}-mode skill hook in SessionStart', () => {
304
+ syncHooksToClaudeSettings('test-agent');
305
+ const settings = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
306
+ const commands = settings.hooks.SessionStart.flatMap((g: { hooks: { command?: string }[] }) =>
307
+ g.hooks.map((h) => h.command),
308
+ );
309
+ expect(commands.some((c: string) => c.includes('test-agent-mode skill'))).toBe(true);
310
+ });
311
+
312
+ it('includes the admin_health hook in SessionStart', () => {
313
+ syncHooksToClaudeSettings('test-agent');
314
+ const settings = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
315
+ const commands = settings.hooks.SessionStart.flatMap((g: { hooks: { command?: string }[] }) =>
316
+ g.hooks.map((h) => h.command),
317
+ );
318
+ expect(commands.some((c: string) => c.includes('admin_health'))).toBe(true);
319
+ });
320
+
321
+ it('is idempotent — running twice produces the same output', () => {
322
+ syncHooksToClaudeSettings('test-agent');
323
+ const after1 = mockFs['/mock-home/.claude/settings.json'];
324
+ const result2 = syncHooksToClaudeSettings('test-agent');
325
+ const after2 = mockFs['/mock-home/.claude/settings.json'];
326
+ expect(after1).toBe(after2);
327
+ expect(result2.skipped).toContain('SessionStart');
328
+ expect(result2.installed).toHaveLength(0);
329
+ expect(result2.updated).toHaveLength(0);
330
+ });
331
+
332
+ it('preserves non-agent hooks already in settings', () => {
333
+ mockFs['/mock-home/.claude/settings.json'] = JSON.stringify({
334
+ hooks: {
335
+ SessionStart: [{ hooks: [{ type: 'command', command: 'echo existing' }] }],
336
+ },
337
+ });
338
+ syncHooksToClaudeSettings('test-agent');
339
+ const settings = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
340
+ const commands = settings.hooks.SessionStart.flatMap((g: { hooks: { command?: string }[] }) =>
341
+ g.hooks.map((h) => h.command),
342
+ );
343
+ expect(commands).toContain('echo existing');
344
+ expect(commands.some((c: string) => c.includes('admin_health'))).toBe(true);
345
+ });
346
+
347
+ it('updates stale agent hooks to match current defaults', () => {
348
+ // A stale hook contains the agent marker but outdated content
349
+ mockFs['/mock-home/.claude/settings.json'] = JSON.stringify({
350
+ hooks: {
351
+ SessionStart: [
352
+ {
353
+ hooks: [
354
+ {
355
+ type: 'command',
356
+ command: `root=$(git rev-parse --show-toplevel 2>/dev/null || echo "."); if grep -q '"test-agent"' "$root/.mcp.json" 2>/dev/null; then echo 'Call mcp__test-agent__test-agent_admin op:OLD_STALE_OP'; fi`,
357
+ timeout: 5000,
358
+ },
359
+ ],
360
+ },
361
+ ],
362
+ },
363
+ });
364
+ const result = syncHooksToClaudeSettings('test-agent');
365
+ const settings = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
366
+ const commands = settings.hooks.SessionStart.flatMap((g: { hooks: { command?: string }[] }) =>
367
+ g.hooks.map((h) => h.command),
368
+ );
369
+ expect(commands.some((c: string) => c.includes('OLD_STALE_OP'))).toBe(false);
370
+ expect(commands.some((c: string) => c.includes('admin_health'))).toBe(true);
371
+ expect(result.updated).toContain('SessionStart');
372
+ expect(result.error).toBeUndefined();
373
+ });
374
+
375
+ it('returns error field when write fails', async () => {
376
+ const { writeFileSync } = await import('node:fs');
377
+ vi.mocked(writeFileSync).mockImplementationOnce(() => {
378
+ throw new Error('EACCES: permission denied');
379
+ });
380
+ const result = syncHooksToClaudeSettings('test-agent');
381
+ expect(result.error).toMatch(/EACCES/);
382
+ expect(result.installed).toHaveLength(0);
383
+ expect(result.updated).toHaveLength(0);
384
+ expect(result.skipped).toHaveLength(0);
385
+ });
386
+ });
387
+
388
+ describe('multi-agent hook coexistence', () => {
389
+ type HookGroup = { hooks: { command?: string }[] };
390
+
391
+ function getSessionStartCommands(): string[] {
392
+ const settings = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
393
+ return (settings.hooks.SessionStart as HookGroup[]).flatMap((g) =>
394
+ g.hooks.map((h) => h.command ?? ''),
395
+ );
396
+ }
397
+
398
+ function getAgentCommands(agentId: string): string[] {
399
+ const settings = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
400
+ return (settings.hooks.SessionStart as HookGroup[])
401
+ .flatMap((g) => g.hooks)
402
+ .map((h) => h.command ?? '')
403
+ .filter((c) => c.includes(agentId));
404
+ }
405
+
406
+ it('install A then B — both sets present, no overlap', () => {
407
+ syncHooksToClaudeSettings('agent-a');
408
+ syncHooksToClaudeSettings('agent-b');
409
+
410
+ const commands = getSessionStartCommands();
411
+
412
+ // Both agents must have hooks present
413
+ expect(commands.some((c) => c.includes('agent-a'))).toBe(true);
414
+ expect(commands.some((c) => c.includes('agent-b'))).toBe(true);
415
+
416
+ // agent-a commands must not mention agent-b and vice versa
417
+ const aCommands = getAgentCommands('agent-a');
418
+ const bCommands = getAgentCommands('agent-b');
419
+
420
+ expect(aCommands.every((c) => !c.includes('agent-b'))).toBe(true);
421
+ expect(bCommands.every((c) => !c.includes('agent-a'))).toBe(true);
422
+ });
423
+
424
+ it('re-install A after B — B hooks untouched', () => {
425
+ syncHooksToClaudeSettings('agent-a');
426
+ syncHooksToClaudeSettings('agent-b');
427
+
428
+ const beforeB = getAgentCommands('agent-b');
429
+
430
+ syncHooksToClaudeSettings('agent-a'); // re-run A (e.g. after update)
431
+
432
+ const afterB = getAgentCommands('agent-b');
433
+
434
+ expect(afterB).toEqual(beforeB);
435
+ });
436
+
437
+ it('no duplicates after running both twice', () => {
438
+ syncHooksToClaudeSettings('agent-a');
439
+ syncHooksToClaudeSettings('agent-b');
440
+
441
+ const settings1 = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
442
+ const groupCountAfterTwo = (settings1.hooks.SessionStart as HookGroup[]).length;
443
+
444
+ syncHooksToClaudeSettings('agent-a');
445
+ syncHooksToClaudeSettings('agent-b');
446
+
447
+ const settings2 = JSON.parse(mockFs['/mock-home/.claude/settings.json']);
448
+ const groupCountAfterFour = (settings2.hooks.SessionStart as HookGroup[]).length;
449
+
450
+ expect(groupCountAfterFour).toBe(groupCountAfterTwo);
451
+ });
452
+
453
+ it('manually added non-agent hook survives all agent installs', () => {
454
+ // Pre-populate settings with a non-agent hook in SessionStart
455
+ mockFs['/mock-home/.claude/settings.json'] = JSON.stringify({
456
+ hooks: {
457
+ SessionStart: [{ hooks: [{ type: 'command', command: 'echo custom-non-agent-hook' }] }],
458
+ },
459
+ });
460
+
461
+ syncHooksToClaudeSettings('agent-a');
462
+ syncHooksToClaudeSettings('agent-b');
463
+ syncHooksToClaudeSettings('agent-a');
464
+
465
+ const commands = getSessionStartCommands();
466
+ expect(commands).toContain('echo custom-non-agent-hook');
467
+ });
468
+ });
469
+
298
470
  describe('admin_check_persistence', () => {
299
471
  it('returns NO_STORAGE_DIRECTORY when nothing exists', async () => {
300
472
  const result = (await findOp(ops, 'admin_check_persistence').handler({})) as Record<
@@ -145,6 +145,37 @@ function discoverHookifyFiles(dir: string): Array<{ name: string; path: string }
145
145
 
146
146
  // discoverSkills imported from '../skills/sync-skills.js'
147
147
 
148
+ // ─── Deep Equality Helper ─────────────────────────────────────────────
149
+
150
+ /** Recursively compare two values by structure, independent of key insertion order. */
151
+ function deepEqual(a: unknown, b: unknown): boolean {
152
+ if (a === b) return true;
153
+ if (a === null || b === null) return false;
154
+ if (typeof a !== typeof b) return false;
155
+ if (typeof a !== 'object') return false;
156
+
157
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
158
+
159
+ if (Array.isArray(a) && Array.isArray(b)) {
160
+ if (a.length !== b.length) return false;
161
+ for (let i = 0; i < a.length; i++) {
162
+ if (!deepEqual(a[i], b[i])) return false;
163
+ }
164
+ return true;
165
+ }
166
+
167
+ const objA = a as Record<string, unknown>;
168
+ const objB = b as Record<string, unknown>;
169
+ const keysA = Object.keys(objA);
170
+ const keysB = Object.keys(objB);
171
+ if (keysA.length !== keysB.length) return false;
172
+ for (const key of keysA) {
173
+ if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
174
+ if (!deepEqual(objA[key], objB[key])) return false;
175
+ }
176
+ return true;
177
+ }
178
+
148
179
  // ─── Settings.json Hook Merging ───────────────────────────────────────
149
180
 
150
181
  interface SettingsHook {
@@ -167,7 +198,7 @@ interface SettingsHookGroup {
167
198
  function buildConditionalHookCommand(agentId: string, instruction: string): string {
168
199
  // Escape single quotes in instruction for safe shell embedding
169
200
  const escaped = instruction.replace(/'/g, "'\\''");
170
- return `root=$(git rev-parse --show-toplevel 2>/dev/null || echo "."); if grep -q '"${agentId}"' "$root/.mcp.json" 2>/dev/null; then echo '${escaped}'; fi`;
201
+ return `root=$(git rev-parse --show-toplevel 2>/dev/null || echo "."); if grep -qF '"${agentId}"' "$root/.mcp.json" 2>/dev/null; then echo '${escaped}'; fi`;
171
202
  }
172
203
 
173
204
  /** Default lifecycle hooks for any Soleri agent */
@@ -189,6 +220,16 @@ function getDefaultLifecycleHooks(agentId: string): Record<string, SettingsHookG
189
220
  },
190
221
  ],
191
222
  },
223
+ {
224
+ matcher: '',
225
+ hooks: [
226
+ {
227
+ type: 'command',
228
+ command: `echo 'SESSION_START: Invoke the ${agentId}-mode skill now to load full routing context and command reference.'`,
229
+ timeout: 5,
230
+ },
231
+ ],
232
+ },
192
233
  ],
193
234
  PreCompact: [
194
235
  {
@@ -226,8 +267,11 @@ function getDefaultLifecycleHooks(agentId: string): Record<string, SettingsHookG
226
267
  /** Check if a hook group belongs to this agent by inspecting prompts for the marker */
227
268
  function isAgentHookGroup(group: SettingsHookGroup, agentId: string): boolean {
228
269
  const marker = `mcp__${agentId}__${agentId}_`;
270
+ const skillMarker = `${agentId}-mode skill`;
229
271
  return group.hooks.some(
230
- (h) => (h.prompt && h.prompt.includes(marker)) || (h.command && h.command.includes(marker)),
272
+ (h) =>
273
+ (h.prompt && (h.prompt.includes(marker) || h.prompt.includes(skillMarker))) ||
274
+ (h.command && (h.command.includes(marker) || h.command.includes(skillMarker))),
231
275
  );
232
276
  }
233
277
 
@@ -254,29 +298,55 @@ function mergeSettingsHooks(
254
298
  continue;
255
299
  }
256
300
 
257
- // Check if agent group already exists
258
- const existingIdx = merged[event].findIndex((g) => isAgentHookGroup(g, agentId));
301
+ // Remove all existing agent-owned groups, keep non-agent hooks
302
+ const nonAgentGroups = merged[event].filter((g) => !isAgentHookGroup(g, agentId));
303
+ const existingAgentGroups = merged[event].filter((g) => isAgentHookGroup(g, agentId));
259
304
 
260
- if (existingIdx === -1) {
261
- // Append agent hooks (don't touch non-agent hooks)
262
- merged[event].push(...groups);
305
+ if (deepEqual(existingAgentGroups, groups)) {
306
+ skipped.push(event);
307
+ } else if (existingAgentGroups.length === 0) {
308
+ merged[event] = [...nonAgentGroups, ...groups];
263
309
  installed.push(event);
264
310
  } else {
265
- // Check if template matches
266
- const existing = JSON.stringify(merged[event][existingIdx]);
267
- const template = JSON.stringify(groups[0]);
268
- if (existing === template) {
269
- skipped.push(event);
270
- } else {
271
- merged[event][existingIdx] = groups[0];
272
- updated.push(event);
273
- }
311
+ // Replace all agent groups with current defaults
312
+ merged[event] = [...nonAgentGroups, ...groups];
313
+ updated.push(event);
274
314
  }
275
315
  }
276
316
 
277
317
  return { hooks: merged, installed, updated, skipped };
278
318
  }
279
319
 
320
+ /**
321
+ * Auto-sync lifecycle hooks into ~/.claude/settings.json at engine startup.
322
+ * Idempotent — skips hooks already present, updates stale ones.
323
+ * Returns a summary of what was installed, updated, or skipped.
324
+ */
325
+ export function syncHooksToClaudeSettings(agentId: string): {
326
+ installed: string[];
327
+ updated: string[];
328
+ skipped: string[];
329
+ error?: string;
330
+ } {
331
+ try {
332
+ const settings = readSettingsJson();
333
+ const currentHooks = (settings.hooks ?? {}) as Record<string, SettingsHookGroup[]>;
334
+ const { hooks, installed, updated, skipped } = mergeSettingsHooks(currentHooks, agentId);
335
+ if (installed.length > 0 || updated.length > 0) {
336
+ writeSettingsJson({ ...settings, hooks });
337
+ }
338
+ return { installed, updated, skipped };
339
+ } catch (err) {
340
+ // Non-fatal — hooks will be installed on next run or via admin_setup_global
341
+ return {
342
+ installed: [],
343
+ updated: [],
344
+ skipped: [],
345
+ error: err instanceof Error ? err.message : String(err),
346
+ };
347
+ }
348
+ }
349
+
280
350
  // ─── Op Definitions ───────────────────────────────────────────────────
281
351
 
282
352
  /**
@@ -83,34 +83,6 @@ describe('createArchiveOps', () => {
83
83
  ops = createArchiveOps(runtime);
84
84
  });
85
85
 
86
- it('returns 12 ops', () => {
87
- expect(ops).toHaveLength(12);
88
- });
89
-
90
- it('all ops have required fields', () => {
91
- for (const op of ops) {
92
- expect(op.name).toBeTruthy();
93
- expect(op.handler).toBeDefined();
94
- expect(['read', 'write', 'admin']).toContain(op.auth);
95
- }
96
- });
97
-
98
- it('contains expected op names', () => {
99
- const names = ops.map((o) => o.name);
100
- expect(names).toContain('vault_archive');
101
- expect(names).toContain('vault_restore');
102
- expect(names).toContain('vault_optimize');
103
- expect(names).toContain('vault_backup');
104
- expect(names).toContain('vault_age_report');
105
- expect(names).toContain('vault_set_temporal');
106
- expect(names).toContain('vault_find_expiring');
107
- expect(names).toContain('vault_find_expired');
108
- expect(names).toContain('knowledge_audit');
109
- expect(names).toContain('knowledge_health');
110
- expect(names).toContain('knowledge_merge');
111
- expect(names).toContain('knowledge_reorganize');
112
- });
113
-
114
86
  describe('vault_archive', () => {
115
87
  it('archives old entries', async () => {
116
88
  await findOp(ops, 'vault_archive').handler({
@@ -35,23 +35,6 @@ describe('branching-ops', () => {
35
35
  ops = captureOps(createBranchingOps(runtime));
36
36
  });
37
37
 
38
- it('registers all 5 branching ops', () => {
39
- expect(ops.size).toBe(5);
40
- expect(ops.has('vault_branch')).toBe(true);
41
- expect(ops.has('vault_branch_add')).toBe(true);
42
- expect(ops.has('vault_branch_list')).toBe(true);
43
- expect(ops.has('vault_merge_branch')).toBe(true);
44
- expect(ops.has('vault_delete_branch')).toBe(true);
45
- });
46
-
47
- it('has correct auth levels', () => {
48
- expect(ops.get('vault_branch')!.auth).toBe('write');
49
- expect(ops.get('vault_branch_add')!.auth).toBe('write');
50
- expect(ops.get('vault_branch_list')!.auth).toBe('read');
51
- expect(ops.get('vault_merge_branch')!.auth).toBe('admin');
52
- expect(ops.get('vault_delete_branch')!.auth).toBe('admin');
53
- });
54
-
55
38
  describe('vault_branch', () => {
56
39
  it('creates a branch', async () => {
57
40
  const result = await executeOp(ops, 'vault_branch', { name: 'experiment' });
@@ -83,18 +83,6 @@ describe('createCaptureOps', () => {
83
83
  ops = createCaptureOps(runtime);
84
84
  });
85
85
 
86
- it('returns 4 ops', () => {
87
- expect(ops).toHaveLength(4);
88
- });
89
-
90
- it('all ops have required fields', () => {
91
- for (const op of ops) {
92
- expect(op.name).toBeTruthy();
93
- expect(op.handler).toBeDefined();
94
- expect(['read', 'write']).toContain(op.auth);
95
- }
96
- });
97
-
98
86
  describe('capture_knowledge', () => {
99
87
  it('captures a single entry with governance approval', async () => {
100
88
  const result = (await findOp(ops, 'capture_knowledge').handler({
@@ -191,7 +179,7 @@ describe('createCaptureOps', () => {
191
179
  entries: [{ type: 'pattern', domain: 'a', title: 'A', description: 'a', tags: [] }],
192
180
  })) as Record<string, unknown>;
193
181
  expect(result.autoLinkedCount).toBe(1);
194
- expect(result.suggestedLinks).toBeDefined();
182
+ expect((result.suggestedLinks as unknown[]).length).toBe(1); // one suggestion returned by mock
195
183
  });
196
184
 
197
185
  it('maps extended types correctly', async () => {
@@ -252,7 +240,8 @@ describe('createCaptureOps', () => {
252
240
  description: 'A quick capture',
253
241
  })) as Record<string, unknown>;
254
242
  expect(result.captured).toBe(true);
255
- expect(result.id).toBeDefined();
243
+ expect(typeof result.id).toBe('string');
244
+ expect((result.id as string).startsWith('testing-')).toBe(true); // id is generated as <domain>-<timestamp>-<random>
256
245
  expect((result.governance as Record<string, unknown>).action).toBe('capture');
257
246
  });
258
247
 
@@ -380,6 +369,43 @@ describe('createCaptureOps', () => {
380
369
  // Capture should still succeed despite sync error
381
370
  expect(result.captured).toBe(true);
382
371
  });
372
+
373
+ it('adds planningNote when type is anti-pattern', async () => {
374
+ const result = (await findOp(ops, 'capture_quick').handler({
375
+ type: 'anti-pattern',
376
+ domain: 'testing',
377
+ title: 'No content body',
378
+ description: 'captured without context/example/why',
379
+ })) as Record<string, unknown>;
380
+ expect(result.captured).toBe(true);
381
+ expect(typeof result.planningNote).toBe('string');
382
+ expect(result.planningNote as string).toContain('capture_knowledge');
383
+ });
384
+
385
+ it('adds planningNote when tags include planning-gate', async () => {
386
+ const result = (await findOp(ops, 'capture_quick').handler({
387
+ type: 'rule',
388
+ domain: 'testing',
389
+ title: 'Gated rule',
390
+ description: 'should warn',
391
+ tags: ['planning-gate'],
392
+ })) as Record<string, unknown>;
393
+ expect(result.captured).toBe(true);
394
+ expect(typeof result.planningNote).toBe('string');
395
+ expect(result.planningNote as string).toContain('capture_knowledge');
396
+ });
397
+
398
+ it('does not add planningNote for non-planning types without planning tags', async () => {
399
+ const result = (await findOp(ops, 'capture_quick').handler({
400
+ type: 'reference',
401
+ domain: 'docs',
402
+ title: 'A plain reference',
403
+ description: 'no planning intent',
404
+ tags: ['docs'],
405
+ })) as Record<string, unknown>;
406
+ expect(result.captured).toBe(true);
407
+ expect(result.planningNote).toBeUndefined();
408
+ });
383
409
  });
384
410
 
385
411
  describe('search_intelligent', () => {
@@ -387,8 +413,7 @@ describe('createCaptureOps', () => {
387
413
  const result = (await findOp(ops, 'search_intelligent').handler({
388
414
  query: 'auth patterns',
389
415
  })) as Array<Record<string, unknown>>;
390
- expect(Array.isArray(result)).toBe(true);
391
- expect(result.length).toBeGreaterThan(0);
416
+ expect(result).toHaveLength(1); // brain.intelligentSearch mock returns exactly 1 result
392
417
  expect(result[0].source).toBe('vault');
393
418
  });
394
419