@soleri/core 9.14.4 → 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 (355) 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/brain/brain.d.ts +9 -0
  8. package/dist/brain/brain.d.ts.map +1 -1
  9. package/dist/brain/brain.js +11 -1
  10. package/dist/brain/brain.js.map +1 -1
  11. package/dist/brain/intelligence.d.ts.map +1 -1
  12. package/dist/brain/intelligence.js +24 -0
  13. package/dist/brain/intelligence.js.map +1 -1
  14. package/dist/brain/types.d.ts +1 -0
  15. package/dist/brain/types.d.ts.map +1 -1
  16. package/dist/capabilities/chain-mapping.d.ts.map +1 -1
  17. package/dist/capabilities/chain-mapping.js +5 -4
  18. package/dist/capabilities/chain-mapping.js.map +1 -1
  19. package/dist/capabilities/registry.d.ts +6 -0
  20. package/dist/capabilities/registry.d.ts.map +1 -1
  21. package/dist/capabilities/registry.js +3 -2
  22. package/dist/capabilities/registry.js.map +1 -1
  23. package/dist/chat/chat-session.d.ts +6 -0
  24. package/dist/chat/chat-session.d.ts.map +1 -1
  25. package/dist/chat/chat-session.js +68 -17
  26. package/dist/chat/chat-session.js.map +1 -1
  27. package/dist/context/context-engine.js +1 -1
  28. package/dist/context/context-engine.js.map +1 -1
  29. package/dist/curator/curator.d.ts +6 -0
  30. package/dist/curator/curator.d.ts.map +1 -1
  31. package/dist/curator/curator.js +138 -0
  32. package/dist/curator/curator.js.map +1 -1
  33. package/dist/curator/types.d.ts +10 -0
  34. package/dist/curator/types.d.ts.map +1 -1
  35. package/dist/engine/bin/soleri-engine.js +0 -0
  36. package/dist/engine/core-ops.d.ts.map +1 -1
  37. package/dist/engine/core-ops.js +38 -1
  38. package/dist/engine/core-ops.js.map +1 -1
  39. package/dist/flows/epilogue.d.ts +5 -1
  40. package/dist/flows/epilogue.d.ts.map +1 -1
  41. package/dist/flows/epilogue.js +11 -3
  42. package/dist/flows/epilogue.js.map +1 -1
  43. package/dist/flows/executor.d.ts.map +1 -1
  44. package/dist/flows/executor.js +13 -5
  45. package/dist/flows/executor.js.map +1 -1
  46. package/dist/flows/index.d.ts +1 -2
  47. package/dist/flows/index.d.ts.map +1 -1
  48. package/dist/flows/index.js +1 -0
  49. package/dist/flows/index.js.map +1 -1
  50. package/dist/flows/plan-builder.d.ts +17 -1
  51. package/dist/flows/plan-builder.d.ts.map +1 -1
  52. package/dist/flows/plan-builder.js +67 -6
  53. package/dist/flows/plan-builder.js.map +1 -1
  54. package/dist/flows/probes.d.ts +1 -1
  55. package/dist/flows/probes.d.ts.map +1 -1
  56. package/dist/flows/probes.js +15 -3
  57. package/dist/flows/probes.js.map +1 -1
  58. package/dist/flows/types.d.ts +47 -20
  59. package/dist/flows/types.d.ts.map +1 -1
  60. package/dist/flows/types.js +6 -1
  61. package/dist/flows/types.js.map +1 -1
  62. package/dist/index.d.ts +10 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +9 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/intake/content-classifier.d.ts +10 -4
  67. package/dist/intake/content-classifier.d.ts.map +1 -1
  68. package/dist/intake/content-classifier.js +19 -5
  69. package/dist/intake/content-classifier.js.map +1 -1
  70. package/dist/intake/text-ingester.d.ts +18 -0
  71. package/dist/intake/text-ingester.d.ts.map +1 -1
  72. package/dist/intake/text-ingester.js +37 -13
  73. package/dist/intake/text-ingester.js.map +1 -1
  74. package/dist/packs/pack-installer.d.ts.map +1 -1
  75. package/dist/packs/pack-installer.js +28 -2
  76. package/dist/packs/pack-installer.js.map +1 -1
  77. package/dist/planning/planner-types.d.ts +2 -0
  78. package/dist/planning/planner-types.d.ts.map +1 -1
  79. package/dist/planning/planner.d.ts +4 -0
  80. package/dist/planning/planner.d.ts.map +1 -1
  81. package/dist/planning/planner.js +50 -4
  82. package/dist/planning/planner.js.map +1 -1
  83. package/dist/playbooks/playbook-executor.d.ts +10 -1
  84. package/dist/playbooks/playbook-executor.d.ts.map +1 -1
  85. package/dist/playbooks/playbook-executor.js +8 -2
  86. package/dist/playbooks/playbook-executor.js.map +1 -1
  87. package/dist/playbooks/playbook-types.d.ts +8 -0
  88. package/dist/playbooks/playbook-types.d.ts.map +1 -1
  89. package/dist/plugins/types.d.ts +2 -2
  90. package/dist/runtime/admin-extra-ops.d.ts.map +1 -1
  91. package/dist/runtime/admin-extra-ops.js +30 -0
  92. package/dist/runtime/admin-extra-ops.js.map +1 -1
  93. package/dist/runtime/admin-ops.d.ts.map +1 -1
  94. package/dist/runtime/admin-ops.js +60 -21
  95. package/dist/runtime/admin-ops.js.map +1 -1
  96. package/dist/runtime/admin-setup-ops.d.ts +11 -0
  97. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  98. package/dist/runtime/admin-setup-ops.js +146 -37
  99. package/dist/runtime/admin-setup-ops.js.map +1 -1
  100. package/dist/runtime/capture-ops.d.ts.map +1 -1
  101. package/dist/runtime/capture-ops.js +38 -12
  102. package/dist/runtime/capture-ops.js.map +1 -1
  103. package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
  104. package/dist/runtime/facades/brain-facade.js +16 -4
  105. package/dist/runtime/facades/brain-facade.js.map +1 -1
  106. package/dist/runtime/facades/context-facade.d.ts.map +1 -1
  107. package/dist/runtime/facades/context-facade.js +9 -3
  108. package/dist/runtime/facades/context-facade.js.map +1 -1
  109. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  110. package/dist/runtime/facades/memory-facade.js +20 -7
  111. package/dist/runtime/facades/memory-facade.js.map +1 -1
  112. package/dist/runtime/facades/orchestrate-facade.d.ts.map +1 -1
  113. package/dist/runtime/facades/orchestrate-facade.js +40 -1
  114. package/dist/runtime/facades/orchestrate-facade.js.map +1 -1
  115. package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
  116. package/dist/runtime/facades/plan-facade.js +113 -4
  117. package/dist/runtime/facades/plan-facade.js.map +1 -1
  118. package/dist/runtime/facades/vault-facade.d.ts.map +1 -1
  119. package/dist/runtime/facades/vault-facade.js +24 -3
  120. package/dist/runtime/facades/vault-facade.js.map +1 -1
  121. package/dist/runtime/orchestrate-ops.d.ts +21 -0
  122. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  123. package/dist/runtime/orchestrate-ops.js +132 -38
  124. package/dist/runtime/orchestrate-ops.js.map +1 -1
  125. package/dist/runtime/runtime.d.ts.map +1 -1
  126. package/dist/runtime/runtime.js +16 -0
  127. package/dist/runtime/runtime.js.map +1 -1
  128. package/dist/runtime/schema-helpers.d.ts.map +1 -1
  129. package/dist/runtime/schema-helpers.js +4 -0
  130. package/dist/runtime/schema-helpers.js.map +1 -1
  131. package/dist/runtime/types.d.ts +19 -0
  132. package/dist/runtime/types.d.ts.map +1 -1
  133. package/dist/runtime/vault-linking-ops.d.ts.map +1 -1
  134. package/dist/runtime/vault-linking-ops.js +16 -3
  135. package/dist/runtime/vault-linking-ops.js.map +1 -1
  136. package/dist/scheduler/cron-validator.d.ts +15 -0
  137. package/dist/scheduler/cron-validator.d.ts.map +1 -0
  138. package/dist/scheduler/cron-validator.js +93 -0
  139. package/dist/scheduler/cron-validator.js.map +1 -0
  140. package/dist/scheduler/platform-linux.d.ts +14 -0
  141. package/dist/scheduler/platform-linux.d.ts.map +1 -0
  142. package/dist/scheduler/platform-linux.js +107 -0
  143. package/dist/scheduler/platform-linux.js.map +1 -0
  144. package/dist/scheduler/platform-macos.d.ts +15 -0
  145. package/dist/scheduler/platform-macos.d.ts.map +1 -0
  146. package/dist/scheduler/platform-macos.js +131 -0
  147. package/dist/scheduler/platform-macos.js.map +1 -0
  148. package/dist/scheduler/scheduler-ops.d.ts +14 -0
  149. package/dist/scheduler/scheduler-ops.d.ts.map +1 -0
  150. package/dist/scheduler/scheduler-ops.js +77 -0
  151. package/dist/scheduler/scheduler-ops.js.map +1 -0
  152. package/dist/scheduler/scheduler.d.ts +55 -0
  153. package/dist/scheduler/scheduler.d.ts.map +1 -0
  154. package/dist/scheduler/scheduler.js +144 -0
  155. package/dist/scheduler/scheduler.js.map +1 -0
  156. package/dist/scheduler/types.d.ts +48 -0
  157. package/dist/scheduler/types.d.ts.map +1 -0
  158. package/dist/scheduler/types.js +6 -0
  159. package/dist/scheduler/types.js.map +1 -0
  160. package/dist/skills/sync-skills.d.ts +11 -0
  161. package/dist/skills/sync-skills.d.ts.map +1 -1
  162. package/dist/skills/sync-skills.js +132 -38
  163. package/dist/skills/sync-skills.js.map +1 -1
  164. package/dist/skills/validate-skills.d.ts +32 -0
  165. package/dist/skills/validate-skills.d.ts.map +1 -0
  166. package/dist/skills/validate-skills.js +396 -0
  167. package/dist/skills/validate-skills.js.map +1 -0
  168. package/dist/utils/worktree-reaper.d.ts +38 -0
  169. package/dist/utils/worktree-reaper.d.ts.map +1 -0
  170. package/dist/utils/worktree-reaper.js +85 -0
  171. package/dist/utils/worktree-reaper.js.map +1 -0
  172. package/dist/vault/default-canonical-tags.d.ts +15 -0
  173. package/dist/vault/default-canonical-tags.d.ts.map +1 -0
  174. package/dist/vault/default-canonical-tags.js +65 -0
  175. package/dist/vault/default-canonical-tags.js.map +1 -0
  176. package/dist/vault/scope-detector.d.ts.map +1 -1
  177. package/dist/vault/scope-detector.js +37 -4
  178. package/dist/vault/scope-detector.js.map +1 -1
  179. package/dist/vault/tag-normalizer.d.ts +42 -0
  180. package/dist/vault/tag-normalizer.d.ts.map +1 -0
  181. package/dist/vault/tag-normalizer.js +157 -0
  182. package/dist/vault/tag-normalizer.js.map +1 -0
  183. package/dist/vault/vault-entries.d.ts.map +1 -1
  184. package/dist/vault/vault-entries.js +3 -1
  185. package/dist/vault/vault-entries.js.map +1 -1
  186. package/package.json +5 -1
  187. package/src/__tests__/embeddings.test.ts +3 -3
  188. package/src/agency/agency-manager.test.ts +4 -4
  189. package/src/agency/default-rules.test.ts +0 -13
  190. package/src/brain/brain-intelligence.test.ts +0 -5
  191. package/src/brain/brain.ts +25 -1
  192. package/src/brain/intelligence.ts +25 -0
  193. package/src/brain/second-brain-features.test.ts +2 -14
  194. package/src/brain/types.ts +1 -0
  195. package/src/capabilities/chain-mapping.test.ts +1 -6
  196. package/src/capabilities/chain-mapping.ts +6 -4
  197. package/src/capabilities/registry.test.ts +1 -1
  198. package/src/capabilities/registry.ts +9 -2
  199. package/src/chat/agent-loop.test.ts +1 -1
  200. package/src/chat/chat-enhanced.test.ts +0 -8
  201. package/src/chat/chat-session.ts +75 -17
  202. package/src/chat/chat-transport.test.ts +31 -1
  203. package/src/claudemd/compose.test.ts +0 -5
  204. package/src/context/context-engine.test.ts +0 -1
  205. package/src/context/context-engine.ts +1 -1
  206. package/src/control/intent-router.test.ts +2 -2
  207. package/src/curator/curator.ts +180 -0
  208. package/src/curator/tag-manager.test.ts +0 -4
  209. package/src/curator/types.ts +10 -0
  210. package/src/domain-packs/types.test.ts +0 -5
  211. package/src/dream/dream.test.ts +0 -7
  212. package/src/enforcement/registry.test.ts +2 -2
  213. package/src/engine/core-ops.test.ts +4 -22
  214. package/src/engine/core-ops.ts +36 -1
  215. package/src/engine/module-manifest.test.ts +1 -31
  216. package/src/engine/register-engine.test.ts +3 -33
  217. package/src/errors/retry.test.ts +3 -1
  218. package/src/flows/chain-runner.test.ts +0 -6
  219. package/src/flows/context-router.test.ts +3 -3
  220. package/src/flows/epilogue.test.ts +40 -2
  221. package/src/flows/epilogue.ts +11 -2
  222. package/src/flows/executor.test.ts +48 -2
  223. package/src/flows/executor.ts +15 -5
  224. package/src/flows/index.ts +1 -3
  225. package/src/flows/plan-builder.test.ts +201 -0
  226. package/src/flows/plan-builder.ts +81 -5
  227. package/src/flows/probes.ts +17 -3
  228. package/src/flows/types.ts +31 -2
  229. package/src/health/health-registry.test.ts +3 -1
  230. package/src/index.ts +24 -0
  231. package/src/intake/content-classifier.ts +22 -4
  232. package/src/intake/dedup-gate.test.ts +2 -6
  233. package/src/intake/text-ingester.test.ts +3 -4
  234. package/src/intake/text-ingester.ts +61 -12
  235. package/src/llm/llm-client.test.ts +1 -1
  236. package/src/llm/utils.test.ts +1 -1
  237. package/src/migrations/migration-runner.test.ts +0 -1
  238. package/src/operator/operator-context-store.test.ts +0 -13
  239. package/src/operator/operator-profile.test.ts +2 -20
  240. package/src/packs/pack-installer.ts +28 -2
  241. package/src/packs/pack-system.test.ts +2 -2
  242. package/src/persona/defaults.test.ts +19 -19
  243. package/src/planning/gap-passes.test.ts +0 -46
  244. package/src/planning/gap-patterns.test.ts +0 -42
  245. package/src/planning/goal-ancestry.test.ts +3 -1
  246. package/src/planning/plan-lifecycle.test.ts +15 -7
  247. package/src/planning/planner-types.ts +2 -0
  248. package/src/planning/planner.test.ts +86 -90
  249. package/src/planning/planner.ts +56 -4
  250. package/src/planning/reconciliation-engine.test.ts +3 -10
  251. package/src/planning/task-complexity-assessor.test.ts +0 -5
  252. package/src/planning/task-verifier.test.ts +3 -1
  253. package/src/playbooks/generic/generic-playbooks.test.ts +0 -28
  254. package/src/playbooks/index.test.ts +0 -55
  255. package/src/playbooks/playbook-executor.test.ts +76 -0
  256. package/src/playbooks/playbook-executor.ts +24 -3
  257. package/src/playbooks/playbook-types.ts +8 -0
  258. package/src/plugins/plugin-registry.test.ts +6 -2
  259. package/src/project/project-registry.test.ts +2 -0
  260. package/src/queue/async-infrastructure.test.ts +6 -4
  261. package/src/queue/job-queue.test.ts +13 -7
  262. package/src/runtime/admin-extra-ops.test.ts +35 -30
  263. package/src/runtime/admin-extra-ops.ts +30 -0
  264. package/src/runtime/admin-ops.test.ts +0 -4
  265. package/src/runtime/admin-ops.ts +63 -21
  266. package/src/runtime/admin-setup-ops.test.ts +229 -13
  267. package/src/runtime/admin-setup-ops.ts +145 -36
  268. package/src/runtime/archive-ops.test.ts +0 -28
  269. package/src/runtime/branching-ops.test.ts +0 -17
  270. package/src/runtime/capture-ops.test.ts +41 -16
  271. package/src/runtime/capture-ops.ts +78 -46
  272. package/src/runtime/chain-ops.test.ts +0 -21
  273. package/src/runtime/facades/admin-facade.test.ts +0 -34
  274. package/src/runtime/facades/agency-facade.test.ts +0 -39
  275. package/src/runtime/facades/archive-facade.test.ts +0 -43
  276. package/src/runtime/facades/brain-facade.test.ts +8 -99
  277. package/src/runtime/facades/brain-facade.ts +29 -12
  278. package/src/runtime/facades/branching-facade.test.ts +30 -17
  279. package/src/runtime/facades/chat-facade.test.ts +0 -91
  280. package/src/runtime/facades/chat-service-ops.test.ts +0 -24
  281. package/src/runtime/facades/chat-session-ops.test.ts +0 -12
  282. package/src/runtime/facades/chat-transport-ops.test.ts +0 -23
  283. package/src/runtime/facades/context-facade.test.ts +0 -17
  284. package/src/runtime/facades/context-facade.ts +11 -4
  285. package/src/runtime/facades/control-facade.test.ts +0 -30
  286. package/src/runtime/facades/curator-facade.test.ts +0 -33
  287. package/src/runtime/facades/intake-facade.test.ts +0 -33
  288. package/src/runtime/facades/links-facade.test.ts +0 -37
  289. package/src/runtime/facades/loop-facade.test.ts +0 -26
  290. package/src/runtime/facades/memory-facade.test.ts +0 -18
  291. package/src/runtime/facades/memory-facade.ts +27 -11
  292. package/src/runtime/facades/operator-facade.test.ts +0 -31
  293. package/src/runtime/facades/orchestrate-facade.test.ts +0 -21
  294. package/src/runtime/facades/orchestrate-facade.ts +39 -1
  295. package/src/runtime/facades/plan-facade.test.ts +7 -32
  296. package/src/runtime/facades/plan-facade.ts +137 -4
  297. package/src/runtime/facades/review-facade.test.ts +1 -49
  298. package/src/runtime/facades/sync-facade.test.ts +24 -41
  299. package/src/runtime/facades/tier-facade.test.ts +30 -22
  300. package/src/runtime/facades/vault-facade.test.ts +0 -41
  301. package/src/runtime/facades/vault-facade.ts +26 -3
  302. package/src/runtime/grading-ops.test.ts +0 -27
  303. package/src/runtime/intake-ops.test.ts +0 -19
  304. package/src/runtime/loop-ops.test.ts +0 -48
  305. package/src/runtime/memory-cross-project-ops.test.ts +0 -14
  306. package/src/runtime/memory-extra-ops.test.ts +4 -8
  307. package/src/runtime/orchestrate-ops.test.ts +238 -19
  308. package/src/runtime/orchestrate-ops.ts +166 -41
  309. package/src/runtime/pack-ops.test.ts +0 -26
  310. package/src/runtime/planning-extra-ops.test.ts +2 -14
  311. package/src/runtime/playbook-ops-execution.test.ts +9 -20
  312. package/src/runtime/playbook-ops.test.ts +4 -67
  313. package/src/runtime/review-ops.test.ts +0 -15
  314. package/src/runtime/runtime.ts +18 -0
  315. package/src/runtime/schema-helpers.ts +4 -0
  316. package/src/runtime/sync-ops.test.ts +0 -18
  317. package/src/runtime/tier-ops.test.ts +0 -21
  318. package/src/runtime/types.ts +19 -0
  319. package/src/runtime/vault-extra-ops.test.ts +0 -12
  320. package/src/runtime/vault-linking-ops.test.ts +0 -4
  321. package/src/runtime/vault-linking-ops.ts +26 -8
  322. package/src/runtime/vault-sharing-ops.test.ts +0 -9
  323. package/src/scheduler/cron-validator.ts +101 -0
  324. package/src/scheduler/platform-linux.ts +122 -0
  325. package/src/scheduler/platform-macos.ts +150 -0
  326. package/src/scheduler/scheduler-ops.ts +77 -0
  327. package/src/scheduler/scheduler.test.ts +247 -0
  328. package/src/scheduler/scheduler.ts +174 -0
  329. package/src/scheduler/types.ts +52 -0
  330. package/src/skills/__tests__/sync-skills.test.ts +6 -17
  331. package/src/skills/global-claude-md.test.ts +113 -0
  332. package/src/skills/sync-skills.ts +143 -35
  333. package/src/skills/validate-skills.test.ts +206 -0
  334. package/src/skills/validate-skills.ts +470 -0
  335. package/src/telemetry/telemetry.test.ts +1 -0
  336. package/src/transport/http-server.test.ts +3 -0
  337. package/src/transport/session-manager.test.ts +3 -1
  338. package/src/transport/token-auth.test.ts +6 -9
  339. package/src/transport/ws-server.test.ts +10 -2
  340. package/src/utils/worktree-reaper.ts +113 -0
  341. package/src/vault/__tests__/vault-characterization.test.ts +0 -108
  342. package/src/vault/default-canonical-tags.ts +64 -0
  343. package/src/vault/linking.test.ts +0 -2
  344. package/src/vault/playbook.test.ts +4 -1
  345. package/src/vault/scope-detector.test.ts +3 -1
  346. package/src/vault/scope-detector.ts +42 -4
  347. package/src/vault/tag-normalizer.test.ts +214 -0
  348. package/src/vault/tag-normalizer.ts +188 -0
  349. package/src/vault/vault-connect.test.ts +1 -1
  350. package/src/vault/vault-entries.ts +3 -1
  351. package/src/vault/vault.test.ts +23 -8
  352. package/dist/embeddings/index.d.ts +0 -5
  353. package/dist/embeddings/index.d.ts.map +0 -1
  354. package/dist/embeddings/index.js +0 -3
  355. package/dist/embeddings/index.js.map +0 -1
