patchwork-os 0.2.0-alpha.8 → 0.2.0-beta.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 (581) hide show
  1. package/README.bridge.md +6 -0
  2. package/README.md +315 -35
  3. package/deploy/bootstrap-new-vps.sh +12 -12
  4. package/deploy/bootstrap-vps.sh +187 -0
  5. package/deploy/deploy-dashboard.sh +174 -0
  6. package/deploy/deploy-landing.sh +136 -0
  7. package/dist/activationMetrics.d.ts +67 -0
  8. package/dist/activationMetrics.js +255 -0
  9. package/dist/activationMetrics.js.map +1 -0
  10. package/dist/activityLog.d.ts +49 -0
  11. package/dist/activityLog.js +78 -0
  12. package/dist/activityLog.js.map +1 -1
  13. package/dist/approvalHttp.d.ts +49 -2
  14. package/dist/approvalHttp.js +217 -21
  15. package/dist/approvalHttp.js.map +1 -1
  16. package/dist/approvalInsights.d.ts +49 -0
  17. package/dist/approvalInsights.js +97 -0
  18. package/dist/approvalInsights.js.map +1 -0
  19. package/dist/approvalQueue.d.ts +27 -1
  20. package/dist/approvalQueue.js +123 -3
  21. package/dist/approvalQueue.js.map +1 -1
  22. package/dist/approvalSignals.d.ts +124 -0
  23. package/dist/approvalSignals.js +512 -0
  24. package/dist/approvalSignals.js.map +1 -0
  25. package/dist/automation.d.ts +57 -0
  26. package/dist/automation.js +156 -59
  27. package/dist/automation.js.map +1 -1
  28. package/dist/automationSuggestions.d.ts +79 -0
  29. package/dist/automationSuggestions.js +150 -0
  30. package/dist/automationSuggestions.js.map +1 -0
  31. package/dist/bridge.d.ts +3 -0
  32. package/dist/bridge.js +174 -143
  33. package/dist/bridge.js.map +1 -1
  34. package/dist/bridgeToken.js +57 -19
  35. package/dist/bridgeToken.js.map +1 -1
  36. package/dist/ccPermissions.d.ts +15 -0
  37. package/dist/ccPermissions.js +21 -4
  38. package/dist/ccPermissions.js.map +1 -1
  39. package/dist/claudeDriver.js +74 -16
  40. package/dist/claudeDriver.js.map +1 -1
  41. package/dist/claudeOrchestrator.d.ts +1 -1
  42. package/dist/claudeOrchestrator.js +14 -8
  43. package/dist/claudeOrchestrator.js.map +1 -1
  44. package/dist/commands/dashboard.js +1 -1
  45. package/dist/commands/dashboard.js.map +1 -1
  46. package/dist/commands/launchd.d.ts +2 -0
  47. package/dist/commands/launchd.js +94 -0
  48. package/dist/commands/launchd.js.map +1 -0
  49. package/dist/commands/patchworkInit.d.ts +8 -0
  50. package/dist/commands/patchworkInit.js +77 -11
  51. package/dist/commands/patchworkInit.js.map +1 -1
  52. package/dist/commands/recipe.d.ts +289 -0
  53. package/dist/commands/recipe.js +1359 -0
  54. package/dist/commands/recipe.js.map +1 -0
  55. package/dist/commands/recipeInstall.d.ts +150 -0
  56. package/dist/commands/recipeInstall.js +647 -0
  57. package/dist/commands/recipeInstall.js.map +1 -0
  58. package/dist/commands/tracesExport.d.ts +83 -0
  59. package/dist/commands/tracesExport.js +269 -0
  60. package/dist/commands/tracesExport.js.map +1 -0
  61. package/dist/commands/tracesImport.d.ts +56 -0
  62. package/dist/commands/tracesImport.js +161 -0
  63. package/dist/commands/tracesImport.js.map +1 -0
  64. package/dist/config.d.ts +22 -1
  65. package/dist/config.js +108 -9
  66. package/dist/config.js.map +1 -1
  67. package/dist/connectorRoutes.d.ts +43 -0
  68. package/dist/connectorRoutes.js +1609 -0
  69. package/dist/connectorRoutes.js.map +1 -0
  70. package/dist/connectors/asana.d.ts +198 -0
  71. package/dist/connectors/asana.js +679 -0
  72. package/dist/connectors/asana.js.map +1 -0
  73. package/dist/connectors/baseConnector.d.ts +153 -0
  74. package/dist/connectors/baseConnector.js +336 -0
  75. package/dist/connectors/baseConnector.js.map +1 -0
  76. package/dist/connectors/confluence.d.ts +111 -0
  77. package/dist/connectors/confluence.js +406 -0
  78. package/dist/connectors/confluence.js.map +1 -0
  79. package/dist/connectors/datadog.d.ts +116 -0
  80. package/dist/connectors/datadog.js +385 -0
  81. package/dist/connectors/datadog.js.map +1 -0
  82. package/dist/connectors/discord.d.ts +150 -0
  83. package/dist/connectors/discord.js +543 -0
  84. package/dist/connectors/discord.js.map +1 -0
  85. package/dist/connectors/fixtureLibrary.d.ts +21 -0
  86. package/dist/connectors/fixtureLibrary.js +70 -0
  87. package/dist/connectors/fixtureLibrary.js.map +1 -0
  88. package/dist/connectors/fixtureRecorder.d.ts +1 -0
  89. package/dist/connectors/fixtureRecorder.js +35 -0
  90. package/dist/connectors/fixtureRecorder.js.map +1 -0
  91. package/dist/connectors/github.js +17 -18
  92. package/dist/connectors/github.js.map +1 -1
  93. package/dist/connectors/gitlab.d.ts +180 -0
  94. package/dist/connectors/gitlab.js +582 -0
  95. package/dist/connectors/gitlab.js.map +1 -0
  96. package/dist/connectors/gmail.d.ts +4 -1
  97. package/dist/connectors/gmail.js +157 -27
  98. package/dist/connectors/gmail.js.map +1 -1
  99. package/dist/connectors/googleCalendar.d.ts +4 -1
  100. package/dist/connectors/googleCalendar.js +88 -25
  101. package/dist/connectors/googleCalendar.js.map +1 -1
  102. package/dist/connectors/googleDrive.d.ts +34 -0
  103. package/dist/connectors/googleDrive.js +321 -0
  104. package/dist/connectors/googleDrive.js.map +1 -0
  105. package/dist/connectors/htmlEscape.d.ts +5 -0
  106. package/dist/connectors/htmlEscape.js +13 -0
  107. package/dist/connectors/htmlEscape.js.map +1 -0
  108. package/dist/connectors/hubspot.d.ts +112 -0
  109. package/dist/connectors/hubspot.js +408 -0
  110. package/dist/connectors/hubspot.js.map +1 -0
  111. package/dist/connectors/intercom.d.ts +102 -0
  112. package/dist/connectors/intercom.js +402 -0
  113. package/dist/connectors/intercom.js.map +1 -0
  114. package/dist/connectors/jira.d.ts +98 -0
  115. package/dist/connectors/jira.js +379 -0
  116. package/dist/connectors/jira.js.map +1 -0
  117. package/dist/connectors/linear.js +30 -19
  118. package/dist/connectors/linear.js.map +1 -1
  119. package/dist/connectors/mcpOAuth.d.ts +3 -0
  120. package/dist/connectors/mcpOAuth.js +64 -10
  121. package/dist/connectors/mcpOAuth.js.map +1 -1
  122. package/dist/connectors/mockConnector.d.ts +28 -0
  123. package/dist/connectors/mockConnector.js +81 -0
  124. package/dist/connectors/mockConnector.js.map +1 -0
  125. package/dist/connectors/notion.d.ts +143 -0
  126. package/dist/connectors/notion.js +424 -0
  127. package/dist/connectors/notion.js.map +1 -0
  128. package/dist/connectors/oauthStateStore.d.ts +31 -0
  129. package/dist/connectors/oauthStateStore.js +52 -0
  130. package/dist/connectors/oauthStateStore.js.map +1 -0
  131. package/dist/connectors/pagerduty.d.ts +160 -0
  132. package/dist/connectors/pagerduty.js +464 -0
  133. package/dist/connectors/pagerduty.js.map +1 -0
  134. package/dist/connectors/sentry.js +5 -13
  135. package/dist/connectors/sentry.js.map +1 -1
  136. package/dist/connectors/slack.d.ts +16 -1
  137. package/dist/connectors/slack.js +155 -32
  138. package/dist/connectors/slack.js.map +1 -1
  139. package/dist/connectors/stripe.d.ts +116 -0
  140. package/dist/connectors/stripe.js +379 -0
  141. package/dist/connectors/stripe.js.map +1 -0
  142. package/dist/connectors/tokenStorage.d.ts +35 -0
  143. package/dist/connectors/tokenStorage.js +484 -0
  144. package/dist/connectors/tokenStorage.js.map +1 -0
  145. package/dist/connectors/zendesk.d.ts +104 -0
  146. package/dist/connectors/zendesk.js +442 -0
  147. package/dist/connectors/zendesk.js.map +1 -0
  148. package/dist/cors.d.ts +10 -0
  149. package/dist/cors.js +29 -0
  150. package/dist/cors.js.map +1 -0
  151. package/dist/decisionReplay.d.ts +72 -0
  152. package/dist/decisionReplay.js +92 -0
  153. package/dist/decisionReplay.js.map +1 -0
  154. package/dist/decisionTraceLog.d.ts +6 -0
  155. package/dist/decisionTraceLog.js +54 -2
  156. package/dist/decisionTraceLog.js.map +1 -1
  157. package/dist/drivers/gemini/index.d.ts +5 -1
  158. package/dist/drivers/gemini/index.js +39 -5
  159. package/dist/drivers/gemini/index.js.map +1 -1
  160. package/dist/drivers/index.d.ts +5 -0
  161. package/dist/drivers/index.js +1 -1
  162. package/dist/drivers/index.js.map +1 -1
  163. package/dist/featureFlags.d.ts +79 -0
  164. package/dist/featureFlags.js +208 -0
  165. package/dist/featureFlags.js.map +1 -0
  166. package/dist/fp/automationInterpreter.js +26 -21
  167. package/dist/fp/automationInterpreter.js.map +1 -1
  168. package/dist/fp/automationProgram.d.ts +1 -1
  169. package/dist/fp/automationProgram.js.map +1 -1
  170. package/dist/fp/automationState.js +4 -1
  171. package/dist/fp/automationState.js.map +1 -1
  172. package/dist/fp/policyParser.js +21 -1
  173. package/dist/fp/policyParser.js.map +1 -1
  174. package/dist/inboxRoutes.d.ts +22 -0
  175. package/dist/inboxRoutes.js +114 -0
  176. package/dist/inboxRoutes.js.map +1 -0
  177. package/dist/index.js +1400 -201
  178. package/dist/index.js.map +1 -1
  179. package/dist/installGuard.d.ts +25 -0
  180. package/dist/installGuard.js +48 -0
  181. package/dist/installGuard.js.map +1 -0
  182. package/dist/mcpRoutes.d.ts +37 -0
  183. package/dist/mcpRoutes.js +76 -0
  184. package/dist/mcpRoutes.js.map +1 -0
  185. package/dist/oauth.d.ts +7 -1
  186. package/dist/oauth.js +201 -39
  187. package/dist/oauth.js.map +1 -1
  188. package/dist/oauthRoutes.d.ts +32 -0
  189. package/dist/oauthRoutes.js +124 -0
  190. package/dist/oauthRoutes.js.map +1 -0
  191. package/dist/orchestrator/orchestratorBridge.js +2 -2
  192. package/dist/orchestrator/orchestratorBridge.js.map +1 -1
  193. package/dist/patchworkConfig.d.ts +16 -0
  194. package/dist/patchworkConfig.js +1 -1
  195. package/dist/patchworkConfig.js.map +1 -1
  196. package/dist/pluginLoader.d.ts +28 -0
  197. package/dist/pluginLoader.js +77 -11
  198. package/dist/pluginLoader.js.map +1 -1
  199. package/dist/pluginWatcher.js +8 -3
  200. package/dist/pluginWatcher.js.map +1 -1
  201. package/dist/preToolUseHook.d.ts +12 -0
  202. package/dist/preToolUseHook.js +23 -0
  203. package/dist/preToolUseHook.js.map +1 -1
  204. package/dist/recipeOrchestration.d.ts +121 -0
  205. package/dist/recipeOrchestration.js +955 -0
  206. package/dist/recipeOrchestration.js.map +1 -0
  207. package/dist/recipeRoutes.d.ts +180 -0
  208. package/dist/recipeRoutes.js +1345 -0
  209. package/dist/recipeRoutes.js.map +1 -0
  210. package/dist/recipes/RecipeOrchestrator.d.ts +40 -0
  211. package/dist/recipes/RecipeOrchestrator.js +51 -0
  212. package/dist/recipes/RecipeOrchestrator.js.map +1 -0
  213. package/dist/recipes/agentExecutor.d.ts +29 -0
  214. package/dist/recipes/agentExecutor.js +49 -0
  215. package/dist/recipes/agentExecutor.js.map +1 -0
  216. package/dist/recipes/chainedRunner.d.ts +191 -0
  217. package/dist/recipes/chainedRunner.js +759 -0
  218. package/dist/recipes/chainedRunner.js.map +1 -0
  219. package/dist/recipes/compiler.js +3 -3
  220. package/dist/recipes/compiler.js.map +1 -1
  221. package/dist/recipes/dependencyGraph.d.ts +39 -0
  222. package/dist/recipes/dependencyGraph.js +199 -0
  223. package/dist/recipes/dependencyGraph.js.map +1 -0
  224. package/dist/recipes/disabledMarkers.d.ts +48 -0
  225. package/dist/recipes/disabledMarkers.js +52 -0
  226. package/dist/recipes/disabledMarkers.js.map +1 -0
  227. package/dist/recipes/installer.js +3 -3
  228. package/dist/recipes/installer.js.map +1 -1
  229. package/dist/recipes/legacyRecipeCompat.d.ts +10 -0
  230. package/dist/recipes/legacyRecipeCompat.js +131 -0
  231. package/dist/recipes/legacyRecipeCompat.js.map +1 -0
  232. package/dist/recipes/manifest.d.ts +47 -0
  233. package/dist/recipes/manifest.js +156 -0
  234. package/dist/recipes/manifest.js.map +1 -0
  235. package/dist/recipes/migrationWarnings.d.ts +12 -0
  236. package/dist/recipes/migrationWarnings.js +44 -0
  237. package/dist/recipes/migrationWarnings.js.map +1 -0
  238. package/dist/recipes/migrations/index.d.ts +24 -0
  239. package/dist/recipes/migrations/index.js +55 -0
  240. package/dist/recipes/migrations/index.js.map +1 -0
  241. package/dist/recipes/migrations/types.d.ts +28 -0
  242. package/dist/recipes/migrations/types.js +2 -0
  243. package/dist/recipes/migrations/types.js.map +1 -0
  244. package/dist/recipes/migrations/v1.d.ts +11 -0
  245. package/dist/recipes/migrations/v1.js +18 -0
  246. package/dist/recipes/migrations/v1.js.map +1 -0
  247. package/dist/recipes/names.d.ts +40 -0
  248. package/dist/recipes/names.js +66 -0
  249. package/dist/recipes/names.js.map +1 -0
  250. package/dist/recipes/nestedRecipeStep.d.ts +58 -0
  251. package/dist/recipes/nestedRecipeStep.js +95 -0
  252. package/dist/recipes/nestedRecipeStep.js.map +1 -0
  253. package/dist/recipes/outputRegistry.d.ts +28 -0
  254. package/dist/recipes/outputRegistry.js +52 -0
  255. package/dist/recipes/outputRegistry.js.map +1 -0
  256. package/dist/recipes/parser.js +4 -1
  257. package/dist/recipes/parser.js.map +1 -1
  258. package/dist/recipes/replayRun.d.ts +62 -0
  259. package/dist/recipes/replayRun.js +97 -0
  260. package/dist/recipes/replayRun.js.map +1 -0
  261. package/dist/recipes/resolveRecipePath.d.ts +69 -0
  262. package/dist/recipes/resolveRecipePath.js +202 -0
  263. package/dist/recipes/resolveRecipePath.js.map +1 -0
  264. package/dist/recipes/scheduler.d.ts +23 -7
  265. package/dist/recipes/scheduler.js +225 -45
  266. package/dist/recipes/scheduler.js.map +1 -1
  267. package/dist/recipes/schema.d.ts +17 -2
  268. package/dist/recipes/schemaGenerator.d.ts +28 -0
  269. package/dist/recipes/schemaGenerator.js +565 -0
  270. package/dist/recipes/schemaGenerator.js.map +1 -0
  271. package/dist/recipes/stepObservation.d.ts +44 -0
  272. package/dist/recipes/stepObservation.js +232 -0
  273. package/dist/recipes/stepObservation.js.map +1 -0
  274. package/dist/recipes/templateEngine.d.ts +62 -0
  275. package/dist/recipes/templateEngine.js +201 -0
  276. package/dist/recipes/templateEngine.js.map +1 -0
  277. package/dist/recipes/toolRegistry.d.ts +186 -0
  278. package/dist/recipes/toolRegistry.js +309 -0
  279. package/dist/recipes/toolRegistry.js.map +1 -0
  280. package/dist/recipes/tools/asana.d.ts +16 -0
  281. package/dist/recipes/tools/asana.js +524 -0
  282. package/dist/recipes/tools/asana.js.map +1 -0
  283. package/dist/recipes/tools/calendar.d.ts +6 -0
  284. package/dist/recipes/tools/calendar.js +61 -0
  285. package/dist/recipes/tools/calendar.js.map +1 -0
  286. package/dist/recipes/tools/confluence.d.ts +6 -0
  287. package/dist/recipes/tools/confluence.js +254 -0
  288. package/dist/recipes/tools/confluence.js.map +1 -0
  289. package/dist/recipes/tools/datadog.d.ts +6 -0
  290. package/dist/recipes/tools/datadog.js +239 -0
  291. package/dist/recipes/tools/datadog.js.map +1 -0
  292. package/dist/recipes/tools/diagnostics.d.ts +6 -0
  293. package/dist/recipes/tools/diagnostics.js +36 -0
  294. package/dist/recipes/tools/diagnostics.js.map +1 -0
  295. package/dist/recipes/tools/discord.d.ts +18 -0
  296. package/dist/recipes/tools/discord.js +254 -0
  297. package/dist/recipes/tools/discord.js.map +1 -0
  298. package/dist/recipes/tools/file.d.ts +12 -0
  299. package/dist/recipes/tools/file.js +174 -0
  300. package/dist/recipes/tools/file.js.map +1 -0
  301. package/dist/recipes/tools/git.d.ts +6 -0
  302. package/dist/recipes/tools/git.js +63 -0
  303. package/dist/recipes/tools/git.js.map +1 -0
  304. package/dist/recipes/tools/github.d.ts +6 -0
  305. package/dist/recipes/tools/github.js +116 -0
  306. package/dist/recipes/tools/github.js.map +1 -0
  307. package/dist/recipes/tools/gitlab.d.ts +11 -0
  308. package/dist/recipes/tools/gitlab.js +285 -0
  309. package/dist/recipes/tools/gitlab.js.map +1 -0
  310. package/dist/recipes/tools/gmail.d.ts +6 -0
  311. package/dist/recipes/tools/gmail.js +434 -0
  312. package/dist/recipes/tools/gmail.js.map +1 -0
  313. package/dist/recipes/tools/googleDrive.d.ts +1 -0
  314. package/dist/recipes/tools/googleDrive.js +55 -0
  315. package/dist/recipes/tools/googleDrive.js.map +1 -0
  316. package/dist/recipes/tools/hubspot.d.ts +6 -0
  317. package/dist/recipes/tools/hubspot.js +232 -0
  318. package/dist/recipes/tools/hubspot.js.map +1 -0
  319. package/dist/recipes/tools/index.d.ts +30 -0
  320. package/dist/recipes/tools/index.js +33 -0
  321. package/dist/recipes/tools/index.js.map +1 -0
  322. package/dist/recipes/tools/intercom.d.ts +6 -0
  323. package/dist/recipes/tools/intercom.js +226 -0
  324. package/dist/recipes/tools/intercom.js.map +1 -0
  325. package/dist/recipes/tools/jira.d.ts +14 -0
  326. package/dist/recipes/tools/jira.js +369 -0
  327. package/dist/recipes/tools/jira.js.map +1 -0
  328. package/dist/recipes/tools/linear.d.ts +7 -0
  329. package/dist/recipes/tools/linear.js +307 -0
  330. package/dist/recipes/tools/linear.js.map +1 -0
  331. package/dist/recipes/tools/meetingNotes.d.ts +21 -0
  332. package/dist/recipes/tools/meetingNotes.js +701 -0
  333. package/dist/recipes/tools/meetingNotes.js.map +1 -0
  334. package/dist/recipes/tools/notion.d.ts +6 -0
  335. package/dist/recipes/tools/notion.js +278 -0
  336. package/dist/recipes/tools/notion.js.map +1 -0
  337. package/dist/recipes/tools/pagerduty.d.ts +15 -0
  338. package/dist/recipes/tools/pagerduty.js +451 -0
  339. package/dist/recipes/tools/pagerduty.js.map +1 -0
  340. package/dist/recipes/tools/sentry.d.ts +12 -0
  341. package/dist/recipes/tools/sentry.js +73 -0
  342. package/dist/recipes/tools/sentry.js.map +1 -0
  343. package/dist/recipes/tools/slack.d.ts +6 -0
  344. package/dist/recipes/tools/slack.js +82 -0
  345. package/dist/recipes/tools/slack.js.map +1 -0
  346. package/dist/recipes/tools/stripe.d.ts +6 -0
  347. package/dist/recipes/tools/stripe.js +265 -0
  348. package/dist/recipes/tools/stripe.js.map +1 -0
  349. package/dist/recipes/tools/zendesk.d.ts +6 -0
  350. package/dist/recipes/tools/zendesk.js +245 -0
  351. package/dist/recipes/tools/zendesk.js.map +1 -0
  352. package/dist/recipes/validation.d.ts +13 -0
  353. package/dist/recipes/validation.js +617 -0
  354. package/dist/recipes/validation.js.map +1 -0
  355. package/dist/recipes/yamlRunner.d.ts +116 -1
  356. package/dist/recipes/yamlRunner.js +1000 -401
  357. package/dist/recipes/yamlRunner.js.map +1 -1
  358. package/dist/recipesHttp.d.ts +137 -6
  359. package/dist/recipesHttp.js +941 -29
  360. package/dist/recipesHttp.js.map +1 -1
  361. package/dist/riskTier.js +7 -1
  362. package/dist/riskTier.js.map +1 -1
  363. package/dist/runLog.d.ts +100 -1
  364. package/dist/runLog.js +258 -5
  365. package/dist/runLog.js.map +1 -1
  366. package/dist/schemas/dry-run-plan.v1.json +139 -0
  367. package/dist/schemas/recipe.v1.json +684 -0
  368. package/dist/server.d.ts +121 -8
  369. package/dist/server.js +538 -735
  370. package/dist/server.js.map +1 -1
  371. package/dist/ssrfGuard.d.ts +54 -0
  372. package/dist/ssrfGuard.js +122 -0
  373. package/dist/ssrfGuard.js.map +1 -0
  374. package/dist/streamableHttp.d.ts +39 -1
  375. package/dist/streamableHttp.js +128 -17
  376. package/dist/streamableHttp.js.map +1 -1
  377. package/dist/tokenUsageTracker.d.ts +33 -0
  378. package/dist/tokenUsageTracker.js +146 -0
  379. package/dist/tokenUsageTracker.js.map +1 -0
  380. package/dist/tools/activityLog.d.ts +2 -0
  381. package/dist/tools/addLinearComment.d.ts +1 -0
  382. package/dist/tools/addLinearComment.js +4 -2
  383. package/dist/tools/addLinearComment.js.map +1 -1
  384. package/dist/tools/batchLsp.d.ts +3 -0
  385. package/dist/tools/bridgeDoctor.d.ts +1 -0
  386. package/dist/tools/bridgeDoctor.js +2 -2
  387. package/dist/tools/bridgeDoctor.js.map +1 -1
  388. package/dist/tools/bridgeStatus.d.ts +1 -0
  389. package/dist/tools/cancelClaudeTask.d.ts +2 -0
  390. package/dist/tools/cancelClaudeTask.js +1 -0
  391. package/dist/tools/cancelClaudeTask.js.map +1 -1
  392. package/dist/tools/checkDocumentDirty.d.ts +1 -0
  393. package/dist/tools/clipboard.d.ts +2 -0
  394. package/dist/tools/closeTabs.d.ts +2 -0
  395. package/dist/tools/codeLens.d.ts +1 -0
  396. package/dist/tools/contextBundle.d.ts +1 -0
  397. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  398. package/dist/tools/createLinearIssue.d.ts +1 -0
  399. package/dist/tools/ctxGetTaskContext.d.ts +1 -0
  400. package/dist/tools/ctxQueryTraces.d.ts +1 -0
  401. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  402. package/dist/tools/debug.d.ts +4 -0
  403. package/dist/tools/decorations.d.ts +2 -0
  404. package/dist/tools/documentLinks.d.ts +1 -0
  405. package/dist/tools/editText.d.ts +1 -0
  406. package/dist/tools/enrichCommit.d.ts +1 -0
  407. package/dist/tools/enrichStackTrace.d.ts +1 -0
  408. package/dist/tools/explainDiagnostic.d.ts +1 -0
  409. package/dist/tools/explainSymbol.d.ts +1 -0
  410. package/dist/tools/fetchCalendarEvents.d.ts +1 -0
  411. package/dist/tools/fetchGithubIssue.d.ts +1 -0
  412. package/dist/tools/fetchGithubPR.d.ts +1 -0
  413. package/dist/tools/fetchLinearIssue.d.ts +1 -0
  414. package/dist/tools/fetchSentryIssue.d.ts +1 -0
  415. package/dist/tools/fetchSlackProfile.d.ts +1 -0
  416. package/dist/tools/fetchSlackProfile.js +4 -1
  417. package/dist/tools/fetchSlackProfile.js.map +1 -1
  418. package/dist/tools/fileOperations.d.ts +3 -0
  419. package/dist/tools/fileWatcher.d.ts +2 -0
  420. package/dist/tools/findFiles.d.ts +1 -0
  421. package/dist/tools/findRelatedTests.d.ts +1 -0
  422. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  423. package/dist/tools/foldingRanges.d.ts +1 -0
  424. package/dist/tools/formatDocument.d.ts +1 -0
  425. package/dist/tools/generateTests.d.ts +1 -0
  426. package/dist/tools/getAIComments.d.ts +1 -0
  427. package/dist/tools/getAnalyticsReport.d.ts +1 -0
  428. package/dist/tools/getArchitectureContext.d.ts +1 -0
  429. package/dist/tools/getBufferContent.d.ts +1 -0
  430. package/dist/tools/getChangeImpact.d.ts +1 -0
  431. package/dist/tools/getClaudeTaskStatus.d.ts +2 -0
  432. package/dist/tools/getClaudeTaskStatus.js +1 -0
  433. package/dist/tools/getClaudeTaskStatus.js.map +1 -1
  434. package/dist/tools/getCodeCoverage.d.ts +1 -0
  435. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  436. package/dist/tools/getConnectorStatus.d.ts +1 -0
  437. package/dist/tools/getCurrentSelection.d.ts +2 -0
  438. package/dist/tools/getDebugState.d.ts +1 -0
  439. package/dist/tools/getDependencyTree.d.ts +1 -0
  440. package/dist/tools/getDiagnostics.d.ts +1 -0
  441. package/dist/tools/getDiffFromHandoff.d.ts +1 -0
  442. package/dist/tools/getDocumentSymbols.d.ts +25 -0
  443. package/dist/tools/getDocumentSymbols.js +74 -8
  444. package/dist/tools/getDocumentSymbols.js.map +1 -1
  445. package/dist/tools/getFileTree.d.ts +1 -0
  446. package/dist/tools/getGitDiff.d.ts +1 -0
  447. package/dist/tools/getGitHotspots.d.ts +1 -0
  448. package/dist/tools/getGitLog.d.ts +1 -0
  449. package/dist/tools/getGitStatus.d.ts +1 -0
  450. package/dist/tools/getImportTree.d.ts +1 -0
  451. package/dist/tools/getImportedSignatures.d.ts +1 -0
  452. package/dist/tools/getOpenEditors.d.ts +1 -0
  453. package/dist/tools/getPRTemplate.d.ts +1 -0
  454. package/dist/tools/getProjectContext.d.ts +1 -0
  455. package/dist/tools/getProjectInfo.d.ts +1 -0
  456. package/dist/tools/getSecurityAdvisories.d.ts +1 -0
  457. package/dist/tools/getSecurityAdvisories.js +10 -1
  458. package/dist/tools/getSecurityAdvisories.js.map +1 -1
  459. package/dist/tools/getSessionUsage.d.ts +4 -0
  460. package/dist/tools/getSessionUsage.js +3 -0
  461. package/dist/tools/getSessionUsage.js.map +1 -1
  462. package/dist/tools/getSymbolHistory.d.ts +1 -0
  463. package/dist/tools/getToolCapabilities.d.ts +1 -0
  464. package/dist/tools/getTypeSignature.d.ts +1 -0
  465. package/dist/tools/getWorkspaceFolders.d.ts +1 -0
  466. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  467. package/dist/tools/gitHistory.d.ts +2 -0
  468. package/dist/tools/gitWrite.d.ts +11 -0
  469. package/dist/tools/github/actions.d.ts +2 -0
  470. package/dist/tools/github/actions.js +4 -2
  471. package/dist/tools/github/actions.js.map +1 -1
  472. package/dist/tools/github/composite.d.ts +342 -0
  473. package/dist/tools/github/composite.js +343 -0
  474. package/dist/tools/github/composite.js.map +1 -0
  475. package/dist/tools/github/index.d.ts +1 -0
  476. package/dist/tools/github/index.js +1 -0
  477. package/dist/tools/github/index.js.map +1 -1
  478. package/dist/tools/github/issues.d.ts +4 -0
  479. package/dist/tools/github/issues.js +8 -4
  480. package/dist/tools/github/issues.js.map +1 -1
  481. package/dist/tools/github/pr.d.ts +7 -0
  482. package/dist/tools/github/pr.js +50 -12
  483. package/dist/tools/github/pr.js.map +1 -1
  484. package/dist/tools/handoffNote.d.ts +4 -0
  485. package/dist/tools/handoffNote.js +2 -0
  486. package/dist/tools/handoffNote.js.map +1 -1
  487. package/dist/tools/hoverAtCursor.d.ts +1 -0
  488. package/dist/tools/httpClient.d.ts +2 -0
  489. package/dist/tools/index.d.ts +8 -0
  490. package/dist/tools/index.js +47 -8
  491. package/dist/tools/index.js.map +1 -1
  492. package/dist/tools/inlayHints.d.ts +1 -0
  493. package/dist/tools/launchQuickTask.d.ts +2 -0
  494. package/dist/tools/launchQuickTask.js +1 -0
  495. package/dist/tools/launchQuickTask.js.map +1 -1
  496. package/dist/tools/listClaudeTasks.d.ts +2 -0
  497. package/dist/tools/listClaudeTasks.js +1 -0
  498. package/dist/tools/listClaudeTasks.js.map +1 -1
  499. package/dist/tools/listTerminals.d.ts +1 -0
  500. package/dist/tools/lsp.d.ts +14 -0
  501. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  502. package/dist/tools/openDiff.d.ts +1 -0
  503. package/dist/tools/openFile.d.ts +1 -0
  504. package/dist/tools/openInBrowser.d.ts +1 -0
  505. package/dist/tools/organizeImports.d.ts +1 -0
  506. package/dist/tools/performanceReport.d.ts +1 -0
  507. package/dist/tools/planPersistence.d.ts +5 -0
  508. package/dist/tools/previewEdit.d.ts +1 -0
  509. package/dist/tools/refactorAnalyze.d.ts +1 -0
  510. package/dist/tools/refactorPreview.d.ts +2 -0
  511. package/dist/tools/refactorPreview.js +1 -0
  512. package/dist/tools/refactorPreview.js.map +1 -1
  513. package/dist/tools/replaceBlock.d.ts +1 -0
  514. package/dist/tools/resumeClaudeTask.d.ts +2 -0
  515. package/dist/tools/resumeClaudeTask.js +1 -0
  516. package/dist/tools/resumeClaudeTask.js.map +1 -1
  517. package/dist/tools/runClaudeTask.d.ts +2 -0
  518. package/dist/tools/runClaudeTask.js +1 -0
  519. package/dist/tools/runClaudeTask.js.map +1 -1
  520. package/dist/tools/runCommand.d.ts +1 -0
  521. package/dist/tools/runTests.d.ts +1 -0
  522. package/dist/tools/saveDocument.d.ts +1 -0
  523. package/dist/tools/screenshotAndAnnotate.d.ts +1 -0
  524. package/dist/tools/searchAndReplace.d.ts +1 -0
  525. package/dist/tools/searchTools.d.ts +1 -0
  526. package/dist/tools/searchTools.js +1 -1
  527. package/dist/tools/searchTools.js.map +1 -1
  528. package/dist/tools/searchWorkspace.d.ts +1 -0
  529. package/dist/tools/selectionRanges.d.ts +1 -0
  530. package/dist/tools/semanticTokens.d.ts +1 -0
  531. package/dist/tools/setActiveWorkspaceFolder.d.ts +1 -0
  532. package/dist/tools/signatureHelp.d.ts +1 -0
  533. package/dist/tools/slackListChannels.d.ts +1 -0
  534. package/dist/tools/slackListChannels.js.map +1 -1
  535. package/dist/tools/slackPostMessage.d.ts +1 -0
  536. package/dist/tools/slackPostMessage.js +11 -6
  537. package/dist/tools/slackPostMessage.js.map +1 -1
  538. package/dist/tools/terminal.d.ts +6 -0
  539. package/dist/tools/testTraceToSource.d.ts +1 -0
  540. package/dist/tools/testTraceToSource.js +2 -2
  541. package/dist/tools/testTraceToSource.js.map +1 -1
  542. package/dist/tools/transaction.d.ts +23 -0
  543. package/dist/tools/transaction.js +29 -0
  544. package/dist/tools/transaction.js.map +1 -1
  545. package/dist/tools/typeHierarchy.d.ts +1 -0
  546. package/dist/tools/updateLinearIssue.d.ts +1 -0
  547. package/dist/tools/updateLinearIssue.js +20 -6
  548. package/dist/tools/updateLinearIssue.js.map +1 -1
  549. package/dist/tools/utils.d.ts +2 -0
  550. package/dist/tools/utils.js.map +1 -1
  551. package/dist/tools/vscodeCommands.d.ts +2 -0
  552. package/dist/tools/vscodeTasks.d.ts +2 -0
  553. package/dist/tools/workspaceSettings.d.ts +1 -0
  554. package/dist/traceEncryption.d.ts +46 -0
  555. package/dist/traceEncryption.js +124 -0
  556. package/dist/traceEncryption.js.map +1 -0
  557. package/dist/transport.d.ts +46 -1
  558. package/dist/transport.js +173 -19
  559. package/dist/transport.js.map +1 -1
  560. package/package.json +30 -8
  561. package/scripts/mcp-stdio-shim.cjs +19 -3
  562. package/scripts/start-all.sh +30 -1
  563. package/templates/automation-policies/recipe-authoring.json +25 -0
  564. package/templates/automation-policy.example.json +6 -0
  565. package/templates/co.patchwork-os.bridge.plist +34 -0
  566. package/templates/policies/README.md +72 -0
  567. package/templates/policies/conservative.json +14 -0
  568. package/templates/policies/developer.json +14 -0
  569. package/templates/policies/headless-ci.json +24 -0
  570. package/templates/policies/personal-assistant.json +15 -0
  571. package/templates/policies/regulated-industry.json +18 -0
  572. package/templates/recipes/lint-on-save.yaml +1 -2
  573. package/templates/recipes/morning-brief-slack.yaml +57 -0
  574. package/templates/recipes/morning-brief.yaml +2 -2
  575. package/templates/recipes/project-health-check.yaml +50 -0
  576. package/templates/recipes/webhook/README.md +70 -0
  577. package/templates/recipes/webhook/capture-thought.yaml +26 -0
  578. package/templates/recipes/webhook/customer-escalation.yaml +49 -0
  579. package/templates/recipes/webhook/incident-intake.yaml +46 -0
  580. package/templates/recipes/webhook/meeting-prep.yaml +48 -0
  581. package/templates/recipes/webhook/morning-brief.yaml +57 -0
