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
@@ -24,17 +24,110 @@ import { spawnSync } from "node:child_process";
24
24
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
27
28
  import { parse as parseYaml } from "yaml";
29
+ import { captureFixture } from "../connectors/fixtureRecorder.js";
30
+ import { loadConfig as loadPatchworkConfigSync } from "../patchworkConfig.js";
31
+ import { findYamlRecipePath } from "../recipesHttp.js";
32
+ import { executeAgent as _executeAgent, } from "./agentExecutor.js";
33
+ import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./legacyRecipeCompat.js";
34
+ import { resolveRecipePath } from "./resolveRecipePath.js";
35
+ import { detectSilentFail } from "./stepObservation.js";
36
+ // Import tool registry and trigger tool self-registration
37
+ import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
38
+ import "./tools/index.js";
39
+ /**
40
+ * Bundled-templates directory used as a third allowed root for nested-recipe
41
+ * lookups (`recipe:` references with explicit paths). Resolved once at module
42
+ * load — `__dirname` equivalent points at `dist/recipes/` in the npm tarball
43
+ * (or `src/recipes/` in dev) so the relative `../../templates/recipes` lifts
44
+ * out of the source tree to the package root regardless of build layout.
45
+ *
46
+ * See dogfood A-PR2 / R2 M-5 — the third jail root is captured here, not at
47
+ * call time, so a runtime CWD change cannot relocate it.
48
+ */
49
+ const BUNDLED_TEMPLATES_DIR = (() => {
50
+ const here = path.dirname(fileURLToPath(import.meta.url));
51
+ // dist/recipes/yamlRunner.js → ../../templates/recipes
52
+ // src/recipes/yamlRunner.ts → ../../templates/recipes
53
+ return path.resolve(here, "..", "..", "templates", "recipes");
54
+ })();
55
+ export function evaluateExpect(result, expect) {
56
+ const failures = [];
57
+ if (expect.stepsRun !== undefined && result.stepsRun !== expect.stepsRun) {
58
+ failures.push({
59
+ assertion: "stepsRun",
60
+ expected: expect.stepsRun,
61
+ actual: result.stepsRun,
62
+ message: `Expected stepsRun=${expect.stepsRun}, got ${result.stepsRun}`,
63
+ });
64
+ }
65
+ if (expect.errorMessage !== undefined) {
66
+ const expected = expect.errorMessage ?? null;
67
+ const actual = result.errorMessage ?? null;
68
+ if (expected !== actual) {
69
+ failures.push({
70
+ assertion: "errorMessage",
71
+ expected,
72
+ actual,
73
+ message: expected === null
74
+ ? `Expected clean run (no error), got: ${actual}`
75
+ : `Expected error "${expected}", got: ${actual === null ? "(none)" : actual}`,
76
+ });
77
+ }
78
+ }
79
+ if (expect.outputs !== undefined) {
80
+ for (const key of expect.outputs) {
81
+ if (!result.outputs.includes(key)) {
82
+ failures.push({
83
+ assertion: "outputs",
84
+ expected: key,
85
+ actual: result.outputs,
86
+ message: `Expected output key "${key}" not found in [${result.outputs.join(", ")}]`,
87
+ });
88
+ }
89
+ }
90
+ }
91
+ if (expect.context !== undefined) {
92
+ for (const [key, expectedVal] of Object.entries(expect.context)) {
93
+ const actual = result.context[key];
94
+ if (actual === undefined) {
95
+ failures.push({
96
+ assertion: `context.${key}`,
97
+ expected: expectedVal,
98
+ actual: undefined,
99
+ message: `Expected context key "${key}" to equal "${expectedVal}", but key is missing`,
100
+ });
101
+ }
102
+ else if (!actual.includes(expectedVal)) {
103
+ failures.push({
104
+ assertion: `context.${key}`,
105
+ expected: expectedVal,
106
+ actual,
107
+ message: `Expected context["${key}"] to contain "${expectedVal}", got "${actual}"`,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ return failures;
113
+ }
114
+ // Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
115
+ function stripLeadingNarration(text) {
116
+ const lines = text.split("\n");
117
+ const firstMarkdown = lines.findIndex((l) => /^(#|>|`|\||[-*+] |\d+\. |\*\*)/.test(l.trimStart()));
118
+ return firstMarkdown > 0 ? lines.slice(firstMarkdown).join("\n") : text;
119
+ }
28
120
  export function loadYamlRecipe(filePath) {
29
121
  const text = readFileSync(filePath, "utf-8");
30
122
  const raw = parseYaml(text);
31
123
  return validateYamlRecipe(raw);
32
124
  }
33
125
  export function validateYamlRecipe(raw) {
34
- if (typeof raw !== "object" || raw === null) {
126
+ const normalized = normalizeRecipeForRuntime(raw, defaultDeprecationWarn);
127
+ if (typeof normalized !== "object" || normalized === null) {
35
128
  throw new Error("recipe must be an object");
36
129
  }
37
- const r = raw;
130
+ const r = normalized;
38
131
  if (typeof r.name !== "string" || !r.name) {
39
132
  throw new Error("recipe.name required");
40
133
  }
@@ -44,126 +137,711 @@ export function validateYamlRecipe(raw) {
44
137
  if (!Array.isArray(r.steps) || r.steps.length === 0) {
45
138
  throw new Error("recipe.steps must be a non-empty array");
46
139
  }
140
+ if (r.servers !== undefined &&
141
+ (!Array.isArray(r.servers) ||
142
+ r.servers.some((s) => typeof s !== "string"))) {
143
+ throw new Error("recipe.servers must be an array of strings if present");
144
+ }
47
145
  return r;
48
146
  }
147
+ /** Track already-loaded plugin specs to avoid double-loading within a process. */
148
+ const loadedPluginSpecs = new Set();
149
+ /**
150
+ * Load plugin specs declared in `recipe.servers` and register their tools into
151
+ * the recipe tool registry. Errors per-spec are logged as warnings — never fatal.
152
+ */
153
+ export async function loadRecipeServers(specs) {
154
+ const toLoad = specs.filter((s) => !loadedPluginSpecs.has(s));
155
+ if (toLoad.length === 0)
156
+ return;
157
+ let loadPluginsFull;
158
+ try {
159
+ ({ loadPluginsFull } = await import("../pluginLoader.js"));
160
+ }
161
+ catch (err) {
162
+ console.warn(`[recipe servers] failed to import pluginLoader: ${err instanceof Error ? err.message : String(err)}`);
163
+ return;
164
+ }
165
+ const minimalConfig = {
166
+ workspace: process.cwd(),
167
+ workspaceFolders: [process.cwd()],
168
+ commandTimeout: 30_000,
169
+ maxResultSize: 1_048_576,
170
+ };
171
+ const minimalLogger = {
172
+ info: (msg) => console.info(`[recipe servers] ${msg}`),
173
+ warn: (msg) => console.warn(`[recipe servers] ${msg}`),
174
+ error: (msg) => console.error(`[recipe servers] ${msg}`),
175
+ debug: (_msg) => { },
176
+ };
177
+ for (const spec of toLoad) {
178
+ try {
179
+ const loaded = await loadPluginsFull([spec], minimalConfig, minimalLogger);
180
+ let toolCount = 0;
181
+ for (const plugin of loaded) {
182
+ const pluginTools = plugin.tools.map((t) => ({
183
+ name: t.schema.name,
184
+ handler: t.handler,
185
+ schema: t.schema,
186
+ }));
187
+ toolCount += registerPluginTools(pluginTools);
188
+ }
189
+ loadedPluginSpecs.add(spec);
190
+ if (toolCount > 0) {
191
+ console.info(`[recipe servers] loaded "${spec}" — ${toolCount} tool(s) registered`);
192
+ }
193
+ }
194
+ catch (err) {
195
+ console.warn(`[recipe servers] failed to load "${spec}": ${err instanceof Error ? err.message : String(err)}`);
196
+ }
197
+ }
198
+ }
49
199
  export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
200
+ if (recipe.servers?.length) {
201
+ await loadRecipeServers(recipe.servers);
202
+ }
50
203
  const now = deps.now ? deps.now() : new Date();
204
+ // Resolve recipe-level context blocks (type: env) into seed context
205
+ const envCtx = {};
206
+ if (Array.isArray(recipe.context)) {
207
+ for (const block of recipe
208
+ .context ?? []) {
209
+ const b = block;
210
+ if (b.type === "env" && Array.isArray(b.keys)) {
211
+ for (const key of b.keys) {
212
+ const v = process.env[key];
213
+ if (v !== undefined)
214
+ envCtx[key] = v;
215
+ }
216
+ }
217
+ }
218
+ }
51
219
  const ctx = {
52
220
  date: now.toISOString().slice(0, 10),
53
221
  time: now.toTimeString().slice(0, 5),
222
+ ...envCtx,
54
223
  ...seedContext,
55
224
  };
56
- const readFile = deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8"));
57
- const writeFile = deps.writeFile ??
58
- ((p, content) => {
59
- const abs = expandHome(p);
60
- mkdirSync(path.dirname(abs), { recursive: true });
61
- writeFileSync(abs, content);
62
- });
63
- const appendFile = deps.appendFile ??
64
- ((p, content) => {
65
- const abs = expandHome(p);
66
- mkdirSync(path.dirname(abs), { recursive: true });
67
- appendFileSync(abs, content);
68
- });
69
- const mkdir = deps.mkdir ??
70
- ((p) => mkdirSync(expandHome(p), { recursive: true }));
225
+ const stepDeps = resolveStepDeps(deps);
226
+ // Open a `running`-state run-log entry so the dashboard sees the recipe
227
+ // as in flight. Only when a long-lived `runLog` is provided (bridge path);
228
+ // CLI runs fall back to `appendDirect` at end via the existing logDir
229
+ // path. Skip in test mode.
230
+ const recipeStartedAt = now.getTime();
231
+ const recipeTriggerKind = recipe.trigger?.type ?? "manual";
232
+ const yamlTriggerKind = (["cron", "webhook", "recipe"].includes(recipeTriggerKind)
233
+ ? recipeTriggerKind
234
+ : "recipe");
235
+ let runSeq;
236
+ if (deps.runLog && !stepDeps.testMode) {
237
+ try {
238
+ runSeq = deps.runLog.startRun({
239
+ taskId: `yaml:${recipe.name}:${recipeStartedAt}`,
240
+ recipeName: recipe.name,
241
+ trigger: yamlTriggerKind,
242
+ createdAt: recipeStartedAt,
243
+ startedAt: recipeStartedAt,
244
+ });
245
+ }
246
+ catch {
247
+ // Non-fatal — run-log failures must never break recipe execution.
248
+ }
249
+ }
71
250
  const outputs = [];
251
+ const stepResults = [];
72
252
  let stepsRun = 0;
73
- const workdir = deps.workdir ?? process.cwd();
74
- const stepDeps = {
75
- readFile,
76
- writeFile,
77
- appendFile,
78
- mkdir,
79
- workdir,
80
- gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
81
- gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
82
- getDiagnostics: deps.getDiagnostics ?? (() => ""),
83
- fetchFn: deps.fetchFn ?? globalThis.fetch,
84
- claudeFn: deps.claudeFn ?? defaultClaudeFn,
85
- claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
86
- getGmailToken: deps.getGmailToken ??
87
- (async () => {
88
- const { getValidAccessToken } = await import("../connectors/gmail.js");
89
- return getValidAccessToken();
90
- }),
91
- };
253
+ let runError;
92
254
  for (const step of recipe.steps) {
93
255
  // Handle agent steps separately
94
256
  if (step.agent) {
95
257
  const agentCfg = step.agent;
96
258
  const renderedPrompt = render(agentCfg.prompt, ctx);
97
- const model = agentCfg.model ?? "claude-haiku-4-5-20251001";
98
259
  const intoKey = agentCfg.into ?? "agent_output";
260
+ const stepId = intoKey;
261
+ const stepStart = Date.now();
99
262
  let agentResult;
100
- if (agentCfg.driver === "claude-code") {
101
- agentResult = await stepDeps.claudeCodeFn(renderedPrompt);
102
- }
103
- else if (agentCfg.driver === "api") {
104
- agentResult = await stepDeps.claudeFn(renderedPrompt, model);
105
- }
106
- else {
107
- // Default driver: use API path. If no ANTHROPIC_API_KEY and caller did not provide a
108
- // custom claudeFn (i.e. using the built-in default that returns a skip message), probe
109
- // for the claude CLI and fall back automatically.
110
- const usingDefaultClaudeFn = deps.claudeFn === undefined;
111
- if (!process.env.ANTHROPIC_API_KEY && usingDefaultClaudeFn) {
112
- const probe = spawnSync("claude", ["--version"], {
113
- encoding: "utf-8",
114
- timeout: 5000,
263
+ try {
264
+ agentResult = await _executeAgent({
265
+ prompt: renderedPrompt,
266
+ driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
267
+ model: agentCfg.model,
268
+ ...(agentCfg.mcpAccess !== undefined && {
269
+ mcpAccess: agentCfg.mcpAccess,
270
+ }),
271
+ }, buildAgentExecutorDeps(stepDeps, deps));
272
+ // Catch both `[agent step failed: ...]` (existing) and the
273
+ // silent-fail patterns `[agent step skipped: ...]` etc. via the
274
+ // shared detector. Per-step opt-out via `silentFailDetection: false`.
275
+ const agentSilentFail = step.silentFailDetection !== false
276
+ ? detectSilentFail(agentResult)
277
+ : null;
278
+ if (agentResult.startsWith("[agent step failed:") || agentSilentFail) {
279
+ const reason = agentSilentFail
280
+ ? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
281
+ : agentResult;
282
+ runError = runError ?? reason;
283
+ stepResults.push({
284
+ id: stepId,
285
+ tool: "agent",
286
+ status: "error",
287
+ error: reason,
288
+ durationMs: Date.now() - stepStart,
115
289
  });
116
- if (!probe.error) {
117
- agentResult = await stepDeps.claudeCodeFn(renderedPrompt);
290
+ }
291
+ else {
292
+ const stripped = stripLeadingNarration(agentResult);
293
+ if (!stripped.trim()) {
294
+ const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
295
+ runError = runError ?? errMsg;
296
+ stepResults.push({
297
+ id: stepId,
298
+ tool: "agent",
299
+ status: "error",
300
+ error: errMsg,
301
+ durationMs: Date.now() - stepStart,
302
+ });
118
303
  }
119
304
  else {
120
- agentResult = await stepDeps.claudeFn(renderedPrompt, model);
305
+ // Try to parse as JSON so dot-notation ({{meeting.field}}) works
306
+ try {
307
+ const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
308
+ const parsed = JSON.parse((jsonMatch[1] ?? "").trim());
309
+ ctx[intoKey] = parsed;
310
+ }
311
+ catch {
312
+ ctx[intoKey] = stripped;
313
+ }
314
+ outputs.push(intoKey);
315
+ stepResults.push({
316
+ id: stepId,
317
+ tool: "agent",
318
+ status: "ok",
319
+ durationMs: Date.now() - stepStart,
320
+ });
121
321
  }
122
322
  }
123
- else {
124
- agentResult = await stepDeps.claudeFn(renderedPrompt, model);
125
- }
126
323
  }
127
- ctx[intoKey] = agentResult;
128
- outputs.push(intoKey);
324
+ catch (err) {
325
+ const msg = err instanceof Error ? err.message : String(err);
326
+ runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
327
+ stepResults.push({
328
+ id: stepId,
329
+ tool: "agent",
330
+ status: "error",
331
+ error: msg,
332
+ durationMs: Date.now() - stepStart,
333
+ });
334
+ }
129
335
  stepsRun++;
130
336
  continue;
131
337
  }
132
- const result = await executeStep(step, ctx, stepDeps);
133
- stepsRun++;
134
- if (result !== null) {
135
- if (step.into) {
136
- ctx[step.into] = result;
137
- // For Gmail steps, also expose flat dot-notation keys for render()
138
- const isGmailStep = step.tool === "gmail.fetch_unread" ||
139
- step.tool === "gmail.search" ||
140
- step.tool === "gmail.fetch_thread";
141
- if (isGmailStep) {
338
+ const stepStart = Date.now();
339
+ const stepId = step.into ?? `step_${stepsRun}`;
340
+ // Resolve retry policy: step-level overrides recipe-level.
341
+ const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
342
+ const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
343
+ let result = null;
344
+ let stepError;
345
+ let thrownError;
346
+ let thrownErrorCode;
347
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
348
+ if (attempt > 0) {
349
+ await new Promise((r) => setTimeout(r, retryDelayMs));
350
+ }
351
+ stepError = undefined;
352
+ thrownError = undefined;
353
+ thrownErrorCode = undefined;
354
+ try {
355
+ result = await executeStep(step, ctx, stepDeps);
356
+ // Detect tool-level errors reported as JSON {ok: false, error: ...}
357
+ if (result !== null) {
142
358
  try {
143
359
  const parsed = JSON.parse(result);
144
- for (const [k, v] of Object.entries(parsed)) {
145
- if (typeof v === "string" || typeof v === "number") {
146
- ctx[`${step.into}.${k}`] = String(v);
147
- }
148
- }
149
- // Also expose messages array as JSON string for agent prompts
150
- if (Array.isArray(parsed.messages)) {
151
- ctx[`${step.into}.json`] = JSON.stringify(parsed.messages);
360
+ if (parsed.ok === false && typeof parsed.error === "string") {
361
+ stepError = parsed.error;
152
362
  }
153
363
  }
154
364
  catch {
155
- // non-JSON result, skip
365
+ /* non-JSON result is fine */
366
+ }
367
+ }
368
+ // Silent-fail detection: tools that return string placeholders
369
+ // (`(git branches unavailable)`, `[agent step skipped: ...]`)
370
+ // or empty list-tool error shapes (`{count:0,error:"..."}`)
371
+ // succeed with bad data — flag them as `error` so the runner
372
+ // doesn't quietly hand garbage to a downstream agent. Per-step
373
+ // opt-out via `silentFailDetection: false`.
374
+ if (!stepError &&
375
+ result !== null &&
376
+ step.silentFailDetection !== false) {
377
+ const detected = detectSilentFail(result);
378
+ if (detected) {
379
+ stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
156
380
  }
157
381
  }
158
382
  }
383
+ catch (err) {
384
+ thrownError = err instanceof Error ? err.message : String(err);
385
+ // Preserve structured error codes (e.g. recipe_path_jail_escape)
386
+ // so callers and tests can branch on `err.code` per R2 M-4
387
+ // without scraping the message string.
388
+ const code = err?.code;
389
+ if (typeof code === "string")
390
+ thrownErrorCode = code;
391
+ result = null;
392
+ }
393
+ if (!stepError && !thrownError)
394
+ break;
395
+ }
396
+ // Recipe-level fallback: log_only / deliver_original treat step failure
397
+ // as non-fatal (fail-open) — same semantics as step-level optional: true.
398
+ const fallback = recipe.on_error?.fallback;
399
+ const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
400
+ const failOpen = step.optional === true || fallbackFailOpen;
401
+ if (thrownError) {
402
+ stepResults.push({
403
+ id: stepId,
404
+ tool: step.tool,
405
+ status: "error",
406
+ error: thrownError,
407
+ ...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
408
+ durationMs: Date.now() - stepStart,
409
+ });
410
+ if (!failOpen) {
411
+ runError = runError ?? `${step.tool} failed: ${thrownError}`;
412
+ }
413
+ else if (fallbackFailOpen && !step.optional) {
414
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
415
+ }
416
+ }
417
+ else {
418
+ stepResults.push({
419
+ id: stepId,
420
+ tool: step.tool,
421
+ status: result === null ? "skipped" : stepError ? "error" : "ok",
422
+ error: stepError,
423
+ durationMs: Date.now() - stepStart,
424
+ });
425
+ if (stepError) {
426
+ if (!failOpen) {
427
+ runError = runError ?? `${step.tool} failed: ${stepError}`;
428
+ }
429
+ else if (fallbackFailOpen && !step.optional) {
430
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${stepError}`);
431
+ }
432
+ }
433
+ }
434
+ stepsRun++;
435
+ if (result !== null) {
436
+ // Apply transform if present — render template with $result injected
437
+ if (step.transform) {
438
+ try {
439
+ result = render(step.transform, { ...ctx, $result: result });
440
+ }
441
+ catch (err) {
442
+ // warn but fall through with original result
443
+ console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
444
+ }
445
+ }
446
+ if (step.into) {
447
+ ctx[step.into] = result;
448
+ if (step.tool) {
449
+ applyToolOutputContext(step.tool, step.into, result, ctx);
450
+ }
451
+ }
159
452
  if (step.tool === "file.write" || step.tool === "file.append") {
160
- outputs.push(render(step.path, ctx));
453
+ // R2 C-1 / F-02: re-validate the rendered path against the jail so a
454
+ // template substitution that survived earlier checks (e.g. via a
455
+ // chained sub-recipe deps override) cannot smuggle an out-of-jail
456
+ // path into the run log / dashboard outputs list.
457
+ const renderedPath = render(step.path, ctx);
458
+ outputs.push(resolveRecipePath(renderedPath, {
459
+ workspace: stepDeps.workdir,
460
+ write: true,
461
+ }));
161
462
  }
162
463
  }
163
464
  }
164
- return { recipe: recipe.name, stepsRun, outputs, context: ctx };
465
+ // Evaluate expect block before persisting so failures are stored in the run log
466
+ const assertionFailures = recipe.expect
467
+ ? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
468
+ : [];
469
+ // Write to RecipeRunLog so the dashboard Runs page shows this execution.
470
+ // Bridge path: completeRun on the running entry opened above (live-tail).
471
+ // CLI path: construct a local log + appendDirect (no live-tail).
472
+ if (!stepDeps.testMode) {
473
+ try {
474
+ const doneAt = Date.now();
475
+ const outputTail = stepResults
476
+ .map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
477
+ .join("\n")
478
+ .slice(0, 2000);
479
+ const finalStepResults = stepResults.map((s) => ({
480
+ id: s.id,
481
+ tool: s.tool,
482
+ status: s.status,
483
+ error: s.error,
484
+ durationMs: s.durationMs,
485
+ }));
486
+ if (deps.runLog && runSeq !== undefined) {
487
+ deps.runLog.completeRun(runSeq, {
488
+ status: runError ? "error" : "done",
489
+ doneAt,
490
+ durationMs: doneAt - recipeStartedAt,
491
+ stepResults: finalStepResults,
492
+ outputTail,
493
+ ...(runError !== undefined && { errorMessage: runError }),
494
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
495
+ });
496
+ }
497
+ else {
498
+ const { RecipeRunLog } = await import("../runLog.js");
499
+ const { homedir } = await import("node:os");
500
+ const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
501
+ const log = new RecipeRunLog({ dir: resolvedLogDir });
502
+ log.appendDirect({
503
+ taskId: `yaml:${recipe.name}:${recipeStartedAt}`,
504
+ recipeName: recipe.name,
505
+ trigger: yamlTriggerKind,
506
+ status: runError ? "error" : "done",
507
+ createdAt: recipeStartedAt,
508
+ startedAt: recipeStartedAt,
509
+ doneAt,
510
+ durationMs: doneAt - recipeStartedAt,
511
+ outputTail,
512
+ errorMessage: runError,
513
+ stepResults: finalStepResults,
514
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
515
+ });
516
+ }
517
+ }
518
+ catch {
519
+ // Non-fatal — run log write failure should never break recipe execution
520
+ }
521
+ }
522
+ // Notify via Slack if any step failed
523
+ if (runError && !stepDeps.testMode) {
524
+ try {
525
+ const { isConnected, postMessage } = await import("../connectors/slack.js");
526
+ if (isConnected()) {
527
+ // Read notification channel from ~/.patchwork/config.json, fallback to first available
528
+ let notifyChannel = "all-massappealdesigns";
529
+ try {
530
+ const cfgPath = path.join(os.homedir(), ".patchwork", "config.json");
531
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
532
+ const notifications = cfg.notifications;
533
+ if (typeof notifications?.slackChannel === "string") {
534
+ notifyChannel = notifications.slackChannel;
535
+ }
536
+ }
537
+ catch {
538
+ /* use default */
539
+ }
540
+ const failedSteps = stepResults
541
+ .filter((s) => s.status === "error")
542
+ .map((s) => `• ${s.tool ?? s.id}: ${s.error ?? "unknown error"}`)
543
+ .join("\n");
544
+ await postMessage(notifyChannel, `⚠️ *Recipe failed: ${recipe.name}*\n\n${failedSteps}\n\n_${new Date().toISOString()}_`);
545
+ }
546
+ }
547
+ catch {
548
+ // Non-fatal — notification failure should never mask the original error
549
+ }
550
+ }
551
+ return {
552
+ recipe: recipe.name,
553
+ stepsRun,
554
+ outputs,
555
+ context: ctx,
556
+ stepResults,
557
+ errorMessage: runError,
558
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
559
+ };
165
560
  }
166
- function defaultClaudeCodeFn(prompt) {
561
+ export async function executeStep(step, ctx, deps) {
562
+ const toolId = step.tool;
563
+ if (!toolId) {
564
+ return null;
565
+ }
566
+ // Check if tool is registered in the new registry
567
+ if (hasTool(toolId)) {
568
+ const tool = getTool(toolId);
569
+ // Build params with template rendering for string values
570
+ const params = {};
571
+ for (const [key, value] of Object.entries(step)) {
572
+ if (key === "tool" || key === "agent" || key === "into")
573
+ continue;
574
+ params[key] = deepRender(value, ctx);
575
+ }
576
+ // Check if mock connector is available for this tool
577
+ if (deps.mockConnectors?.[toolId]) {
578
+ return deps.mockConnectors[toolId].invoke("execute", params);
579
+ }
580
+ if (tool &&
581
+ deps.recordFixturesDir &&
582
+ tool.namespace !== "file" &&
583
+ tool.namespace !== "git" &&
584
+ tool.namespace !== "diagnostics") {
585
+ return captureFixture(path.join(deps.recordFixturesDir, `${tool.namespace}.json`), tool.namespace, toolId.split(".")[1] ?? toolId, params, async () => executeTool(toolId, { params, step, ctx, deps }));
586
+ }
587
+ return executeTool(toolId, { params, step, ctx, deps });
588
+ }
589
+ // Unknown tool — skip, don't throw (forward compat)
590
+ return null;
591
+ }
592
+ /** Minimal `{{ expr }}` renderer — flat keys and dot-notation paths. */
593
+ export function render(template, ctx) {
594
+ return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
595
+ const key = expr.trim();
596
+ const coerce = (v) => {
597
+ if (v == null)
598
+ return "";
599
+ if (typeof v === "object")
600
+ return JSON.stringify(v);
601
+ return String(v);
602
+ };
603
+ // Fast path: flat key exists
604
+ if (Object.hasOwn(ctx, key))
605
+ return coerce(ctx[key]);
606
+ // Dot-notation: resolve nested path into ctx values (JSON-parse string intermediates)
607
+ const parts = key.split(".");
608
+ // biome-ignore lint/suspicious/noExplicitAny: resolved values are dynamic JSON shapes
609
+ let val = ctx;
610
+ for (const part of parts) {
611
+ if (val == null)
612
+ return "";
613
+ if (typeof val === "string") {
614
+ try {
615
+ val = JSON.parse(val);
616
+ }
617
+ catch {
618
+ return "";
619
+ }
620
+ }
621
+ if (typeof val !== "object")
622
+ return "";
623
+ // Object.hasOwn — bracket access on a Record walks the prototype chain,
624
+ // which would expose Object.prototype members (toString, constructor,
625
+ // etc.) to attacker-controllable template paths. String(toString)
626
+ // renders the function source and leaks it into recipe output.
627
+ const obj = val;
628
+ val = Object.hasOwn(obj, part) ? obj[part] : undefined;
629
+ }
630
+ return val == null
631
+ ? ""
632
+ : typeof val === "object"
633
+ ? JSON.stringify(val)
634
+ : String(val);
635
+ });
636
+ }
637
+ /** Recursively render all string leaves in a value (for nested params like blocks). */
638
+ function deepRender(value, ctx) {
639
+ if (typeof value === "string")
640
+ return render(value, ctx);
641
+ if (Array.isArray(value))
642
+ return value.map((v) => deepRender(v, ctx));
643
+ if (value !== null && typeof value === "object") {
644
+ const out = {};
645
+ for (const [k, v] of Object.entries(value)) {
646
+ out[k] = deepRender(v, ctx);
647
+ }
648
+ return out;
649
+ }
650
+ return value;
651
+ }
652
+ function parseSinceToGitArg(since) {
653
+ const m = /^(\d+)(h|d)$/i.exec(since.trim());
654
+ if (!m)
655
+ return since;
656
+ const [, num, unit = "h"] = m;
657
+ return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
658
+ }
659
+ // Exported for test coverage of the regression fix (was returning the
660
+ // `(git log unavailable)` placeholder string on any failure, which
661
+ // silently looked like success to pre-#72 runners).
662
+ export function defaultGitLogSince(since, workdir) {
663
+ // Same antipattern that broke `defaultGitStaleBranches` (PR #70): on
664
+ // any error this used to return `(git log unavailable)`. The runner
665
+ // saw that as success-with-empty-data and downstream agents
666
+ // summarized "no recent commits" — false signal.
667
+ //
668
+ // Fix: return a JSON `{ok: false, error}` shape on failure so the
669
+ // runner's existing JSON-error detection (yamlRunner step-error
670
+ // block) flags the step as `error`. Successful runs still return
671
+ // bare git output text.
672
+ try {
673
+ const sinceArg = parseSinceToGitArg(since);
674
+ const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
675
+ cwd: workdir ?? process.cwd(),
676
+ encoding: "utf-8",
677
+ timeout: 5000,
678
+ });
679
+ if (result.error) {
680
+ return JSON.stringify({
681
+ ok: false,
682
+ error: `git log failed: ${result.error.message}`,
683
+ });
684
+ }
685
+ if (result.status !== 0) {
686
+ const stderr = (result.stderr ?? "").toString().trim().slice(0, 200);
687
+ return JSON.stringify({
688
+ ok: false,
689
+ error: `git log exited ${result.status}${stderr ? `: ${stderr}` : ""}`,
690
+ });
691
+ }
692
+ return (result.stdout ?? "").trim();
693
+ }
694
+ catch (err) {
695
+ return JSON.stringify({
696
+ ok: false,
697
+ error: `git log threw: ${err instanceof Error ? err.message : String(err)}`,
698
+ });
699
+ }
700
+ }
701
+ // Exported for test coverage of the regression fix (was using `git branch
702
+ // --since=<date>` which isn't a real flag).
703
+ export function defaultGitStaleBranches(days, workdir) {
704
+ // Two bugs were caught dogfooding the `branch-health` recipe:
705
+ // 1) `git branch --since=<date>` is NOT a valid flag — git exits 129
706
+ // with "unknown option `since=...`". The function used to ALWAYS
707
+ // fall through to the "(git branches unavailable)" placeholder.
708
+ // 2) Even if `--since` had been a real flag, its semantics ("commits
709
+ // since") would have produced the OPPOSITE list of what
710
+ // "stale_branches" implies — branches with recent activity, not
711
+ // ones that have gone quiet.
712
+ //
713
+ // Fix: use `git for-each-ref` with a `committerdate` format, parse the
714
+ // ISO date in JS, and emit branches whose last commit is OLDER than
715
+ // the cutoff. Output is one per line: `<short-name> <YYYY-MM-DD>`.
716
+ try {
717
+ const cutoffMs = Date.now() - days * 86_400_000;
718
+ const r = spawnSync("git", [
719
+ "for-each-ref",
720
+ "--sort=committerdate",
721
+ "--format=%(refname:short)\t%(committerdate:iso-strict)",
722
+ "refs/heads/",
723
+ ], {
724
+ cwd: workdir ?? process.cwd(),
725
+ encoding: "utf-8",
726
+ timeout: 5000,
727
+ });
728
+ if (r.error || r.status !== 0)
729
+ return "(git branches unavailable)";
730
+ const lines = (r.stdout ?? "").split("\n").filter(Boolean);
731
+ const stale = [];
732
+ for (const line of lines) {
733
+ const tab = line.indexOf("\t");
734
+ if (tab < 0)
735
+ continue;
736
+ const name = line.slice(0, tab);
737
+ const dateStr = line.slice(tab + 1);
738
+ const ts = Date.parse(dateStr);
739
+ if (Number.isNaN(ts))
740
+ continue;
741
+ if (ts < cutoffMs) {
742
+ stale.push(`${name}\t${dateStr.slice(0, 10)}`);
743
+ }
744
+ }
745
+ if (stale.length === 0) {
746
+ return `(no branches inactive >${days}d)`;
747
+ }
748
+ return stale.join("\n");
749
+ }
750
+ catch {
751
+ return "(git branches unavailable)";
752
+ }
753
+ }
754
+ /** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
755
+ function resolveStepDeps(deps) {
756
+ const workdir = deps.workdir ?? process.cwd();
757
+ // Defense-in-depth: even if a file.* tool somehow forgets to call
758
+ // resolveRecipePath in its execute(), the default StepDeps file ops will
759
+ // jail the path before touching the filesystem (G-security F-01 / R2 C-1
760
+ // chained-runner third-substitution-site coverage).
761
+ return {
762
+ readFile: deps.readFile ??
763
+ ((p) => readFileSync(resolveRecipePath(p, { workspace: workdir }), "utf-8")),
764
+ writeFile: deps.writeFile ??
765
+ ((p, content) => {
766
+ const abs = resolveRecipePath(p, { workspace: workdir, write: true });
767
+ mkdirSync(path.dirname(abs), { recursive: true });
768
+ writeFileSync(abs, content);
769
+ }),
770
+ appendFile: deps.appendFile ??
771
+ ((p, content) => {
772
+ const abs = resolveRecipePath(p, { workspace: workdir, write: true });
773
+ mkdirSync(path.dirname(abs), { recursive: true });
774
+ appendFileSync(abs, content);
775
+ }),
776
+ mkdir: deps.mkdir ??
777
+ ((p) => mkdirSync(resolveRecipePath(p, { workspace: workdir, write: true }), {
778
+ recursive: true,
779
+ })),
780
+ workdir,
781
+ gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
782
+ gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
783
+ // The `diagnostics.get` recipe tool is registered (src/recipes/tools/
784
+ // diagnostics.ts) but only meaningful when the bridge wires a real
785
+ // `getDiagnostics` impl backed by the LSP / extension client. CLI runs
786
+ // and tests have no bridge to ask, so the default returns a JSON error
787
+ // shape that the step-error detector flags as `error` instead of the
788
+ // pre-fix empty string that silently passed as success.
789
+ getDiagnostics: deps.getDiagnostics ??
790
+ (() => JSON.stringify({
791
+ ok: false,
792
+ error: "diagnostics.get unavailable (no bridge / no `deps.getDiagnostics` injected)",
793
+ })),
794
+ fetchFn: deps.fetchFn ?? globalThis.fetch,
795
+ claudeFn: deps.claudeFn ?? defaultClaudeFn,
796
+ claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
797
+ localFn: deps.localFn ?? defaultLocalFn,
798
+ providerDriverFn: deps.providerDriverFn ?? makeProviderDriverFn(),
799
+ mockConnectors: deps.mockConnectors ?? {},
800
+ recordFixturesDir: deps.recordFixturesDir,
801
+ getGmailToken: deps.getGmailToken ??
802
+ (async () => {
803
+ const { getValidAccessToken } = await import("../connectors/gmail.js");
804
+ return getValidAccessToken();
805
+ }),
806
+ getDriveToken: deps.getDriveToken ??
807
+ (async () => {
808
+ const { getValidAccessToken } = await import("../connectors/googleDrive.js");
809
+ return getValidAccessToken();
810
+ }),
811
+ logDir: deps.logDir,
812
+ testMode: deps.testMode ?? false,
813
+ };
814
+ }
815
+ function buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride) {
816
+ const claudeCliFn = claudeCodeFnOverride ?? stepDeps.claudeCodeFn;
817
+ return {
818
+ anthropicFn: (prompt, model) => stepDeps.claudeFn(prompt, model),
819
+ providerDriverFn: (driver, prompt, model) => stepDeps.providerDriverFn(driver, prompt, model),
820
+ claudeCliFn: (prompt, opts) => claudeCliFn(prompt, opts),
821
+ localFn: (prompt, model) => stepDeps.localFn(prompt, model),
822
+ probeClaudeCli: () => {
823
+ if (runnerDeps.claudeFn !== undefined)
824
+ return false;
825
+ const probe = spawnSync("claude", ["--version"], {
826
+ encoding: "utf-8",
827
+ timeout: 5000,
828
+ });
829
+ return !probe.error;
830
+ },
831
+ loadPatchworkConfig: () => {
832
+ // Synchronous static import — earlier `require()` form silently failed
833
+ // under "type": "module" and returned {}, dropping config-driven
834
+ // model/driver preferences for no-driver agent steps.
835
+ try {
836
+ return loadPatchworkConfigSync();
837
+ }
838
+ catch {
839
+ return {};
840
+ }
841
+ },
842
+ };
843
+ }
844
+ function defaultClaudeCodeFn(prompt, _opts) {
167
845
  try {
168
846
  const result = spawnSync("claude", [
169
847
  "-p",
@@ -188,6 +866,51 @@ function defaultClaudeCodeFn(prompt) {
188
866
  return Promise.resolve(`[agent step failed: ${err instanceof Error ? err.message : String(err)}]`);
189
867
  }
190
868
  }
869
+ /** Returns a providerDriverFn with a per-run driver cache (not shared across runs). */
870
+ function makeProviderDriverFn() {
871
+ const cache = new Map();
872
+ return async function defaultProviderDriverFn(driverName, prompt, model) {
873
+ try {
874
+ let driver = cache.get(driverName);
875
+ if (!driver) {
876
+ const { createDriver } = await import("../drivers/index.js");
877
+ const d = createDriver(driverName, { binary: "claude", antBinary: "ant" }, () => { });
878
+ if (!d)
879
+ return `[agent step failed: ${driverName} driver returned null]`;
880
+ driver = d;
881
+ cache.set(driverName, driver);
882
+ }
883
+ const controller = new AbortController();
884
+ const timeoutMs = 300_000;
885
+ const startupTimeoutMs = 30_000;
886
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
887
+ try {
888
+ const result = await driver.run({
889
+ prompt,
890
+ workspace: process.cwd(),
891
+ timeoutMs,
892
+ startupTimeoutMs,
893
+ signal: controller.signal,
894
+ model,
895
+ });
896
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
897
+ const detail = result.stderrTail ?? result.text ?? "";
898
+ return `[agent step failed: ${driverName} exited ${result.exitCode}${detail ? ` — ${detail.slice(0, 200)}` : ""}]`;
899
+ }
900
+ if (!result.text) {
901
+ return `[agent step failed: ${driverName} returned empty output (possible timeout or auth error)]`;
902
+ }
903
+ return result.text;
904
+ }
905
+ finally {
906
+ clearTimeout(timeout);
907
+ }
908
+ }
909
+ catch (err) {
910
+ return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
911
+ }
912
+ };
913
+ }
191
914
  async function defaultClaudeFn(prompt, model) {
192
915
  const apiKey = process.env.ANTHROPIC_API_KEY;
193
916
  if (!apiKey)
@@ -222,338 +945,222 @@ async function defaultClaudeFn(prompt, model) {
222
945
  return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
223
946
  }
224
947
  }
225
- async function executeStep(step, ctx, deps) {
226
- switch (step.tool) {
227
- case "file.read": {
228
- const p = render(step.path, ctx);
229
- try {
230
- return deps.readFile(p);
231
- }
232
- catch {
233
- if (step.optional)
234
- return "";
235
- throw new Error(`file.read: could not read ${p}`);
236
- }
948
+ async function defaultLocalFn(prompt, model) {
949
+ try {
950
+ const { createLocalAdapter } = await import("../adapters/local.js");
951
+ const { loadConfig: loadPatchworkConfig } = await import("../patchworkConfig.js");
952
+ const cfg = loadPatchworkConfig();
953
+ const adapter = createLocalAdapter({
954
+ endpoint: cfg.localEndpoint,
955
+ defaultModel: cfg.localModel ?? model,
956
+ });
957
+ const result = await adapter.complete({
958
+ systemPrompt: "",
959
+ messages: [{ role: "user", content: prompt }],
960
+ });
961
+ return result.text ?? "[agent step failed: empty response from local LLM]";
962
+ }
963
+ catch (err) {
964
+ return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
965
+ }
966
+ }
967
+ /**
968
+ * Build ExecutionDeps for ChainedRecipeRunner backed by the yamlRunner step
969
+ * handlers. This lets chained recipes use the same tool set (file.*, git.*,
970
+ * gmail.*, github.*, linear.*, diagnostics.*) as simple YAML recipes.
971
+ *
972
+ * Pass the result as `chainedDeps` when calling `dispatchRecipe` or
973
+ * `runChainedRecipe` so that `executeTool` is properly wired.
974
+ */
975
+ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
976
+ const stepDeps = resolveStepDeps(runnerDeps);
977
+ function normalizeNestedRecipeLookupName(ref) {
978
+ return ref.trim().replace(/\.ya?ml$/i, "");
979
+ }
980
+ function tryLoadRecipeFile(filePath) {
981
+ if (!existsSync(filePath))
982
+ return null;
983
+ try {
984
+ const recipe = loadYamlRecipe(filePath);
985
+ return { recipe, sourcePath: filePath };
237
986
  }
238
- case "file.write": {
239
- const p = render(step.path, ctx);
240
- const content = render(step.content, ctx);
241
- deps.writeFile(p, content);
242
- return content;
243
- }
244
- case "file.append": {
245
- const p = render(step.path, ctx);
246
- const content = render(step.content, ctx);
247
- const when = step.when;
248
- if (when && !evalWhen(when, ctx))
249
- return null;
250
- deps.appendFile(p, content);
251
- return content;
252
- }
253
- case "git.log_since": {
254
- const since = render(String(step.since ?? "24h"), ctx);
255
- return deps.gitLogSince(since, deps.workdir);
256
- }
257
- case "git.stale_branches": {
258
- const days = typeof step.days === "number" ? step.days : 30;
259
- return deps.gitStaleBranches(days, deps.workdir);
260
- }
261
- case "diagnostics.get": {
262
- const uri = render(String(step.uri ?? ""), ctx);
263
- return deps.getDiagnostics(uri);
264
- }
265
- case "gmail.fetch_unread": {
266
- const since = render(String(step.since ?? "24h"), ctx);
267
- const MAX_GMAIL_RESULTS = 50;
268
- const max = Math.min(typeof step.max === "number" ? step.max : 20, MAX_GMAIL_RESULTS);
269
- const query = `is:unread newer_than:${sinceToGmailQuery(since)}`;
270
- return gmailSearch(query, max, deps);
271
- }
272
- case "gmail.search": {
273
- const query = render(String(step.query ?? ""), ctx);
274
- const MAX_GMAIL_RESULTS = 50;
275
- const max = Math.min(typeof step.max === "number" ? step.max : 10, MAX_GMAIL_RESULTS);
276
- return gmailSearch(query, max, deps);
277
- }
278
- case "gmail.fetch_thread": {
279
- const id = render(String(step.id ?? ""), ctx);
280
- return gmailFetchThread(id, deps);
281
- }
282
- case "github.list_issues": {
283
- const { listIssues } = await import("../connectors/github.js");
284
- const repo = step.repo ? render(String(step.repo), ctx) : undefined;
285
- const assignee = step.assignee
286
- ? render(String(step.assignee), ctx)
287
- : "@me";
288
- const limit = typeof step.max === "number" ? step.max : 20;
289
- const issues = await listIssues({ repo, assignee, limit });
290
- return JSON.stringify({ count: issues.length, issues });
291
- }
292
- case "github.list_prs": {
293
- const { listPRs } = await import("../connectors/github.js");
294
- const repo = step.repo ? render(String(step.repo), ctx) : undefined;
295
- const author = step.author ? render(String(step.author), ctx) : "@me";
296
- const limit = typeof step.max === "number" ? step.max : 20;
297
- const prs = await listPRs({ repo, author, limit });
298
- return JSON.stringify({ count: prs.length, prs });
299
- }
300
- case "linear.list_issues": {
301
- const { loadTokens, listIssues: listLinearIssues } = await import("../connectors/linear.js");
302
- if (!loadTokens()) {
303
- return JSON.stringify({
304
- count: 0,
305
- issues: [],
306
- error: "Linear not connected",
307
- });
308
- }
309
- const teamKey = step.team ? render(String(step.team), ctx) : undefined;
310
- const assigneeMe = step.assignee === "@me" || step.assignee === undefined;
311
- const stateFilter = step.state
312
- ? render(String(step.state), ctx)
313
- : "started,unstarted";
314
- const limit = typeof step.max === "number" ? step.max : 20;
315
- const states = stateFilter
316
- .split(",")
317
- .map((s) => s.trim())
318
- .filter(Boolean);
319
- try {
320
- const issues = await listLinearIssues({
321
- team: teamKey,
322
- assigneeMe,
323
- states,
324
- limit,
325
- });
326
- return JSON.stringify({ count: issues.length, issues });
327
- }
328
- catch (err) {
329
- return JSON.stringify({
330
- count: 0,
331
- issues: [],
332
- error: err instanceof Error ? err.message : String(err),
333
- });
987
+ catch {
988
+ return null;
989
+ }
990
+ }
991
+ const executeTool = async (tool, params) => {
992
+ // R2 C-1 third-substitution-site coverage: the chained runner has its
993
+ // own template-resolution path (`chainedRunner.ts:194-205`). By the
994
+ // time we reach this dispatch point the params have been rendered
995
+ // *and* JSON-parsed, so a `path` field that survived the chained
996
+ // substitution may have just been promoted from inside-jail to
997
+ // outside-jail. Re-jail any `path` field on file.* tools here so that
998
+ // chained sub-recipes can't bypass the per-tool jail in `tools/file.ts`
999
+ // by injecting `..` segments via outer-recipe vars.
1000
+ if ((tool === "file.read" ||
1001
+ tool === "file.write" ||
1002
+ tool === "file.append") &&
1003
+ typeof params.path === "string") {
1004
+ params = {
1005
+ ...params,
1006
+ path: resolveRecipePath(params.path, {
1007
+ workspace: stepDeps.workdir,
1008
+ write: tool !== "file.read",
1009
+ }),
1010
+ };
1011
+ }
1012
+ // Construct a YamlStep-compatible object so we can reuse executeStep.
1013
+ const step = { tool, ...params };
1014
+ // executeStep uses a RunContext for {{}} rendering — by the time executeTool
1015
+ // is called the chained runner has already resolved templates, so we pass
1016
+ // an empty context (no double-rendering).
1017
+ const result = await executeStep(step, {}, stepDeps);
1018
+ return result ?? "";
1019
+ };
1020
+ const executeAgent = async (prompt, model, driver, mcpAccess) => _executeAgent({
1021
+ prompt,
1022
+ model,
1023
+ driver,
1024
+ ...(mcpAccess !== undefined && { mcpAccess }),
1025
+ }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
1026
+ // ---------------------------------------------------------------------
1027
+ // BEGIN A-PR2 EDIT BLOCK — `loadNestedRecipe` jail (dogfood F-04).
1028
+ //
1029
+ // Path-shaped recipe references (`recipe: ./inner.yaml`, `recipe: /abs.yaml`)
1030
+ // are restricted to three allowed roots:
1031
+ // 1. parent recipe's directory (`path.dirname(parentSourcePath)`)
1032
+ // 2. user recipes dir (`~/.patchwork/recipes/`)
1033
+ // 3. bundled templates dir (`BUNDLED_TEMPLATES_DIR`, captured at boot)
1034
+ //
1035
+ // Resolved candidates that escape all three (e.g. `/etc/passwd.yaml`) are
1036
+ // rejected with `null` — same shape as a not-found lookup so the chained
1037
+ // runner reports its existing "nested_recipe_not_found" error rather than
1038
+ // surfacing a security-implementation detail to the recipe author.
1039
+ //
1040
+ // Coordination note (A-PR1 may also touch this file): the helper
1041
+ // `pathIsWithin` below is local to this module — A-PR1 is changing
1042
+ // unrelated `vars` validation paths and should not collide here. If a merge
1043
+ // conflict surfaces, keep BOTH the jail AND the A-PR1 vars validation.
1044
+ // ---------------------------------------------------------------------
1045
+ const pathIsWithin = (candidate, base) => {
1046
+ const resolvedCandidate = path.resolve(candidate);
1047
+ const resolvedBase = path.resolve(base);
1048
+ if (resolvedCandidate === resolvedBase)
1049
+ return true;
1050
+ return resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
1051
+ };
1052
+ const loadNestedRecipe = async (name, parentSourcePath) => {
1053
+ const lookupName = normalizeNestedRecipeLookupName(name);
1054
+ const { homedir: nestedHomedir } = await import("node:os");
1055
+ const userRecipesDir = path.join(nestedHomedir(), ".patchwork", "recipes");
1056
+ if (parentSourcePath) {
1057
+ const parentDir = path.dirname(parentSourcePath);
1058
+ const pathLike = path.isAbsolute(name) ||
1059
+ name.startsWith("./") ||
1060
+ name.startsWith("../") ||
1061
+ /[\\/]/.test(name) ||
1062
+ /\.ya?ml$/i.test(name);
1063
+ if (pathLike) {
1064
+ const resolvedBase = path.isAbsolute(name)
1065
+ ? path.resolve(name)
1066
+ : path.resolve(parentDir, name);
1067
+ const candidates = /\.ya?ml$/i.test(resolvedBase)
1068
+ ? [resolvedBase]
1069
+ : [`${resolvedBase}.yaml`, `${resolvedBase}.yml`, resolvedBase];
1070
+ // Jail: every candidate must live inside one of the three allowed
1071
+ // roots (parent dir, user recipes, bundled templates). Reject silently
1072
+ // — null mirrors the existing not-found path so error messages stay
1073
+ // generic and don't leak the jail boundaries.
1074
+ const allowedRoots = [parentDir, userRecipesDir, BUNDLED_TEMPLATES_DIR];
1075
+ for (const candidate of candidates) {
1076
+ const inJail = allowedRoots.some((root) => pathIsWithin(candidate, root));
1077
+ if (!inJail)
1078
+ continue;
1079
+ const loaded = tryLoadRecipeFile(candidate);
1080
+ if (loaded)
1081
+ return loaded;
1082
+ }
334
1083
  }
335
1084
  }
336
- case "calendar.list_events": {
337
- const { listEvents } = await import("../connectors/googleCalendar.js");
338
- const daysAhead = typeof step.days_ahead === "number" ? step.days_ahead : 7;
339
- const maxResults = typeof step.max === "number" ? step.max : 20;
340
- const calendarId = step.calendar_id
341
- ? render(String(step.calendar_id), ctx)
342
- : undefined;
1085
+ // END A-PR2 EDIT BLOCK
1086
+ // Reuses `userRecipesDir` already resolved above for the jail check.
1087
+ const recipesDir = userRecipesDir;
1088
+ // Check for manifest-based package directory first.
1089
+ // Supports both plain names ("morning-brief") and scoped names ("@acme/morning-brief").
1090
+ const pkgDirCandidates = [
1091
+ path.join(recipesDir, lookupName),
1092
+ // scoped: @acme/morning-brief → recipesDir/@acme/morning-brief
1093
+ ];
1094
+ for (const pkgDir of pkgDirCandidates) {
343
1095
  try {
344
- const events = await listEvents({ daysAhead, maxResults, calendarId });
345
- return JSON.stringify({ count: events.length, events });
1096
+ const { loadManifestFromDir } = await import("./manifest.js");
1097
+ const manifest = loadManifestFromDir(pkgDir);
1098
+ if (manifest) {
1099
+ const mainPath = path.join(pkgDir, manifest.recipes.main);
1100
+ const loaded = tryLoadRecipeFile(mainPath);
1101
+ if (loaded)
1102
+ return loaded;
1103
+ }
346
1104
  }
347
- catch (err) {
348
- return JSON.stringify({
349
- count: 0,
350
- events: [],
351
- error: err instanceof Error ? err.message : String(err),
352
- });
1105
+ catch {
1106
+ // not a manifest dir — try flat file candidates
353
1107
  }
354
1108
  }
355
- default:
356
- // Unknown tool — skip, don't throw (forward compat)
357
- return null;
358
- }
359
- }
360
- /** Minimal `{{ expr }}` renderer — replaces against flat context map. */
361
- export function render(template, ctx) {
362
- return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
363
- const key = expr.trim();
364
- return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
365
- });
1109
+ const candidate = findYamlRecipePath(recipesDir, lookupName);
1110
+ if (candidate) {
1111
+ const loaded = tryLoadRecipeFile(candidate);
1112
+ if (loaded)
1113
+ return loaded;
1114
+ }
1115
+ return null;
1116
+ };
1117
+ return { executeTool, executeAgent, loadNestedRecipe };
366
1118
  }
367
1119
  /**
368
- * Evaluate simple `N > 0 || M > 0` guards after template rendering.
369
- * Supports: numeric literals, >, <, >=, <=, ==, !=, ||, &&, !.
370
- * Returns true (run step) for anything it can't parse.
1120
+ * Dispatch a loaded recipe to the appropriate runner.
1121
+ *
1122
+ * Recipes with `trigger.type: "chained"` are routed to the ChainedRecipeRunner
1123
+ * (parallel execution, template variables, nested recipes, dry-run).
1124
+ * All other recipes use the existing synchronous yamlRunner path.
1125
+ *
1126
+ * `chainedDeps` is only required when the recipe is chained; omit for simple recipes.
371
1127
  */
372
- function evalWhen(when, ctx) {
373
- try {
374
- const expanded = render(when, ctx).trim();
375
- // Only handle the `N op M` and `expr || expr` / `expr && expr` patterns.
376
- const orParts = expanded.split("||");
377
- if (orParts.length > 1) {
378
- return orParts.some((p) => evalWhen(p.trim(), {}));
379
- }
380
- const andParts = expanded.split("&&");
381
- if (andParts.length > 1) {
382
- return andParts.every((p) => evalWhen(p.trim(), {}));
383
- }
384
- const m = /^(-?[\d.]+)\s*(>|<|>=|<=|==|!=)\s*(-?[\d.]+)$/.exec(expanded);
385
- if (!m)
386
- return true;
387
- const [, lhs, op, rhs] = m;
388
- const l = Number(lhs);
389
- const r = Number(rhs);
390
- switch (op) {
391
- case ">":
392
- return l > r;
393
- case "<":
394
- return l < r;
395
- case ">=":
396
- return l >= r;
397
- case "<=":
398
- return l <= r;
399
- case "==":
400
- return l === r;
401
- case "!=":
402
- return l !== r;
403
- default:
404
- return true;
1128
+ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
1129
+ const triggerType = recipe.trigger
1130
+ ?.type;
1131
+ if (triggerType === "chained") {
1132
+ const { runChainedRecipe } = await import("./chainedRunner.js");
1133
+ const chainedRecipe = recipe;
1134
+ const now = deps.now ? deps.now() : new Date();
1135
+ const options = {
1136
+ env: {
1137
+ ...process.env,
1138
+ DATE: now.toISOString().slice(0, 10),
1139
+ TIME: now.toTimeString().slice(0, 5),
1140
+ ...seedContext,
1141
+ },
1142
+ maxConcurrency: chainedRecipe.maxConcurrency ?? 4,
1143
+ maxDepth: chainedRecipe.maxDepth ?? 3,
1144
+ dryRun: deps.chainedOptions?.dryRun ?? false,
1145
+ sourcePath: deps.chainedOptions?.sourcePath,
1146
+ onStepStart: deps.chainedOptions?.onStepStart,
1147
+ onStepComplete: deps.chainedOptions?.onStepComplete,
1148
+ runLogDir: deps.chainedOptions?.runLogDir,
1149
+ runLog: deps.chainedOptions?.runLog,
1150
+ activityLog: deps.chainedOptions?.activityLog,
1151
+ mockedOutputs: deps.chainedOptions?.mockedOutputs,
1152
+ taskIdPrefix: deps.chainedOptions?.taskIdPrefix,
1153
+ };
1154
+ if (!deps.chainedDeps) {
1155
+ throw new Error("chainedDeps required for chained recipes (provide executeTool, executeAgent, loadNestedRecipe)");
405
1156
  }
1157
+ return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
406
1158
  }
407
- catch {
408
- return true;
409
- }
410
- }
411
- function sinceToGmailQuery(since) {
412
- // "24h" → "1d", "7d" → "7d", "1h" → "1d" (round up)
413
- const m = /^(\d+)(h|d)$/.exec(since.trim().toLowerCase());
414
- if (!m)
415
- return "1d";
416
- const [, num, unit] = m;
417
- if (unit === "d")
418
- return `${num}d`;
419
- // hours → round up to days (min 1d)
420
- const days = Math.max(1, Math.ceil(Number(num) / 24));
421
- return `${days}d`;
422
- }
423
- function getHeader(headers, name) {
424
- return (headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ??
425
- "");
426
- }
427
- async function gmailSearch(query, max, deps) {
428
- const errorResult = (msg) => JSON.stringify({ count: 0, messages: [], error: msg });
429
- let token;
430
- try {
431
- token = await deps.getGmailToken();
432
- }
433
- catch {
434
- return errorResult("Gmail not connected");
435
- }
436
- try {
437
- const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${max}`;
438
- const listRes = await deps.fetchFn(listUrl, {
439
- headers: { Authorization: `Bearer ${token}` },
440
- });
441
- if (!listRes.ok)
442
- return errorResult("Gmail API error");
443
- const listJson = (await listRes.json());
444
- const ids = listJson.messages ?? [];
445
- const messages = await Promise.all(ids.slice(0, max).map(async (m) => {
446
- const detailUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=Subject,From,Date`;
447
- const detailRes = await deps.fetchFn(detailUrl, {
448
- headers: { Authorization: `Bearer ${token}` },
449
- });
450
- if (!detailRes.ok)
451
- return { id: m.id, subject: "", from: "", date: "", snippet: "" };
452
- const detail = (await detailRes.json());
453
- const hdrs = detail.payload?.headers ?? [];
454
- return {
455
- id: detail.id,
456
- subject: getHeader(hdrs, "Subject"),
457
- from: getHeader(hdrs, "From"),
458
- date: getHeader(hdrs, "Date"),
459
- snippet: detail.snippet ?? "",
460
- };
461
- }));
462
- const result = { count: messages.length, messages };
463
- return JSON.stringify(result);
464
- }
465
- catch {
466
- return errorResult("Gmail fetch failed");
467
- }
468
- }
469
- async function gmailFetchThread(id, deps) {
470
- const errorResult = (msg) => JSON.stringify({ subject: "", messages: [], error: msg });
471
- let token;
472
- try {
473
- token = await deps.getGmailToken();
474
- }
475
- catch {
476
- return errorResult("Gmail not connected");
477
- }
478
- try {
479
- const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${id}?format=metadata&metadataHeaders=Subject,From,Date`;
480
- const res = await deps.fetchFn(url, {
481
- headers: { Authorization: `Bearer ${token}` },
482
- });
483
- if (!res.ok)
484
- return errorResult("Gmail API error");
485
- const thread = (await res.json());
486
- const msgs = thread.messages ?? [];
487
- const firstHdrs = msgs[0]?.payload?.headers ?? [];
488
- const subject = getHeader(firstHdrs, "Subject");
489
- const messages = msgs.map((m) => {
490
- const hdrs = m.payload?.headers ?? [];
491
- return {
492
- from: getHeader(hdrs, "From"),
493
- date: getHeader(hdrs, "Date"),
494
- body_snippet: m.snippet ?? "",
495
- };
496
- });
497
- const result = { subject, messages };
498
- return JSON.stringify(result);
499
- }
500
- catch {
501
- return errorResult("Gmail fetch failed");
502
- }
503
- }
504
- function expandHome(p) {
505
- if (p.startsWith("~/"))
506
- return path.join(os.homedir(), p.slice(2));
507
- return p;
508
- }
509
- function parseSinceToGitArg(since) {
510
- const m = /^(\d+)(h|d)$/i.exec(since.trim());
511
- if (!m)
512
- return since;
513
- const [, num, unit = "h"] = m;
514
- return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
515
- }
516
- function defaultGitLogSince(since, workdir) {
517
- try {
518
- const sinceArg = parseSinceToGitArg(since);
519
- const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
520
- cwd: workdir ?? process.cwd(),
521
- encoding: "utf-8",
522
- timeout: 5000,
523
- });
524
- if (result.error || result.status !== 0)
525
- return "(git log unavailable)";
526
- return (result.stdout ?? "").trim();
527
- }
528
- catch {
529
- return "(git log unavailable)";
530
- }
531
- }
532
- function defaultGitStaleBranches(days, workdir) {
533
- try {
534
- const cutoff = new Date(Date.now() - days * 86_400_000)
535
- .toISOString()
536
- .slice(0, 10);
537
- const r = spawnSync("git", ["branch", "--format=%(refname:short) %(committerdate:short)"], {
538
- cwd: workdir ?? process.cwd(),
539
- encoding: "utf-8",
540
- timeout: 5000,
541
- });
542
- const branches = r.error || r.status !== 0 ? "" : (r.stdout ?? "").trim();
543
- if (!branches)
544
- return "(no local branches)";
545
- return (branches
546
- .split("\n")
547
- .filter((line) => {
548
- const parts = line.trim().split(/\s+/);
549
- const dateStr = parts[1];
550
- return dateStr && dateStr < cutoff;
551
- })
552
- .join("\n") || "(none older than 30 days)");
553
- }
554
- catch {
555
- return "(git unavailable)";
556
- }
1159
+ // For non-chained recipes, lift `runLog` from chainedOptions onto the
1160
+ // RunnerDeps so runYamlRecipe gets the bridge's singleton too.
1161
+ return runYamlRecipe(recipe, deps.chainedOptions?.runLog
1162
+ ? { ...deps, runLog: deps.chainedOptions.runLog }
1163
+ : deps, seedContext);
557
1164
  }
558
1165
  /** List all YAML recipes in a directory. Returns names. */
559
1166
  export function listYamlRecipes(recipesDir) {