patchwork-os 0.2.0-alpha.9 → 0.2.0-beta.1

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