@@ -1,8 +1,268 @@
1
- import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
2
- import path from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import * as path from "node:path";
5
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
+ import { loadConfig, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
7
+ import { disabledMarkerPath, getConfigDisabledNames, isInstallDirDisabled, } from "./recipes/disabledMarkers.js";
8
+ import { RECIPE_NAME_RE } from "./recipes/names.js";
9
+ import { validateRecipeDefinition } from "./recipes/validation.js";
10
+ /**
11
+ * Returns true unless `filePath` lives inside an install dir whose
12
+ * `.disabled` marker is present. Top-level legacy recipes (direct children
13
+ * of `recipesDir`) are always considered enabled — there's no install dir
14
+ * to put a marker in. Used by every trigger surface (webhook, manual fire,
15
+ * automation) so the marker means the same thing everywhere.
16
+ */
17
+ export function isRecipeFileEnabled(filePath, recipesDir) {
18
+ const rel = path.relative(recipesDir, filePath);
19
+ // Top-level file in recipesDir → no install dir → enabled by default.
20
+ if (rel === "" || rel.startsWith("..") || !rel.includes(path.sep)) {
21
+ return true;
22
+ }
23
+ const installDirName = rel.split(path.sep)[0];
24
+ if (!installDirName)
25
+ return true;
26
+ const installDir = path.join(recipesDir, installDirName);
27
+ return !isInstallDirDisabled(installDir);
28
+ }
29
+ /**
30
+ * Iterate one level of subdirectories under `recipesDir` that look like
31
+ * install dirs (directory containing `recipe.json` or at least one `.yaml`).
32
+ * Skips dirs whose `.disabled` marker is present so callers automatically
33
+ * honor the marker without having to remember.
34
+ *
35
+ * Yields `{ installDir, entrypointPath }` pairs where `entrypointPath` is the
36
+ * file the caller should parse:
37
+ * - `recipe.json`'s `recipes.main` if a manifest exists
38
+ * - otherwise the first `*.yaml` / `*.yml` in the dir
39
+ *
40
+ * Used by webhook + manual-fire path resolvers to find recipes installed
41
+ * via `runRecipeInstall`.
42
+ */
43
+ function* iterateInstallDirs(recipesDir, options = {}) {
44
+ const includeDisabled = options.includeDisabled === true;
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(recipesDir);
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ for (const f of entries) {
53
+ const fullPath = path.join(recipesDir, f);
54
+ let isDir = false;
55
+ try {
56
+ isDir = statSync(fullPath).isDirectory();
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ if (!isDir)
62
+ continue;
63
+ const enabled = !isInstallDirDisabled(fullPath);
64
+ if (!enabled && !includeDisabled)
65
+ continue;
66
+ let entrypoint = null;
67
+ const manifestPath = path.join(fullPath, "recipe.json");
68
+ if (existsSync(manifestPath)) {
69
+ try {
70
+ const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
71
+ if (m.recipes?.main) {
72
+ const candidate = path.join(fullPath, m.recipes.main);
73
+ if (existsSync(candidate))
74
+ entrypoint = candidate;
75
+ }
76
+ }
77
+ catch {
78
+ // malformed manifest — fall through to first-yaml fallback
79
+ }
80
+ }
81
+ if (!entrypoint) {
82
+ try {
83
+ const yaml = readdirSync(fullPath).find((x) => /\.ya?ml$/i.test(x));
84
+ if (yaml)
85
+ entrypoint = path.join(fullPath, yaml);
86
+ }
87
+ catch {
88
+ // unreadable
89
+ }
90
+ }
91
+ if (entrypoint) {
92
+ yield { installDir: fullPath, entrypointPath: entrypoint, enabled };
93
+ }
94
+ }
95
+ }
96
+ /**
97
+ * Locate an install dir by the *recipe name* declared inside its entrypoint
98
+ * (not the directory name). The dashboard reports recipes by the parsed
99
+ * `name` field, while `runRecipeEnable` looks them up by dir name —
100
+ * the two are usually different (`morning-pkg` vs `morning-brief`). Includes
101
+ * disabled dirs so re-enabling actually finds them.
102
+ */
103
+ function findInstallDirByRecipeName(recipesDir, name) {
104
+ for (const { installDir, entrypointPath } of iterateInstallDirs(recipesDir, {
105
+ includeDisabled: true,
106
+ })) {
107
+ try {
108
+ const ext = path.extname(entrypointPath).toLowerCase();
109
+ const raw = readFileSync(entrypointPath, "utf-8");
110
+ const parsed = (ext === ".json" ? JSON.parse(raw) : parseYaml(raw));
111
+ if (parsed.name === name)
112
+ return installDir;
113
+ }
114
+ catch {
115
+ // skip malformed
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * Unified enable/disable for install-dir AND legacy top-level recipes.
122
+ *
123
+ * Routing:
124
+ * 1. Try to find an install dir whose entrypoint declares this `name`.
125
+ * If found, write/remove the `.disabled` marker on that dir. This
126
+ * matches CLI `recipe enable/disable` and the trigger-side
127
+ * enforcement landed in PRs #43 / #49.
128
+ * 2. Otherwise the recipe is a top-level legacy file — fall back to
129
+ * the legacy `cfg.recipes.disabled` config-file array, which the
130
+ * scheduler already honors as a parallel mechanism (it checks both).
131
+ *
132
+ * Replaces the old dashboard-only `setRecipeEnabledFn` that wrote ONLY to
133
+ * the legacy config — which silently did nothing for install-dir recipes.
134
+ */
135
+ export function setRecipeEnabled(name, enabled, options = {}) {
136
+ const recipesDir = options.recipesDir ?? path.join(homedir(), ".patchwork", "recipes");
137
+ try {
138
+ const installDir = findInstallDirByRecipeName(recipesDir, name);
139
+ if (installDir) {
140
+ const markerPath = disabledMarkerPath(installDir);
141
+ if (enabled) {
142
+ if (existsSync(markerPath))
143
+ rmSync(markerPath);
144
+ }
145
+ else {
146
+ writeFileSync(markerPath, "");
147
+ }
148
+ return { ok: true };
149
+ }
150
+ // Legacy top-level path — fall back to config-file disabled list
151
+ const cfg = (options.loadConfigFn ?? loadConfig)();
152
+ const disabled = getConfigDisabledNames(cfg);
153
+ if (enabled)
154
+ disabled.delete(name);
155
+ else
156
+ disabled.add(name);
157
+ const next = {
158
+ ...cfg,
159
+ recipes: {
160
+ ...(cfg.recipes ?? {}),
161
+ disabled: [...disabled],
162
+ },
163
+ };
164
+ if (options.saveConfigFn)
165
+ options.saveConfigFn(next);
166
+ else
167
+ savePatchworkConfig(next);
168
+ return { ok: true };
169
+ }
170
+ catch (err) {
171
+ return {
172
+ ok: false,
173
+ error: err instanceof Error ? err.message : String(err),
174
+ };
175
+ }
176
+ }
177
+ function normalizeRecipeDraftTrigger(trigger) {
178
+ if (trigger.type === "schedule" || trigger.type === "cron") {
179
+ const schedule = typeof trigger.schedule === "string" && trigger.schedule.trim()
180
+ ? trigger.schedule.trim()
181
+ : typeof trigger.cron === "string" && trigger.cron.trim()
182
+ ? trigger.cron.trim()
183
+ : "";
184
+ return {
185
+ type: "cron",
186
+ ...(schedule ? { schedule } : {}),
187
+ };
188
+ }
189
+ if (trigger.type === "webhook") {
190
+ const pathValue = typeof trigger.path === "string" ? trigger.path.trim() : "";
191
+ return {
192
+ type: "webhook",
193
+ ...(pathValue ? { path: pathValue } : {}),
194
+ };
195
+ }
196
+ return { type: "manual" };
197
+ }
198
+ function validateRecipeDraft(draft) {
199
+ if (!draft || typeof draft !== "object") {
200
+ return "Invalid recipe draft";
201
+ }
202
+ if (!draft.trigger || typeof draft.trigger !== "object") {
203
+ return "trigger required";
204
+ }
205
+ if (draft.trigger.type !== "manual" &&
206
+ draft.trigger.type !== "webhook" &&
207
+ draft.trigger.type !== "schedule" &&
208
+ draft.trigger.type !== "cron") {
209
+ return "Invalid trigger type";
210
+ }
211
+ const normalizedTrigger = normalizeRecipeDraftTrigger(draft.trigger);
212
+ if (normalizedTrigger.type === "webhook") {
213
+ if (typeof normalizedTrigger.path !== "string" ||
214
+ !normalizedTrigger.path.startsWith("/")) {
215
+ return "webhook trigger requires a path starting with /";
216
+ }
217
+ }
218
+ if (normalizedTrigger.type === "cron") {
219
+ if (typeof normalizedTrigger.schedule !== "string" ||
220
+ !normalizedTrigger.schedule.trim()) {
221
+ return "cron trigger requires a schedule";
222
+ }
223
+ }
224
+ if (!Array.isArray(draft.steps) || draft.steps.length === 0) {
225
+ return "Recipe must have at least one step";
226
+ }
227
+ const stepIds = new Set();
228
+ for (let i = 0; i < draft.steps.length; i++) {
229
+ const step = draft.steps[i];
230
+ const index = i + 1;
231
+ const id = typeof step?.id === "string" ? step.id.trim() : "";
232
+ if (!id) {
233
+ return `Step ${index} is missing an id`;
234
+ }
235
+ if (stepIds.has(id)) {
236
+ return `Step ${index} has a duplicate id`;
237
+ }
238
+ stepIds.add(id);
239
+ if (typeof step?.prompt !== "string" || !step.prompt.trim()) {
240
+ return `Step ${index} is missing a prompt`;
241
+ }
242
+ }
243
+ if (draft.vars !== undefined) {
244
+ if (!Array.isArray(draft.vars)) {
245
+ return "vars must be an array";
246
+ }
247
+ const varNames = new Set();
248
+ for (let i = 0; i < draft.vars.length; i++) {
249
+ const item = draft.vars[i];
250
+ const index = i + 1;
251
+ const name = typeof item?.name === "string" ? item.name.trim() : "";
252
+ if (!name) {
253
+ return `Variable ${index} is missing a name`;
254
+ }
255
+ if (varNames.has(name)) {
256
+ return `Variable ${index} has a duplicate name`;
257
+ }
258
+ varNames.add(name);
259
+ }
260
+ }
261
+ return null;
262
+ }
3
263
  export function saveRecipe(recipesDir, draft) {
4
264
  const safeName = draft.name.toLowerCase().replace(/\s+/g, "-");
5
- if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(safeName)) {
265
+ if (!RECIPE_NAME_RE.test(safeName)) {
6
266
  return { ok: false, error: "Invalid recipe name" };
7
267
  }
8
268
  const candidate = path.resolve(recipesDir, `${safeName}.json`);
@@ -10,20 +270,46 @@ export function saveRecipe(recipesDir, draft) {
10
270
  if (!candidate.startsWith(base + path.sep)) {
11
271
  return { ok: false, error: "Invalid path" };
12
272
  }
273
+ const validationError = validateRecipeDraft(draft);
274
+ if (validationError) {
275
+ return { ok: false, error: validationError };
276
+ }
13
277
  try {
14
278
  mkdirSync(recipesDir, { recursive: true });
279
+ // Nest `vars` under `trigger.vars` (validator only reads it there;
280
+ // top-level was the same shape bug PR #259 fixed for the YAML path).
281
+ const baseTrigger = normalizeRecipeDraftTrigger(draft.trigger);
282
+ const trigger = draft.vars && draft.vars.length > 0
283
+ ? {
284
+ ...baseTrigger,
285
+ vars: draft.vars.map((item) => ({
286
+ ...item,
287
+ name: item.name.trim(),
288
+ })),
289
+ }
290
+ : baseTrigger;
15
291
  const payload = {
16
292
  name: safeName,
17
293
  description: draft.description,
18
- trigger: draft.trigger,
294
+ trigger,
19
295
  steps: draft.steps.map((s) => ({
20
- id: s.id,
296
+ id: s.id.trim(),
21
297
  agent: s.agent,
22
298
  prompt: s.prompt,
23
299
  })),
24
- ...(draft.vars && draft.vars.length > 0 ? { vars: draft.vars } : {}),
25
300
  createdAt: Date.now(),
26
301
  };
302
+ // Surface the FIRST error from `validateRecipeDefinition` — earlier
303
+ // versions filtered to only "Unknown template reference" issues,
304
+ // which silently bypassed cron validation, var-name regex, and
305
+ // reserved-name shadowing on this legacy JSON path. Anyone scripting
306
+ // against the bridge's `POST /recipes` endpoint was getting much
307
+ // weaker validation than the dashboard's YAML PUT path.
308
+ const deepValidation = validateRecipeDefinition(payload);
309
+ const deepError = deepValidation.issues.find((issue) => issue.level === "error");
310
+ if (deepError) {
311
+ return { ok: false, error: deepError.message };
312
+ }
27
313
  writeFileSync(candidate, JSON.stringify(payload, null, 2), "utf-8");
28
314
  return { ok: true, path: candidate };
29
315
  }
@@ -34,6 +320,426 @@ export function saveRecipe(recipesDir, draft) {
34
320
  };
35
321
  }
36
322
  }
323
+ function resolveJsonRecipePathByName(recipesDir, safeName) {
324
+ const candidate = path.resolve(recipesDir, `${safeName}.json`);
325
+ const base = path.resolve(recipesDir);
326
+ if (!candidate.startsWith(base + path.sep))
327
+ return null;
328
+ if (existsSync(candidate))
329
+ return candidate;
330
+ try {
331
+ for (const entry of readdirSync(recipesDir)) {
332
+ if (!entry.endsWith(".json") || entry.endsWith(".permissions.json")) {
333
+ continue;
334
+ }
335
+ const entryPath = path.join(recipesDir, entry);
336
+ try {
337
+ const entryRaw = readFileSync(entryPath, "utf-8");
338
+ const entryParsed = JSON.parse(entryRaw);
339
+ if (entryParsed.name?.toLowerCase() !== safeName) {
340
+ continue;
341
+ }
342
+ return entryPath;
343
+ }
344
+ catch {
345
+ // skip malformed candidate
346
+ }
347
+ }
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ // Also search install dirs from `recipeInstall`. Skips dirs with
353
+ // `.disabled` marker so the manual-fire / orchestrator path can't
354
+ // resolve a recipe the user has explicitly disabled.
355
+ for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
356
+ if (!entrypointPath.endsWith(".json"))
357
+ continue;
358
+ try {
359
+ const parsed = JSON.parse(readFileSync(entrypointPath, "utf-8"));
360
+ if (parsed.name?.toLowerCase() === safeName) {
361
+ return entrypointPath;
362
+ }
363
+ }
364
+ catch {
365
+ // skip malformed
366
+ }
367
+ }
368
+ return null;
369
+ }
370
+ export function loadRecipeContent(recipesDir, name) {
371
+ const safeName = name.toLowerCase();
372
+ if (!RECIPE_NAME_RE.test(safeName))
373
+ return null;
374
+ const yamlPath = findYamlRecipePath(recipesDir, safeName);
375
+ if (yamlPath) {
376
+ try {
377
+ return {
378
+ content: readFileSync(yamlPath, "utf-8"),
379
+ path: yamlPath,
380
+ };
381
+ }
382
+ catch {
383
+ return null;
384
+ }
385
+ }
386
+ const jsonPath = resolveJsonRecipePathByName(recipesDir, safeName);
387
+ if (!jsonPath) {
388
+ return null;
389
+ }
390
+ try {
391
+ return {
392
+ content: readFileSync(jsonPath, "utf-8"),
393
+ path: jsonPath,
394
+ };
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ }
400
+ export function saveRecipeContent(recipesDir, name, content) {
401
+ const safeName = name.toLowerCase();
402
+ if (!RECIPE_NAME_RE.test(safeName)) {
403
+ return { ok: false, error: "Invalid recipe name" };
404
+ }
405
+ if (!content.trim()) {
406
+ return { ok: false, error: "Recipe content is required" };
407
+ }
408
+ let parsed;
409
+ try {
410
+ parsed = parseYaml(content);
411
+ }
412
+ catch (err) {
413
+ return {
414
+ ok: false,
415
+ error: err instanceof Error ? err.message : String(err),
416
+ };
417
+ }
418
+ const validation = validateRecipeDefinition(parsed);
419
+ const warnings = validation.issues
420
+ .filter((issue) => issue.level === "warning")
421
+ .map((issue) => issue.message);
422
+ const validationError = validation.issues.find((issue) => issue.level === "error");
423
+ if (validationError) {
424
+ return {
425
+ ok: false,
426
+ error: validationError.message,
427
+ ...(warnings.length > 0 ? { warnings } : {}),
428
+ };
429
+ }
430
+ // If the parsed body's `name:` field disagrees with the filename
431
+ // (e.g. caller PUT to /recipes/myrecipe with a body whose `name:` is
432
+ // `MyRecipe`), rewrite it to match. The filename is the source of
433
+ // truth for routing, dashboard list keys, and webhook resolution;
434
+ // body drift just causes silent confusion.
435
+ //
436
+ // Earlier versions used a `^name:\s*.+$/m` text replace, but that:
437
+ // (a) only handled the FIRST top-level `name:` (YAML duplicate keys
438
+ // — which the parser resolves to the LAST — would survive in
439
+ // the file even after rewrite), and
440
+ // (b) didn't recognize quoted forms like `name: "MyRecipe"` cleanly
441
+ // across all whitespace shapes.
442
+ // Parse → mutate → stringify is robust against both: the YAML
443
+ // serializer normalizes the entire document, so any duplicate/quoted
444
+ // name disappears.
445
+ let normalizedContent = content;
446
+ if (parsed &&
447
+ typeof parsed === "object" &&
448
+ !Array.isArray(parsed) &&
449
+ typeof parsed.name === "string" &&
450
+ parsed.name !== safeName) {
451
+ const oldName = parsed.name;
452
+ const recipe = { ...parsed, name: safeName };
453
+ try {
454
+ normalizedContent = stringifyYaml(recipe);
455
+ warnings.push(`Recipe body name "${oldName}" was rewritten to "${safeName}" to match the filename.`);
456
+ }
457
+ catch {
458
+ // Re-stringify failed (e.g. cyclic structure); fall back to the
459
+ // text replace so save still succeeds. Safe because the parse
460
+ // above already validated the document.
461
+ normalizedContent = content.replace(/^name:\s*.+$/m, `name: ${safeName}`);
462
+ warnings.push(`Recipe body name "${oldName}" was rewritten to "${safeName}" to match the filename (text-replace fallback).`);
463
+ }
464
+ }
465
+ try {
466
+ mkdirSync(recipesDir, { recursive: true });
467
+ const base = path.resolve(recipesDir);
468
+ const candidate = findYamlRecipePath(recipesDir, safeName) ??
469
+ path.resolve(recipesDir, `${safeName}.yaml`);
470
+ if (!candidate.startsWith(base + path.sep)) {
471
+ return { ok: false, error: "Invalid path" };
472
+ }
473
+ writeFileSync(candidate, normalizedContent.endsWith("\n")
474
+ ? normalizedContent
475
+ : `${normalizedContent}\n`, "utf-8");
476
+ return {
477
+ ok: true,
478
+ path: candidate,
479
+ ...(warnings.length > 0 ? { warnings } : {}),
480
+ };
481
+ }
482
+ catch (err) {
483
+ return {
484
+ ok: false,
485
+ error: err instanceof Error ? err.message : String(err),
486
+ };
487
+ }
488
+ }
489
+ /**
490
+ * Deletes a recipe file (yaml/yml or json) plus any sidecar permissions file.
491
+ * Returns ok=false with a 404-style error when the recipe cannot be located.
492
+ */
493
+ export function deleteRecipeContent(recipesDir, name) {
494
+ const safeName = name.toLowerCase();
495
+ if (!RECIPE_NAME_RE.test(safeName)) {
496
+ return { ok: false, error: "Invalid recipe name" };
497
+ }
498
+ const base = path.resolve(recipesDir);
499
+ const target = findYamlRecipePath(recipesDir, safeName) ??
500
+ resolveJsonRecipePathByName(recipesDir, safeName);
501
+ if (!target) {
502
+ return { ok: false, error: "Recipe not found" };
503
+ }
504
+ const resolved = path.resolve(target);
505
+ if (!resolved.startsWith(base + path.sep)) {
506
+ return { ok: false, error: "Invalid path" };
507
+ }
508
+ try {
509
+ rmSync(resolved, { force: true });
510
+ const sidecar = `${resolved}.permissions.json`;
511
+ if (existsSync(sidecar)) {
512
+ try {
513
+ rmSync(sidecar, { force: true });
514
+ }
515
+ catch {
516
+ // sidecar removal best-effort
517
+ }
518
+ }
519
+ return { ok: true, path: resolved };
520
+ }
521
+ catch (err) {
522
+ return {
523
+ ok: false,
524
+ error: err instanceof Error ? err.message : String(err),
525
+ };
526
+ }
527
+ }
528
+ /**
529
+ * Duplicate a recipe as a variant. Copies the source YAML, rewrites the
530
+ * `name:` field to `<original>-v<N>` (first available suffix), and writes
531
+ * the copy to disk. Returns the new variant name and path on success.
532
+ *
533
+ * The variant name follows the same validation rules as recipe names.
534
+ * Suffixes v2..v9 are tried before returning an error.
535
+ */
536
+ export function duplicateRecipe(recipesDir, sourceName) {
537
+ const safeName = sourceName.toLowerCase();
538
+ if (!RECIPE_NAME_RE.test(safeName)) {
539
+ return { ok: false, error: "Invalid recipe name" };
540
+ }
541
+ const source = loadRecipeContent(recipesDir, safeName);
542
+ if (!source) {
543
+ return { ok: false, error: "Recipe not found" };
544
+ }
545
+ if (!/\.ya?ml$/i.test(source.path)) {
546
+ return {
547
+ ok: false,
548
+ error: "Recipe variants are only supported for YAML recipes",
549
+ };
550
+ }
551
+ if (!/^name:\s*.+$/m.test(source.content)) {
552
+ return {
553
+ ok: false,
554
+ error: "Source recipe is missing a top-level 'name:' field",
555
+ };
556
+ }
557
+ // Determine next available variant name: strip any existing -vN suffix,
558
+ // then try -v2 through -v9.
559
+ const base = safeName.replace(/-v\d+$/, "");
560
+ let variantName = null;
561
+ for (let n = 2; n <= 9; n++) {
562
+ const candidate = `${base}-v${n}`;
563
+ if (!findYamlRecipePath(recipesDir, candidate)) {
564
+ variantName = candidate;
565
+ break;
566
+ }
567
+ }
568
+ if (!variantName) {
569
+ return {
570
+ ok: false,
571
+ error: "Too many variants already exist (v2–v9 taken)",
572
+ };
573
+ }
574
+ // Rewrite the name: field in the YAML. Simple line-by-line replacement
575
+ // is safe here: the name field is always a scalar on its own line.
576
+ const newContent = source.content.replace(/^name:\s*.+$/m, `name: ${variantName}`);
577
+ const saveResult = saveRecipeContent(recipesDir, variantName, newContent);
578
+ if (!saveResult.ok) {
579
+ return { ok: false, error: saveResult.error };
580
+ }
581
+ return { ok: true, variantName, path: saveResult.path };
582
+ }
583
+ /**
584
+ * Promote a variant recipe to become the canonical name.
585
+ *
586
+ * Steps:
587
+ * 1. Load the variant's YAML.
588
+ * 2. Rewrite its `name:` field to `targetName`.
589
+ * 3. Save under `targetName` (overwrites any existing file at that name).
590
+ * 4. Delete the variant file so only one copy exists.
591
+ *
592
+ * The caller supplies `variantName` (e.g. "morning-brief-v2") and
593
+ * `targetName` (e.g. "morning-brief"). Both must pass the recipe name
594
+ * validation regex. Returns `{ ok, path }` on success.
595
+ */
596
+ export async function promoteRecipeVariant(recipesDir, variantName, targetName, options) {
597
+ const safeVariant = variantName.toLowerCase();
598
+ const safeTarget = targetName.toLowerCase();
599
+ if (!RECIPE_NAME_RE.test(safeVariant) || !RECIPE_NAME_RE.test(safeTarget)) {
600
+ return { ok: false, error: "Invalid recipe name" };
601
+ }
602
+ if (safeVariant === safeTarget) {
603
+ return { ok: false, error: "Variant and target names must differ" };
604
+ }
605
+ const source = loadRecipeContent(recipesDir, safeVariant);
606
+ if (!source) {
607
+ return { ok: false, error: "Variant recipe not found" };
608
+ }
609
+ if (!/\.ya?ml$/i.test(source.path)) {
610
+ return {
611
+ ok: false,
612
+ error: "Recipe variants are only supported for YAML recipes",
613
+ };
614
+ }
615
+ if (!/^name:\s*.+$/m.test(source.content)) {
616
+ return {
617
+ ok: false,
618
+ error: "Variant recipe is missing a top-level 'name:' field",
619
+ };
620
+ }
621
+ // Guard against silent overwrites: if the target already exists the caller
622
+ // must pass force:true. We also capture the prior content hash for audit.
623
+ const existing = loadRecipeContent(recipesDir, safeTarget);
624
+ if (existing && !options?.force) {
625
+ return {
626
+ ok: false,
627
+ targetExists: true,
628
+ error: `Recipe "${safeTarget}" already exists. Pass force:true to overwrite.`,
629
+ };
630
+ }
631
+ // Write audit log entry before the overwrite so the replaced content is
632
+ // traceable even if the variant file is deleted in the next step.
633
+ if (existing) {
634
+ try {
635
+ const priorHash = createHash("sha256")
636
+ .update(existing.content)
637
+ .digest("hex");
638
+ const auditPath = existing.path.replace(/\.ya?ml$/, ".promote-audit.json");
639
+ writeFileSync(auditPath, JSON.stringify({
640
+ ts: new Date().toISOString(),
641
+ action: "promote_overwrite",
642
+ variantName: safeVariant,
643
+ targetName: safeTarget,
644
+ priorContentHash: priorHash,
645
+ priorContentPath: existing.path,
646
+ }, null, 2), "utf-8");
647
+ }
648
+ catch {
649
+ // Audit log failure must not block the promote — log and continue.
650
+ }
651
+ }
652
+ const newContent = source.content.replace(/^name:\s*.+$/m, `name: ${safeTarget}`);
653
+ const saveResult = saveRecipeContent(recipesDir, safeTarget, newContent);
654
+ if (!saveResult.ok) {
655
+ return { ok: false, error: saveResult.error };
656
+ }
657
+ // Delete the variant file — best-effort; don't fail the promote if cleanup fails.
658
+ deleteRecipeContent(recipesDir, safeVariant);
659
+ return { ok: true, path: saveResult.path };
660
+ }
661
+ /**
662
+ * Lints raw YAML/JSON recipe content without writing to disk. Used by the
663
+ * dashboard edit UI to surface validateRecipeDefinition warnings live, in
664
+ * addition to the warnings returned by saveRecipeContent on save.
665
+ */
666
+ export function lintRecipeContent(content) {
667
+ if (!content.trim()) {
668
+ return { ok: false, errors: ["Recipe content is required"], warnings: [] };
669
+ }
670
+ let parsed;
671
+ try {
672
+ parsed = parseYaml(content);
673
+ }
674
+ catch (err) {
675
+ return {
676
+ ok: false,
677
+ errors: [err instanceof Error ? err.message : String(err)],
678
+ warnings: [],
679
+ };
680
+ }
681
+ const validation = validateRecipeDefinition(parsed);
682
+ const errors = [];
683
+ const warnings = [];
684
+ for (const issue of validation.issues) {
685
+ if (issue.level === "error")
686
+ errors.push(issue.message);
687
+ else
688
+ warnings.push(issue.message);
689
+ }
690
+ return { ok: errors.length === 0, errors, warnings };
691
+ }
692
+ // ---------------------------------------------------------------------------
693
+ // Recipe trust levels
694
+ // ---------------------------------------------------------------------------
695
+ export const TRUST_LEVELS = [
696
+ "draft",
697
+ "manual_run",
698
+ "ask_every_time",
699
+ "ask_novel",
700
+ "mostly_trusted",
701
+ "fully_trusted",
702
+ ];
703
+ const TRUST_LEVELS_FILE = "trust_levels.json";
704
+ function trustLevelsPath(recipesDir) {
705
+ return path.join(recipesDir, TRUST_LEVELS_FILE);
706
+ }
707
+ function loadTrustLevels(recipesDir) {
708
+ const p = trustLevelsPath(recipesDir);
709
+ try {
710
+ const raw = readFileSync(p, "utf-8");
711
+ return JSON.parse(raw);
712
+ }
713
+ catch {
714
+ return {};
715
+ }
716
+ }
717
+ function saveTrustLevels(recipesDir, levels) {
718
+ const p = trustLevelsPath(recipesDir);
719
+ mkdirSync(recipesDir, { recursive: true });
720
+ writeFileSync(p, JSON.stringify(levels, null, 2), "utf-8");
721
+ }
722
+ export function getTrustLevel(recipesDir, name) {
723
+ const levels = loadTrustLevels(recipesDir);
724
+ return levels[name] ?? "draft";
725
+ }
726
+ export function setTrustLevel(recipesDir, name, level) {
727
+ if (!TRUST_LEVELS.includes(level)) {
728
+ return { ok: false, error: `Invalid trust level: ${level}` };
729
+ }
730
+ try {
731
+ const levels = loadTrustLevels(recipesDir);
732
+ levels[name] = level;
733
+ saveTrustLevels(recipesDir, levels);
734
+ return { ok: true };
735
+ }
736
+ catch (err) {
737
+ return {
738
+ ok: false,
739
+ error: err instanceof Error ? err.message : String(err),
740
+ };
741
+ }
742
+ }
37
743
  export function listInstalledRecipes(recipesDir) {
38
744
  let entries;
39
745
  try {
@@ -42,24 +748,20 @@ export function listInstalledRecipes(recipesDir) {
42
748
  catch {
43
749
  return { recipesDir, recipes: [] };
44
750
  }
751
+ const cfg = loadConfig();
752
+ const disabledSet = new Set(cfg.recipes?.disabled ?? []);
753
+ const trustLevels = loadTrustLevels(recipesDir);
45
754
  const recipes = [];
46
755
  for (const f of entries) {
47
- if (!f.endsWith(".json") || f.endsWith(".permissions.json"))
756
+ const isYaml = f.endsWith(".yaml") || f.endsWith(".yml");
757
+ const isJson = f.endsWith(".json") && !f.endsWith(".permissions.json");
758
+ if (!isYaml && !isJson)
48
759
  continue;
49
760
  const fullPath = path.join(recipesDir, f);
50
761
  try {
51
762
  const raw = readFileSync(fullPath, "utf-8");
52
- const parsed = JSON.parse(raw);
763
+ const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
53
764
  const stat = statSync(fullPath);
54
- const permsPath = `${fullPath}.permissions.json`;
55
- let hasPermissions = false;
56
- try {
57
- statSync(permsPath);
58
- hasPermissions = true;
59
- }
60
- catch {
61
- // no permissions sidecar
62
- }
63
765
  const resolvedRecipesDir = path.resolve(recipesDir);
64
766
  let source;
65
767
  if (fullPath.startsWith(resolvedRecipesDir + path.sep) ||
@@ -72,27 +774,205 @@ export function listInstalledRecipes(recipesDir) {
72
774
  else {
73
775
  source = "unknown";
74
776
  }
777
+ const ext = isYaml ? (f.endsWith(".yml") ? ".yml" : ".yaml") : ".json";
778
+ const parsedName = parsed.name ?? path.basename(f, ext);
779
+ const lintRes = validateRecipeDefinition(parsed);
780
+ let errCount = 0;
781
+ let warnCount = 0;
782
+ let firstError;
783
+ for (const issue of lintRes.issues) {
784
+ if (issue.level === "error") {
785
+ errCount++;
786
+ if (!firstError)
787
+ firstError = issue.message;
788
+ }
789
+ else {
790
+ warnCount++;
791
+ }
792
+ }
793
+ const webhookPath = parsed.trigger?.type === "webhook" &&
794
+ typeof parsed.trigger?.path === "string"
795
+ ? parsed.trigger.path
796
+ : undefined;
75
797
  recipes.push({
76
- name: parsed.name ?? path.basename(f, ".json"),
798
+ name: parsedName,
77
799
  description: parsed.description,
78
800
  trigger: parsed.trigger?.type,
801
+ ...(webhookPath ? { webhookPath } : {}),
79
802
  stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
80
803
  path: fullPath,
81
804
  installedAt: stat.mtimeMs,
82
- hasPermissions,
83
805
  source,
806
+ // Top-level legacy recipes don't have install dirs to put a marker
807
+ // in, so the `enabled` field still comes from the legacy config list.
808
+ enabled: !disabledSet.has(parsedName),
809
+ trustLevel: (trustLevels[parsedName] ?? "draft"),
84
810
  ...(Array.isArray(parsed.vars) && parsed.vars.length > 0
85
811
  ? { vars: parsed.vars }
86
812
  : {}),
813
+ lint: {
814
+ ok: errCount === 0,
815
+ errorCount: errCount,
816
+ warningCount: warnCount,
817
+ ...(firstError ? { firstError } : {}),
818
+ },
87
819
  });
88
820
  }
89
821
  catch {
90
822
  // skip malformed recipe file
91
823
  }
92
824
  }
825
+ // Second pass — recipes installed via `runRecipeInstall` into subdirs.
826
+ // `enabled` reflects the per-install `.disabled` marker; the legacy
827
+ // config disabled list is a top-level concern (we still apply it as a
828
+ // safety belt in case a name collides).
829
+ for (const { installDir, entrypointPath, enabled: installEnabled, } of iterateInstallDirs(recipesDir, { includeDisabled: true })) {
830
+ try {
831
+ const ext = path.extname(entrypointPath).toLowerCase();
832
+ const isYaml = ext === ".yaml" || ext === ".yml";
833
+ const isJson = ext === ".json";
834
+ if (!isYaml && !isJson)
835
+ continue;
836
+ const raw = readFileSync(entrypointPath, "utf-8");
837
+ const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
838
+ const stat = statSync(entrypointPath);
839
+ const parsedName = parsed.name ??
840
+ path.basename(entrypointPath, path.extname(entrypointPath));
841
+ const lintRes = validateRecipeDefinition(parsed);
842
+ let errCount = 0;
843
+ let warnCount = 0;
844
+ let firstError;
845
+ for (const issue of lintRes.issues) {
846
+ if (issue.level === "error") {
847
+ errCount++;
848
+ if (!firstError)
849
+ firstError = issue.message;
850
+ }
851
+ else {
852
+ warnCount++;
853
+ }
854
+ }
855
+ const webhookPath = parsed.trigger?.type === "webhook" &&
856
+ typeof parsed.trigger?.path === "string"
857
+ ? parsed.trigger.path
858
+ : undefined;
859
+ recipes.push({
860
+ name: parsedName,
861
+ description: parsed.description,
862
+ trigger: parsed.trigger?.type,
863
+ ...(webhookPath ? { webhookPath } : {}),
864
+ stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
865
+ path: entrypointPath,
866
+ installedAt: stat.mtimeMs,
867
+ source: "user",
868
+ // Disabled if EITHER the install marker is set OR the legacy config
869
+ // names this recipe — defence-in-depth so a stale config entry can't
870
+ // accidentally re-enable a recipe the user explicitly disabled, and
871
+ // the dashboard can't accidentally enable one disabled by an admin
872
+ // through the legacy file.
873
+ enabled: installEnabled && !disabledSet.has(parsedName),
874
+ trustLevel: (trustLevels[parsedName] ?? "draft"),
875
+ ...(Array.isArray(parsed.vars) && parsed.vars.length > 0
876
+ ? { vars: parsed.vars }
877
+ : {}),
878
+ lint: {
879
+ ok: errCount === 0,
880
+ errorCount: errCount,
881
+ warningCount: warnCount,
882
+ ...(firstError ? { firstError } : {}),
883
+ },
884
+ });
885
+ void installDir;
886
+ }
887
+ catch {
888
+ // skip malformed install dir
889
+ }
890
+ }
93
891
  recipes.sort((a, b) => a.name.localeCompare(b.name));
94
892
  return { recipesDir, recipes };
95
893
  }
894
+ /**
895
+ * Thrown by `findYamlRecipePath` (and listing/lint paths that surface this)
896
+ * when more than one enabled YAML recipe declares the same `name`. Callers
897
+ * must surface this loudly rather than silently picking the first match —
898
+ * dashboard run buttons, scheduler fires, and webhook resolution would all
899
+ * be ambiguous otherwise.
900
+ */
901
+ export class RecipeNameConflictError extends Error {
902
+ recipeName;
903
+ paths;
904
+ constructor(recipeName, paths) {
905
+ super(`Multiple YAML recipes declare name "${recipeName}": ${paths
906
+ .map((p) => path.basename(p))
907
+ .join(", ")}`);
908
+ this.name = "RecipeNameConflictError";
909
+ this.recipeName = recipeName;
910
+ this.paths = paths;
911
+ }
912
+ }
913
+ export function findYamlRecipePath(recipesDir, name) {
914
+ const safeName = name.toLowerCase();
915
+ if (!RECIPE_NAME_RE.test(safeName))
916
+ return null;
917
+ const base = path.resolve(recipesDir);
918
+ const matches = new Set();
919
+ // Exact-filename matches (top-level legacy layout). The parsed `name`
920
+ // field is allowed to differ from the filename, so we still scan below.
921
+ for (const ext of [".yaml", ".yml"]) {
922
+ const candidate = path.resolve(recipesDir, `${safeName}${ext}`);
923
+ if (!candidate.startsWith(base + path.sep))
924
+ return null;
925
+ if (existsSync(candidate))
926
+ matches.add(candidate);
927
+ }
928
+ let entries = [];
929
+ try {
930
+ entries = readdirSync(recipesDir);
931
+ }
932
+ catch {
933
+ // recipesDir missing — fall through; matches may still be empty
934
+ }
935
+ for (const entry of entries) {
936
+ if (!entry.endsWith(".yaml") && !entry.endsWith(".yml"))
937
+ continue;
938
+ const entryPath = path.join(recipesDir, entry);
939
+ if (matches.has(entryPath))
940
+ continue;
941
+ try {
942
+ const entryParsed = parseYaml(readFileSync(entryPath, "utf-8"));
943
+ if (entryParsed.name?.toLowerCase() === safeName) {
944
+ matches.add(entryPath);
945
+ }
946
+ }
947
+ catch {
948
+ // skip malformed candidate
949
+ }
950
+ }
951
+ // Install dirs from `recipeInstall`. iterateInstallDirs skips dirs with
952
+ // `.disabled` marker so the manual-fire / orchestrator path can't
953
+ // resolve a recipe the user has explicitly disabled.
954
+ for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
955
+ if (!/\.ya?ml$/i.test(entrypointPath))
956
+ continue;
957
+ if (matches.has(entrypointPath))
958
+ continue;
959
+ try {
960
+ const parsed = parseYaml(readFileSync(entrypointPath, "utf-8"));
961
+ if (parsed.name?.toLowerCase() === safeName) {
962
+ matches.add(entrypointPath);
963
+ }
964
+ }
965
+ catch {
966
+ // skip malformed
967
+ }
968
+ }
969
+ if (matches.size === 0)
970
+ return null;
971
+ if (matches.size > 1) {
972
+ throw new RecipeNameConflictError(safeName, [...matches].sort());
973
+ }
974
+ return [...matches][0] ?? null;
975
+ }
96
976
  /**
97
977
  * Scan recipes and return the first webhook-triggered recipe whose
98
978
  * trigger.path matches the requested path. Returns null on miss.
@@ -106,18 +986,50 @@ export function findWebhookRecipe(recipesDir, requestPath) {
106
986
  catch {
107
987
  return null;
108
988
  }
989
+ // Pass 1 — top-level files (legacy)
109
990
  for (const f of entries) {
110
- if (!f.endsWith(".json") || f.endsWith(".permissions.json"))
991
+ const isYaml = f.endsWith(".yaml") || f.endsWith(".yml");
992
+ const isJson = f.endsWith(".json") && !f.endsWith(".permissions.json");
993
+ if (!isYaml && !isJson)
111
994
  continue;
112
995
  try {
113
- const raw = readFileSync(path.join(recipesDir, f), "utf-8");
114
- const parsed = JSON.parse(raw);
996
+ const filePath = path.join(recipesDir, f);
997
+ const raw = readFileSync(filePath, "utf-8");
998
+ const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
115
999
  if (parsed.trigger?.type !== "webhook")
116
1000
  continue;
117
1001
  if (parsed.trigger.path === requestPath) {
118
1002
  return {
119
- name: parsed.name ?? path.basename(f, ".json"),
1003
+ name: parsed.name ?? path.basename(f, path.extname(f)),
120
1004
  path: requestPath,
1005
+ filePath,
1006
+ format: isYaml ? "yaml" : "json",
1007
+ };
1008
+ }
1009
+ }
1010
+ catch {
1011
+ // skip malformed
1012
+ }
1013
+ }
1014
+ // Pass 2 — install dirs (skips dirs marked .disabled).
1015
+ for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
1016
+ const ext = path.extname(entrypointPath).toLowerCase();
1017
+ const isYaml = ext === ".yaml" || ext === ".yml";
1018
+ const isJson = ext === ".json";
1019
+ if (!isYaml && !isJson)
1020
+ continue;
1021
+ try {
1022
+ const raw = readFileSync(entrypointPath, "utf-8");
1023
+ const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
1024
+ if (parsed.trigger?.type !== "webhook")
1025
+ continue;
1026
+ if (parsed.trigger.path === requestPath) {
1027
+ return {
1028
+ name: parsed.name ??
1029
+ path.basename(entrypointPath, path.extname(entrypointPath)),
1030
+ path: requestPath,
1031
+ filePath: entrypointPath,
1032
+ format: isYaml ? "yaml" : "json",
121
1033
  };
122
1034
  }
123
1035
  }
@@ -134,15 +1046,15 @@ export function findWebhookRecipe(recipesDir, requestPath) {
134
1046
  */
135
1047
  export function loadRecipePrompt(recipesDir, name) {
136
1048
  const safeName = name.toLowerCase();
137
- if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(safeName))
1049
+ if (!RECIPE_NAME_RE.test(safeName))
138
1050
  return null;
139
- const candidate = path.resolve(recipesDir, `${safeName}.json`);
140
- const base = path.resolve(recipesDir);
141
- if (!candidate.startsWith(base + path.sep))
1051
+ const recipePath = resolveJsonRecipePathByName(recipesDir, safeName);
1052
+ if (!recipePath) {
142
1053
  return null;
1054
+ }
143
1055
  let raw;
144
1056
  try {
145
- raw = readFileSync(candidate, "utf-8");
1057
+ raw = readFileSync(recipePath, "utf-8");
146
1058
  }
147
1059
  catch {
148
1060
  return null;
@@ -166,7 +1078,7 @@ export function loadRecipePrompt(recipesDir, name) {
166
1078
  }
167
1079
  }
168
1080
  lines.push("\nWhen finished, print a one-line summary prefixed with 'RECIPE DONE:'.");
169
- return { prompt: lines.join("\n"), path: candidate };
1081
+ return { prompt: lines.join("\n"), path: recipePath };
170
1082
  }
171
1083
  /**
172
1084
  * Append a webhook payload to a base prompt so the agent can reference