@@ -134,18 +134,6 @@ describe('createPlanningExtraOps', () => {
134
134
  ops = createPlanningExtraOps(runtime);
135
135
  });
136
136
 
137
- it('returns expected op count', () => {
138
- expect(ops.length).toBeGreaterThanOrEqual(22);
139
- });
140
-
141
- it('all ops have required fields', () => {
142
- for (const op of ops) {
143
- expect(op.name).toBeTruthy();
144
- expect(op.handler).toBeDefined();
145
- expect(['read', 'write', 'admin']).toContain(op.auth);
146
- }
147
- });
148
-
149
137
  describe('plan_iterate', () => {
150
138
  it('iterates a draft plan', async () => {
151
139
  const result = (await findOp(ops, 'plan_iterate').handler({
@@ -460,7 +448,7 @@ describe('createPlanningExtraOps', () => {
460
448
  intent: 'BUILD',
461
449
  })) as Record<string, unknown>;
462
450
  expect(result.matched).toBe(true);
463
- expect(result.sections).toBeDefined();
451
+ expect(result.sections).toEqual(['design', 'scope', 'tokens']); // generic + domain brainstorm sections
464
452
  });
465
453
 
466
454
  it('returns not-matched when no playbook fits', async () => {
@@ -498,7 +486,7 @@ describe('createPlanningExtraOps', () => {
498
486
  planId: 'plan-1',
499
487
  })) as Record<string, unknown>;
500
488
  expect(result.planId).toBe('plan-1');
501
- expect(result.taskMetrics).toBeDefined();
489
+ expect(result.taskMetrics).toHaveLength(1); // mock plan has exactly 1 task
502
490
  });
503
491
 
504
492
  it('returns error for missing plan', async () => {
@@ -26,15 +26,6 @@ describe('playbook execution ops', () => {
26
26
  ops = createPlaybookOps(runtime);
27
27
  }
28
28
 
29
- it('should return 8 ops total', () => {
30
- setup();
31
- expect(ops).toHaveLength(8);
32
- const names = ops.map((o) => o.name);
33
- expect(names).toContain('playbook_start');
34
- expect(names).toContain('playbook_step');
35
- expect(names).toContain('playbook_complete');
36
- });
37
-
38
29
  // ─── playbook_start ─────────────────────────────────────────────
39
30
 
40
31
  describe('playbook_start', () => {
@@ -46,7 +37,7 @@ describe('playbook execution ops', () => {
46
37
 
47
38
  expect(result.sessionId).toMatch(/^pbk-/);
48
39
  expect(result.label).toBe('Test-Driven Development');
49
- expect(result.totalSteps).toBeGreaterThan(0);
40
+ expect(result.totalSteps).toBe(4); // TDD playbook has 4 steps: RED, GREEN, REFACTOR, VERIFY
50
41
  });
51
42
 
52
43
  it('should start by intent auto-match', async () => {
@@ -57,7 +48,7 @@ describe('playbook execution ops', () => {
57
48
  })) as { sessionId: string; label: string };
58
49
 
59
50
  expect(result.sessionId).toMatch(/^pbk-/);
60
- expect(result.label).toBeDefined();
51
+ expect(result.label).toBe('Test-Driven Development'); // BUILD + "tests" triggers TDD playbook
61
52
  });
62
53
 
63
54
  it('should return error for unknown playbookId', async () => {
@@ -69,21 +60,19 @@ describe('playbook execution ops', () => {
69
60
  expect(result.error).toContain('not found');
70
61
  });
71
62
 
72
- it('should return available playbooks when no match', async () => {
63
+ it('should start Verification playbook for DELIVER intent', async () => {
73
64
  setup();
74
65
  const result = (await findOp('playbook_start').handler({
75
66
  intent: 'DELIVER',
76
67
  text: 'something very obscure with no keyword matches',
77
- })) as { error: string; available: Array<{ id: string }> };
68
+ })) as { sessionId: string; label: string };
78
69
 
79
- // May or may not match if error, should list available
80
- if (result.error) {
81
- expect(result.available).toBeDefined();
82
- expect(result.available.length).toBeGreaterThan(0);
83
- }
70
+ // DELIVER intent always matches Verification Before Completion
71
+ expect(result.sessionId).toMatch(/^pbk-/);
72
+ expect(result.label).toBe('Verification Before Completion');
84
73
  });
85
74
 
86
- it('should return error with no params', async () => {
75
+ it('should return error with all 7 available playbooks listed when no params', async () => {
87
76
  setup();
88
77
  const result = (await findOp('playbook_start').handler({})) as {
89
78
  error: string;
@@ -91,7 +80,7 @@ describe('playbook execution ops', () => {
91
80
  };
92
81
 
93
82
  expect(result.error).toContain('Provide');
94
- expect(result.available.length).toBeGreaterThan(0);
83
+ expect(result.available).toHaveLength(7); // 7 built-in playbooks
95
84
  });
96
85
  });
97
86
 
@@ -183,25 +183,16 @@ describe('playbook-ops', () => {
183
183
  });
184
184
  });
185
185
 
186
- describe('playbook_seed', () => {
187
- it('seeds default playbooks', async () => {
188
- const { ops } = setup();
189
- const res = await executeOp(ops, 'playbook_seed');
190
- expect(res.success).toBe(true);
191
- // seedDefaultPlaybooks returns { seeded, skipped, errors }
192
- const data = res.data as { seeded: number; skipped: number };
193
- expect(typeof data.seeded).toBe('number');
194
- });
195
- });
196
-
197
186
  describe('playbook_start', () => {
198
187
  it('returns error when neither playbookId nor intent provided', async () => {
199
188
  const { ops } = setup();
200
189
  const res = await executeOp(ops, 'playbook_start', {});
201
190
  expect(res.success).toBe(true);
202
- const data = res.data as { error: string; available: unknown[] };
191
+ const data = res.data as { error: string; available: Array<{ id: string; title: string }> };
203
192
  expect(data.error).toContain('Provide playbookId or intent');
204
- expect(data.available).toBeDefined();
193
+ expect(data.available).toHaveLength(7); // 7 built-in playbooks
194
+ expect(data.available[0]).toHaveProperty('id');
195
+ expect(data.available[0]).toHaveProperty('title');
205
196
  });
206
197
 
207
198
  it('returns error for unknown playbookId', async () => {
@@ -228,58 +219,4 @@ describe('playbook-ops', () => {
228
219
  // Either starts a matched playbook or returns "no matching playbook"
229
220
  });
230
221
  });
231
-
232
- describe('playbook_step', () => {
233
- it('delegates to executor step', async () => {
234
- const { ops, playbookExecutor } = setup();
235
- const res = await executeOp(ops, 'playbook_step', {
236
- sessionId: 'test-session',
237
- output: 'Did the thing',
238
- });
239
- expect(res.success).toBe(true);
240
- expect(playbookExecutor.step).toHaveBeenCalledWith('test-session', {
241
- output: 'Did the thing',
242
- skip: undefined,
243
- });
244
- });
245
-
246
- it('supports skip option', async () => {
247
- const { ops, playbookExecutor } = setup();
248
- await executeOp(ops, 'playbook_step', { sessionId: 's1', skip: true });
249
- expect(playbookExecutor.step).toHaveBeenCalledWith('s1', { output: undefined, skip: true });
250
- });
251
- });
252
-
253
- describe('playbook_complete', () => {
254
- it('delegates to executor complete', async () => {
255
- const { ops, playbookExecutor } = setup();
256
- const res = await executeOp(ops, 'playbook_complete', { sessionId: 's1' });
257
- expect(res.success).toBe(true);
258
- expect(playbookExecutor.complete).toHaveBeenCalledWith('s1', {
259
- abort: undefined,
260
- gateResults: undefined,
261
- });
262
- });
263
-
264
- it('supports abort option', async () => {
265
- const { ops, playbookExecutor } = setup();
266
- await executeOp(ops, 'playbook_complete', { sessionId: 's1', abort: true });
267
- expect(playbookExecutor.complete).toHaveBeenCalledWith('s1', {
268
- abort: true,
269
- gateResults: undefined,
270
- });
271
- });
272
-
273
- it('passes gate results', async () => {
274
- const { ops, playbookExecutor } = setup();
275
- await executeOp(ops, 'playbook_complete', {
276
- sessionId: 's1',
277
- gateResults: { 'tests-pass': true, 'lint-clean': false },
278
- });
279
- expect(playbookExecutor.complete).toHaveBeenCalledWith('s1', {
280
- abort: undefined,
281
- gateResults: { 'tests-pass': true, 'lint-clean': false },
282
- });
283
- });
284
- });
285
222
  });
@@ -39,21 +39,6 @@ describe('createReviewOps', () => {
39
39
  ops = createReviewOps(rt);
40
40
  });
41
41
 
42
- it('returns 5 ops', () => {
43
- expect(ops.length).toBe(5);
44
- });
45
-
46
- it('has the expected op names', () => {
47
- const names = ops.map((o) => o.name);
48
- expect(names).toEqual([
49
- 'vault_submit_review',
50
- 'vault_approve',
51
- 'vault_reject',
52
- 'vault_pending_reviews',
53
- 'vault_review_stats',
54
- ]);
55
- });
56
-
57
42
  // ─── vault_submit_review ──────────────────────────────────────
58
43
 
59
44
  describe('vault_submit_review', () => {
@@ -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
 
@@ -14,6 +14,10 @@ export function coerceArray<T extends z.ZodTypeAny>(itemSchema: T) {
14
14
  /* fall through to let Zod reject */
15
15
  }
16
16
  }
17
+ // Wrap a bare object in an array so callers can omit the array wrapper
18
+ if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
19
+ return [val];
20
+ }
17
21
  return val;
18
22
  }, z.array(itemSchema));
19
23
  }
@@ -72,24 +72,6 @@ describe('createSyncOps', () => {
72
72
  ops = createSyncOps(rt);
73
73
  });
74
74
 
75
- it('returns 8 ops', () => {
76
- expect(ops.length).toBe(8);
77
- });
78
-
79
- it('has the expected op names', () => {
80
- const names = ops.map((o) => o.name);
81
- expect(names).toEqual([
82
- 'vault_git_push',
83
- 'vault_git_pull',
84
- 'vault_git_sync',
85
- 'obsidian_export',
86
- 'obsidian_import',
87
- 'obsidian_sync',
88
- 'vault_export_pack',
89
- 'vault_import_pack',
90
- ]);
91
- });
92
-
93
75
  // ─── vault_git_push ───────────────────────────────────────────
94
76
 
95
77
  describe('vault_git_push', () => {
@@ -37,27 +37,6 @@ describe('tier-ops', () => {
37
37
  ops = captureOps(createTierOps(runtime));
38
38
  });
39
39
 
40
- it('registers all 7 tier/source ops', () => {
41
- expect(ops.size).toBe(7);
42
- expect(ops.has('vault_connect')).toBe(true);
43
- expect(ops.has('vault_disconnect')).toBe(true);
44
- expect(ops.has('vault_tiers')).toBe(true);
45
- expect(ops.has('vault_search_all')).toBe(true);
46
- expect(ops.has('vault_connect_source')).toBe(true);
47
- expect(ops.has('vault_disconnect_source')).toBe(true);
48
- expect(ops.has('vault_list_sources')).toBe(true);
49
- });
50
-
51
- it('has correct auth levels', () => {
52
- expect(ops.get('vault_connect')!.auth).toBe('admin');
53
- expect(ops.get('vault_disconnect')!.auth).toBe('admin');
54
- expect(ops.get('vault_tiers')!.auth).toBe('read');
55
- expect(ops.get('vault_search_all')!.auth).toBe('read');
56
- expect(ops.get('vault_connect_source')!.auth).toBe('admin');
57
- expect(ops.get('vault_disconnect_source')!.auth).toBe('admin');
58
- expect(ops.get('vault_list_sources')!.auth).toBe('read');
59
- });
60
-
61
40
  // ─── Multi-vault ops ──────────────────────────────────────────────
62
41
 
63
42
  describe('vault_connect', () => {
@@ -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
  /**
@@ -77,18 +77,6 @@ describe('createVaultExtraOps', () => {
77
77
  ops = createVaultExtraOps(runtime);
78
78
  });
79
79
 
80
- it('returns 13 ops', () => {
81
- expect(ops).toHaveLength(13);
82
- });
83
-
84
- it('all ops have required fields', () => {
85
- for (const op of ops) {
86
- expect(op.name).toBeTruthy();
87
- expect(op.handler).toBeDefined();
88
- expect(['read', 'write', 'admin']).toContain(op.auth);
89
- }
90
- });
91
-
92
80
  describe('vault_get', () => {
93
81
  it('returns entry by ID', async () => {
94
82
  const result = (await findOp(ops, 'vault_get').handler({ id: 'entry-1' })) as Record<
@@ -52,10 +52,6 @@ describe('createVaultLinkingOps', () => {
52
52
  ops = createVaultLinkingOps(rt);
53
53
  });
54
54
 
55
- it('returns 9 ops', () => {
56
- expect(ops.length).toBe(9);
57
- });
58
-
59
55
  // ─── link_entries ─────────────────────────────────────────────
60
56
 
61
57
  describe('link_entries', () => {
@@ -32,14 +32,32 @@ export function createVaultLinkingOps(runtime: AgentRuntime): OpDefinition[] {
32
32
  name: 'link_entries',
33
33
  description: 'Create a typed link between two vault entries (Zettelkasten)',
34
34
  auth: 'write',
35
- schema: z.object({
36
- sourceId: z.string().describe('REQUIRED: Source entry ID'),
37
- targetId: z.string().describe('REQUIRED: Target entry ID'),
38
- linkType: z
39
- .enum(['supports', 'contradicts', 'extends', 'sequences'])
40
- .describe('REQUIRED: Relationship type'),
41
- note: z.string().optional().describe('Optional context for the link'),
42
- }),
35
+ schema: z.preprocess(
36
+ (val) => {
37
+ if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
38
+ const obj = val as Record<string, unknown>;
39
+ // Accept "relationship" as alias for "linkType"
40
+ if (obj.relationship !== undefined && obj.linkType === undefined) {
41
+ return { ...obj, linkType: obj.relationship };
42
+ }
43
+ }
44
+ return val;
45
+ },
46
+ z.object({
47
+ sourceId: z.string().describe('REQUIRED: Source entry ID'),
48
+ targetId: z.string().describe('REQUIRED: Target entry ID'),
49
+ linkType: z
50
+ .enum(['supports', 'contradicts', 'extends', 'sequences'])
51
+ .describe(
52
+ 'REQUIRED: Relationship type — supports | contradicts | extends | sequences. Alias: relationship',
53
+ ),
54
+ relationship: z
55
+ .enum(['supports', 'contradicts', 'extends', 'sequences'])
56
+ .optional()
57
+ .describe('Alias for linkType'),
58
+ note: z.string().optional().describe('Optional context for the link'),
59
+ }),
60
+ ),
43
61
  handler: async (params) => {
44
62
  const sourceId = params.sourceId as string;
45
63
  const targetId = params.targetId as string;
@@ -54,15 +54,6 @@ describe('createVaultSharingOps', () => {
54
54
  ops = createVaultSharingOps(rt);
55
55
  });
56
56
 
57
- it('returns 3 scope ops', () => {
58
- expect(ops.length).toBe(3);
59
- });
60
-
61
- it('has the expected op names', () => {
62
- const names = ops.map((o) => o.name);
63
- expect(names).toEqual(['vault_detect_scope', 'vault_set_scope', 'vault_list_by_scope']);
64
- });
65
-
66
57
  // ─── vault_detect_scope ───────────────────────────────────────
67
58
 
68
59
  describe('vault_detect_scope', () => {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Minimal cron expression validator.
3
+ * Enforces minimum 1-hour interval to prevent runaway automation.
4
+ */
5
+
6
+ /**
7
+ * Validate a cron expression (5-field: minute hour day month weekday).
8
+ * Returns null if valid, or an error message if invalid.
9
+ */
10
+ export function validateCron(expression: string): string | null {
11
+ const parts = expression.trim().split(/\s+/);
12
+ if (parts.length !== 5) {
13
+ return 'Cron expression must have exactly 5 fields: minute hour day month weekday';
14
+ }
15
+
16
+ const [minute, hour] = parts;
17
+
18
+ // Enforce minimum 1-hour interval: minute field must be a single fixed value (no */1 style)
19
+ if (minute === '*' || minute.startsWith('*/')) {
20
+ return 'Minimum scheduling interval is 1 hour. Minute field cannot be * or */N';
21
+ }
22
+
23
+ // Hour field: validate basic format (number, range, or list — not */1 which would be every hour on the minute)
24
+ if (hour !== '*' && !isValidField(hour, 0, 23)) {
25
+ return `Invalid hour field: "${hour}"`;
26
+ }
27
+
28
+ if (!isValidField(parts[0], 0, 59)) return `Invalid minute field: "${parts[0]}"`;
29
+ if (!isValidField(parts[2], 1, 31)) return `Invalid day field: "${parts[2]}"`;
30
+ if (!isValidField(parts[3], 1, 12)) return `Invalid month field: "${parts[3]}"`;
31
+ if (!isValidField(parts[4], 0, 7)) return `Invalid weekday field: "${parts[4]}"`;
32
+
33
+ return null;
34
+ }
35
+
36
+ function isValidField(field: string, min: number, max: number): boolean {
37
+ if (field === '*') return true;
38
+
39
+ // Handle step values: */N or N-M/S
40
+ if (field.includes('/')) {
41
+ const [range, step] = field.split('/');
42
+ const stepNum = Number(step);
43
+ if (isNaN(stepNum) || stepNum < 1) return false;
44
+ if (range !== '*' && !isValidRange(range, min, max)) return false;
45
+ return true;
46
+ }
47
+
48
+ // Handle comma-separated lists
49
+ if (field.includes(',')) {
50
+ return field.split(',').every((v) => isValidValue(v.trim(), min, max));
51
+ }
52
+
53
+ // Handle ranges
54
+ if (field.includes('-')) {
55
+ return isValidRange(field, min, max);
56
+ }
57
+
58
+ return isValidValue(field, min, max);
59
+ }
60
+
61
+ function isValidRange(range: string, min: number, max: number): boolean {
62
+ const [start, end] = range.split('-');
63
+ const s = Number(start);
64
+ const e = Number(end);
65
+ return !isNaN(s) && !isNaN(e) && s >= min && e <= max && s <= e;
66
+ }
67
+
68
+ function isValidValue(value: string, min: number, max: number): boolean {
69
+ const n = Number(value);
70
+ return !isNaN(n) && n >= min && n <= max;
71
+ }
72
+
73
+ /**
74
+ * Estimate the minimum interval in hours from a cron expression.
75
+ * Returns Infinity if the expression is invalid.
76
+ */
77
+ export function estimateMinIntervalHours(expression: string): number {
78
+ const parts = expression.trim().split(/\s+/);
79
+ if (parts.length !== 5) return Infinity;
80
+
81
+ const hour = parts[1];
82
+
83
+ // */N style = every N hours
84
+ if (hour.startsWith('*/')) {
85
+ return Number(hour.slice(2));
86
+ }
87
+
88
+ // Comma-separated: e.g. "2,4,6" = every 2 hours
89
+ if (hour.includes(',')) {
90
+ const values = hour
91
+ .split(',')
92
+ .map(Number)
93
+ .sort((a, b) => a - b);
94
+ if (values.length < 2) return 24;
95
+ const gaps = values.slice(1).map((v, i) => v - values[i]);
96
+ return Math.min(...gaps);
97
+ }
98
+
99
+ // Single value or range: at least once per day
100
+ return 24;
101
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Linux platform adapter — uses systemd user timers for scheduling.
3
+ *
4
+ * Creates ~/.config/systemd/user/{name}.timer and {name}.service units.
5
+ */
6
+
7
+ import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { execFileSync } from 'node:child_process';
11
+ import type { PlatformAdapter, ScheduledTask } from './types.js';
12
+
13
+ const SYSTEMD_USER_DIR = join(homedir(), '.config', 'systemd', 'user');
14
+
15
+ function unitPath(name: string, ext: 'service' | 'timer'): string {
16
+ return join(SYSTEMD_USER_DIR, `soleri-${name}.${ext}`);
17
+ }
18
+
19
+ /** Convert a 5-field cron to systemd OnCalendar format (simplified). */
20
+ function cronToOnCalendar(cron: string): string {
21
+ const [minute, hour, day, month, weekday] = cron.split(/\s+/);
22
+
23
+ // Simplified: only handle fixed values and */N patterns
24
+ const d = day === '*' ? '*' : day;
25
+ const M = month === '*' ? '*' : month;
26
+ const dow = weekday === '*' ? '*' : weekday;
27
+ const h = hour === '*' ? '*' : hour.startsWith('*/') ? `*/${hour.slice(2)}` : hour;
28
+ const m = minute === '*' ? '*' : minute;
29
+
30
+ return `${dow === '*' ? '' : dowName(dow)}*-${M}-${d} ${h}:${m}:00`;
31
+ }
32
+
33
+ function dowName(dow: string): string {
34
+ const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
35
+ const n = Number(dow);
36
+ return isNaN(n) ? dow : (names[n % 7] ?? '*');
37
+ }
38
+
39
+ function buildService(task: ScheduledTask, logPath: string): string {
40
+ return `[Unit]
41
+ Description=Soleri scheduled task: ${task.name}
42
+
43
+ [Service]
44
+ Type=oneshot
45
+ ExecStart=/usr/local/bin/claude -p "${task.prompt.replace(/"/g, '\\"')}" --project-dir ${task.projectPath}
46
+ WorkingDirectory=${task.projectPath}
47
+ StandardOutput=append:${logPath}.log
48
+ StandardError=append:${logPath}.err
49
+ TimeoutStartSec=600
50
+ `;
51
+ }
52
+
53
+ function buildTimer(task: ScheduledTask): string {
54
+ return `[Unit]
55
+ Description=Soleri timer for task: ${task.name}
56
+
57
+ [Timer]
58
+ OnCalendar=${cronToOnCalendar(task.cronExpression)}
59
+ Persistent=true
60
+
61
+ [Install]
62
+ WantedBy=timers.target
63
+ `;
64
+ }
65
+
66
+ export class LinuxAdapter implements PlatformAdapter {
67
+ async create(task: ScheduledTask): Promise<string> {
68
+ mkdirSync(SYSTEMD_USER_DIR, { recursive: true });
69
+ const logDir = join(homedir(), '.soleri', 'logs', 'scheduler');
70
+ mkdirSync(logDir, { recursive: true });
71
+ const logPath = join(logDir, task.name);
72
+
73
+ writeFileSync(unitPath(task.name, 'service'), buildService(task, logPath), 'utf-8');
74
+ writeFileSync(unitPath(task.name, 'timer'), buildTimer(task), 'utf-8');
75
+
76
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'pipe' });
77
+
78
+ if (task.enabled) {
79
+ execFileSync('systemctl', ['--user', 'enable', '--now', `soleri-${task.name}.timer`], {
80
+ stdio: 'pipe',
81
+ });
82
+ }
83
+
84
+ return `soleri-${task.name}`;
85
+ }
86
+
87
+ async remove(platformId: string): Promise<void> {
88
+ try {
89
+ execFileSync('systemctl', ['--user', 'disable', '--now', `${platformId}.timer`], {
90
+ stdio: 'pipe',
91
+ });
92
+ } catch {
93
+ // OK — may not exist
94
+ }
95
+ const name = platformId.replace(/^soleri-/, '');
96
+ for (const ext of ['service', 'timer'] as const) {
97
+ const path = unitPath(name, ext);
98
+ if (existsSync(path)) rmSync(path);
99
+ }
100
+ try {
101
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'pipe' });
102
+ } catch {
103
+ // Best-effort
104
+ }
105
+ }
106
+
107
+ async exists(platformId: string): Promise<boolean> {
108
+ const name = platformId.replace(/^soleri-/, '');
109
+ return existsSync(unitPath(name, 'timer'));
110
+ }
111
+
112
+ async pause(platformId: string): Promise<void> {
113
+ execFileSync('systemctl', ['--user', 'disable', `${platformId}.timer`], { stdio: 'pipe' });
114
+ execFileSync('systemctl', ['--user', 'stop', `${platformId}.timer`], { stdio: 'pipe' });
115
+ }
116
+
117
+ async resume(platformId: string): Promise<void> {
118
+ execFileSync('systemctl', ['--user', 'enable', '--now', `${platformId}.timer`], {
119
+ stdio: 'pipe',
120
+ });
121
+ }
122
+ }