patchwork-os 0.2.0-beta.1 → 0.2.0-beta.10.canary.100

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 (701) hide show
  1. package/README.bridge.md +14 -14
  2. package/README.md +201 -34
  3. package/deploy/README.md +1 -1
  4. package/deploy/bootstrap-vps.sh +14 -9
  5. package/deploy/deploy-dashboard.sh +25 -1
  6. package/deploy/macos/README.md +153 -0
  7. package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
  8. package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
  9. package/deploy/macos/install-mac-bridge.sh +244 -0
  10. package/deploy/macos/uninstall-mac-bridge.sh +22 -0
  11. package/dist/activityLog.d.ts +6 -0
  12. package/dist/activityLog.js +63 -26
  13. package/dist/activityLog.js.map +1 -1
  14. package/dist/adapters/claude.js +22 -16
  15. package/dist/adapters/claude.js.map +1 -1
  16. package/dist/adapters/gemini.js +15 -11
  17. package/dist/adapters/gemini.js.map +1 -1
  18. package/dist/adapters/openai.js +9 -9
  19. package/dist/adapters/openai.js.map +1 -1
  20. package/dist/ajv2020.d.ts +25 -0
  21. package/dist/ajv2020.js +33 -0
  22. package/dist/ajv2020.js.map +1 -0
  23. package/dist/analyticsAggregator.js +1 -1
  24. package/dist/analyticsAggregator.js.map +1 -1
  25. package/dist/analyticsConfig.d.ts +29 -0
  26. package/dist/analyticsConfig.js +102 -0
  27. package/dist/analyticsConfig.js.map +1 -0
  28. package/dist/analyticsPrefs.d.ts +38 -2
  29. package/dist/analyticsPrefs.js +148 -23
  30. package/dist/analyticsPrefs.js.map +1 -1
  31. package/dist/analyticsSend.d.ts +17 -1
  32. package/dist/analyticsSend.js +67 -5
  33. package/dist/analyticsSend.js.map +1 -1
  34. package/dist/approvalHttp.d.ts +14 -0
  35. package/dist/approvalHttp.js +212 -9
  36. package/dist/approvalHttp.js.map +1 -1
  37. package/dist/approvalInsights.js +13 -5
  38. package/dist/approvalInsights.js.map +1 -1
  39. package/dist/approvalQueue.d.ts +97 -3
  40. package/dist/approvalQueue.js +193 -19
  41. package/dist/approvalQueue.js.map +1 -1
  42. package/dist/approvalSignals.js +19 -11
  43. package/dist/approvalSignals.js.map +1 -1
  44. package/dist/automation.d.ts +35 -10
  45. package/dist/automation.js +133 -37
  46. package/dist/automation.js.map +1 -1
  47. package/dist/automationSuggestions.js +10 -8
  48. package/dist/automationSuggestions.js.map +1 -1
  49. package/dist/bridge.d.ts +2 -0
  50. package/dist/bridge.js +219 -35
  51. package/dist/bridge.js.map +1 -1
  52. package/dist/bridgeLockDiscovery.d.ts +27 -1
  53. package/dist/bridgeLockDiscovery.js +38 -11
  54. package/dist/bridgeLockDiscovery.js.map +1 -1
  55. package/dist/bridgeToken.js +3 -2
  56. package/dist/bridgeToken.js.map +1 -1
  57. package/dist/claudeDriver.js +23 -8
  58. package/dist/claudeDriver.js.map +1 -1
  59. package/dist/claudeOrchestrator.d.ts +1 -1
  60. package/dist/claudeOrchestrator.js +87 -44
  61. package/dist/claudeOrchestrator.js.map +1 -1
  62. package/dist/commands/analytics.d.ts +8 -0
  63. package/dist/commands/analytics.js +134 -0
  64. package/dist/commands/analytics.js.map +1 -0
  65. package/dist/commands/auditEnv.d.ts +36 -0
  66. package/dist/commands/auditEnv.js +202 -0
  67. package/dist/commands/auditEnv.js.map +1 -0
  68. package/dist/commands/connect.d.ts +47 -0
  69. package/dist/commands/connect.js +419 -0
  70. package/dist/commands/connect.js.map +1 -0
  71. package/dist/commands/dashboard.js +8 -1
  72. package/dist/commands/dashboard.js.map +1 -1
  73. package/dist/commands/doctor.d.ts +35 -0
  74. package/dist/commands/doctor.js +51 -0
  75. package/dist/commands/doctor.js.map +1 -0
  76. package/dist/commands/install.js +3 -0
  77. package/dist/commands/install.js.map +1 -1
  78. package/dist/commands/launchd.js +15 -16
  79. package/dist/commands/launchd.js.map +1 -1
  80. package/dist/commands/patchworkInit.d.ts +5 -0
  81. package/dist/commands/patchworkInit.js +89 -7
  82. package/dist/commands/patchworkInit.js.map +1 -1
  83. package/dist/commands/recipe.d.ts +151 -0
  84. package/dist/commands/recipe.js +649 -9
  85. package/dist/commands/recipe.js.map +1 -1
  86. package/dist/commands/recipeInstall.js +11 -4
  87. package/dist/commands/recipeInstall.js.map +1 -1
  88. package/dist/commands/shadowScan.d.ts +34 -0
  89. package/dist/commands/shadowScan.js +142 -0
  90. package/dist/commands/shadowScan.js.map +1 -0
  91. package/dist/commands/task.d.ts +25 -0
  92. package/dist/commands/task.js +62 -42
  93. package/dist/commands/task.js.map +1 -1
  94. package/dist/commands/tools.d.ts +20 -1
  95. package/dist/commands/tools.js +112 -3
  96. package/dist/commands/tools.js.map +1 -1
  97. package/dist/commands/tracesExport.d.ts +15 -1
  98. package/dist/commands/tracesExport.js +39 -5
  99. package/dist/commands/tracesExport.js.map +1 -1
  100. package/dist/commands/tracesImport.js +21 -4
  101. package/dist/commands/tracesImport.js.map +1 -1
  102. package/dist/commitIssueLinkLog.d.ts +16 -0
  103. package/dist/commitIssueLinkLog.js +110 -24
  104. package/dist/commitIssueLinkLog.js.map +1 -1
  105. package/dist/companions/registry.js +15 -7
  106. package/dist/companions/registry.js.map +1 -1
  107. package/dist/config.d.ts +47 -3
  108. package/dist/config.js +111 -21
  109. package/dist/config.js.map +1 -1
  110. package/dist/connectorRoutes.js +1422 -57
  111. package/dist/connectorRoutes.js.map +1 -1
  112. package/dist/connectors/airtable.d.ts +230 -0
  113. package/dist/connectors/airtable.js +700 -0
  114. package/dist/connectors/airtable.js.map +1 -0
  115. package/dist/connectors/asana.js +13 -9
  116. package/dist/connectors/asana.js.map +1 -1
  117. package/dist/connectors/baseConnector.js +25 -3
  118. package/dist/connectors/baseConnector.js.map +1 -1
  119. package/dist/connectors/caldiy.d.ts +137 -0
  120. package/dist/connectors/caldiy.js +424 -0
  121. package/dist/connectors/caldiy.js.map +1 -0
  122. package/dist/connectors/circleci.d.ts +162 -0
  123. package/dist/connectors/circleci.js +576 -0
  124. package/dist/connectors/circleci.js.map +1 -0
  125. package/dist/connectors/cloudflare.d.ts +132 -0
  126. package/dist/connectors/cloudflare.js +622 -0
  127. package/dist/connectors/cloudflare.js.map +1 -0
  128. package/dist/connectors/confluence.js +35 -0
  129. package/dist/connectors/confluence.js.map +1 -1
  130. package/dist/connectors/connectorRedirectUri.d.ts +30 -0
  131. package/dist/connectors/connectorRedirectUri.js +38 -0
  132. package/dist/connectors/connectorRedirectUri.js.map +1 -0
  133. package/dist/connectors/connectorRegistry.d.ts +60 -0
  134. package/dist/connectors/connectorRegistry.js +354 -0
  135. package/dist/connectors/connectorRegistry.js.map +1 -0
  136. package/dist/connectors/datadog.js +41 -5
  137. package/dist/connectors/datadog.js.map +1 -1
  138. package/dist/connectors/discord.js +14 -10
  139. package/dist/connectors/discord.js.map +1 -1
  140. package/dist/connectors/elasticsearch.d.ts +141 -0
  141. package/dist/connectors/elasticsearch.js +601 -0
  142. package/dist/connectors/elasticsearch.js.map +1 -0
  143. package/dist/connectors/figma.d.ts +130 -0
  144. package/dist/connectors/figma.js +387 -0
  145. package/dist/connectors/figma.js.map +1 -0
  146. package/dist/connectors/github.d.ts +17 -0
  147. package/dist/connectors/github.js +53 -2
  148. package/dist/connectors/github.js.map +1 -1
  149. package/dist/connectors/gitlab.js +12 -6
  150. package/dist/connectors/gitlab.js.map +1 -1
  151. package/dist/connectors/gmail.js +72 -21
  152. package/dist/connectors/gmail.js.map +1 -1
  153. package/dist/connectors/googleCalendar.js +8 -8
  154. package/dist/connectors/googleCalendar.js.map +1 -1
  155. package/dist/connectors/googleDocs.d.ts +103 -0
  156. package/dist/connectors/googleDocs.js +409 -0
  157. package/dist/connectors/googleDocs.js.map +1 -0
  158. package/dist/connectors/googleDrive.d.ts +12 -0
  159. package/dist/connectors/googleDrive.js +35 -8
  160. package/dist/connectors/googleDrive.js.map +1 -1
  161. package/dist/connectors/grafana.d.ts +133 -0
  162. package/dist/connectors/grafana.js +478 -0
  163. package/dist/connectors/grafana.js.map +1 -0
  164. package/dist/connectors/jira.d.ts +25 -0
  165. package/dist/connectors/jira.js +234 -3
  166. package/dist/connectors/jira.js.map +1 -1
  167. package/dist/connectors/linear.js +1 -1
  168. package/dist/connectors/linear.js.map +1 -1
  169. package/dist/connectors/mcpClient.js +37 -23
  170. package/dist/connectors/mcpClient.js.map +1 -1
  171. package/dist/connectors/mcpOAuth.js +78 -16
  172. package/dist/connectors/mcpOAuth.js.map +1 -1
  173. package/dist/connectors/monday.d.ts +217 -0
  174. package/dist/connectors/monday.js +655 -0
  175. package/dist/connectors/monday.js.map +1 -0
  176. package/dist/connectors/mongodb.d.ts +139 -0
  177. package/dist/connectors/mongodb.js +455 -0
  178. package/dist/connectors/mongodb.js.map +1 -0
  179. package/dist/connectors/notion.d.ts +1 -1
  180. package/dist/connectors/notion.js +13 -4
  181. package/dist/connectors/notion.js.map +1 -1
  182. package/dist/connectors/oauthStateStore.d.ts +20 -0
  183. package/dist/connectors/oauthStateStore.js +94 -4
  184. package/dist/connectors/oauthStateStore.js.map +1 -1
  185. package/dist/connectors/obsidian.d.ts +83 -0
  186. package/dist/connectors/obsidian.js +441 -0
  187. package/dist/connectors/obsidian.js.map +1 -0
  188. package/dist/connectors/paystack.d.ts +159 -0
  189. package/dist/connectors/paystack.js +607 -0
  190. package/dist/connectors/paystack.js.map +1 -0
  191. package/dist/connectors/pipedrive.d.ts +189 -0
  192. package/dist/connectors/pipedrive.js +559 -0
  193. package/dist/connectors/pipedrive.js.map +1 -0
  194. package/dist/connectors/postgres.d.ts +127 -0
  195. package/dist/connectors/postgres.js +512 -0
  196. package/dist/connectors/postgres.js.map +1 -0
  197. package/dist/connectors/posthog.d.ts +119 -0
  198. package/dist/connectors/posthog.js +479 -0
  199. package/dist/connectors/posthog.js.map +1 -0
  200. package/dist/connectors/redis.d.ts +140 -0
  201. package/dist/connectors/redis.js +579 -0
  202. package/dist/connectors/redis.js.map +1 -0
  203. package/dist/connectors/resend.d.ts +131 -0
  204. package/dist/connectors/resend.js +529 -0
  205. package/dist/connectors/resend.js.map +1 -0
  206. package/dist/connectors/salesforce.d.ts +136 -0
  207. package/dist/connectors/salesforce.js +603 -0
  208. package/dist/connectors/salesforce.js.map +1 -0
  209. package/dist/connectors/secrets.d.ts +51 -0
  210. package/dist/connectors/secrets.js +102 -0
  211. package/dist/connectors/secrets.js.map +1 -0
  212. package/dist/connectors/sendgrid.d.ts +102 -0
  213. package/dist/connectors/sendgrid.js +423 -0
  214. package/dist/connectors/sendgrid.js.map +1 -0
  215. package/dist/connectors/shopify.d.ts +145 -0
  216. package/dist/connectors/shopify.js +502 -0
  217. package/dist/connectors/shopify.js.map +1 -0
  218. package/dist/connectors/slack.d.ts +1 -1
  219. package/dist/connectors/slack.js +61 -9
  220. package/dist/connectors/slack.js.map +1 -1
  221. package/dist/connectors/snowflake.d.ts +119 -0
  222. package/dist/connectors/snowflake.js +633 -0
  223. package/dist/connectors/snowflake.js.map +1 -0
  224. package/dist/connectors/supabase.d.ts +92 -0
  225. package/dist/connectors/supabase.js +630 -0
  226. package/dist/connectors/supabase.js.map +1 -0
  227. package/dist/connectors/todoist.d.ts +117 -0
  228. package/dist/connectors/todoist.js +485 -0
  229. package/dist/connectors/todoist.js.map +1 -0
  230. package/dist/connectors/tokenStorage.js +56 -14
  231. package/dist/connectors/tokenStorage.js.map +1 -1
  232. package/dist/connectors/twilio.d.ts +118 -0
  233. package/dist/connectors/twilio.js +475 -0
  234. package/dist/connectors/twilio.js.map +1 -0
  235. package/dist/connectors/vercel.d.ts +110 -0
  236. package/dist/connectors/vercel.js +479 -0
  237. package/dist/connectors/vercel.js.map +1 -0
  238. package/dist/connectors/webflow.d.ts +118 -0
  239. package/dist/connectors/webflow.js +393 -0
  240. package/dist/connectors/webflow.js.map +1 -0
  241. package/dist/connectors/woocommerce.d.ts +220 -0
  242. package/dist/connectors/woocommerce.js +464 -0
  243. package/dist/connectors/woocommerce.js.map +1 -0
  244. package/dist/crypto.js +7 -2
  245. package/dist/crypto.js.map +1 -1
  246. package/dist/decisionReplay.d.ts +8 -6
  247. package/dist/decisionReplay.js +50 -17
  248. package/dist/decisionReplay.js.map +1 -1
  249. package/dist/decisionTraceLog.d.ts +28 -0
  250. package/dist/decisionTraceLog.js +156 -29
  251. package/dist/decisionTraceLog.js.map +1 -1
  252. package/dist/drivers/claude/api.js +33 -9
  253. package/dist/drivers/claude/api.js.map +1 -1
  254. package/dist/drivers/claude/envSanitizer.d.ts +0 -6
  255. package/dist/drivers/claude/envSanitizer.js +17 -2
  256. package/dist/drivers/claude/envSanitizer.js.map +1 -1
  257. package/dist/drivers/claude/streamParser.js +5 -4
  258. package/dist/drivers/claude/streamParser.js.map +1 -1
  259. package/dist/drivers/claude/subprocess.js +69 -16
  260. package/dist/drivers/claude/subprocess.js.map +1 -1
  261. package/dist/drivers/gemini/index.d.ts +22 -0
  262. package/dist/drivers/gemini/index.js +256 -129
  263. package/dist/drivers/gemini/index.js.map +1 -1
  264. package/dist/drivers/local/index.d.ts +2 -0
  265. package/dist/drivers/local/index.js +12 -0
  266. package/dist/drivers/local/index.js.map +1 -1
  267. package/dist/drivers/openai/index.js +30 -2
  268. package/dist/drivers/openai/index.js.map +1 -1
  269. package/dist/extensionClient.d.ts +37 -4
  270. package/dist/extensionClient.js +58 -16
  271. package/dist/extensionClient.js.map +1 -1
  272. package/dist/featureFlags.d.ts +91 -0
  273. package/dist/featureFlags.js +174 -3
  274. package/dist/featureFlags.js.map +1 -1
  275. package/dist/fileLock.js +21 -12
  276. package/dist/fileLock.js.map +1 -1
  277. package/dist/fileLockSync.d.ts +67 -0
  278. package/dist/fileLockSync.js +126 -0
  279. package/dist/fileLockSync.js.map +1 -0
  280. package/dist/fp/activityAnalytics.js +26 -12
  281. package/dist/fp/activityAnalytics.js.map +1 -1
  282. package/dist/fp/automationInterpreter.d.ts +15 -1
  283. package/dist/fp/automationInterpreter.js +267 -87
  284. package/dist/fp/automationInterpreter.js.map +1 -1
  285. package/dist/fp/automationProgram.d.ts +30 -0
  286. package/dist/fp/automationProgram.js.map +1 -1
  287. package/dist/fp/automationState.d.ts +24 -5
  288. package/dist/fp/automationState.js +56 -9
  289. package/dist/fp/automationState.js.map +1 -1
  290. package/dist/fp/automationUtils.js +1 -1
  291. package/dist/fp/automationUtils.js.map +1 -1
  292. package/dist/fp/commandDescription.js +7 -1
  293. package/dist/fp/commandDescription.js.map +1 -1
  294. package/dist/fp/extensionSnapshot.js +8 -2
  295. package/dist/fp/extensionSnapshot.js.map +1 -1
  296. package/dist/fp/interpreterContext.d.ts +66 -1
  297. package/dist/fp/interpreterContext.js +91 -1
  298. package/dist/fp/interpreterContext.js.map +1 -1
  299. package/dist/fp/policyParser.js +34 -2
  300. package/dist/fp/policyParser.js.map +1 -1
  301. package/dist/fsWatchWithFallback.d.ts +36 -0
  302. package/dist/fsWatchWithFallback.js +128 -0
  303. package/dist/fsWatchWithFallback.js.map +1 -0
  304. package/dist/haltPushDispatch.d.ts +33 -0
  305. package/dist/haltPushDispatch.js +103 -0
  306. package/dist/haltPushDispatch.js.map +1 -0
  307. package/dist/httpBodyValidation.d.ts +41 -0
  308. package/dist/httpBodyValidation.js +45 -0
  309. package/dist/httpBodyValidation.js.map +1 -0
  310. package/dist/inboxRoutes.d.ts +22 -0
  311. package/dist/inboxRoutes.js +61 -1
  312. package/dist/inboxRoutes.js.map +1 -1
  313. package/dist/index.js +1206 -158
  314. package/dist/index.js.map +1 -1
  315. package/dist/installGuard.js +6 -2
  316. package/dist/installGuard.js.map +1 -1
  317. package/dist/localEndpointGuard.d.ts +25 -0
  318. package/dist/localEndpointGuard.js +101 -0
  319. package/dist/localEndpointGuard.js.map +1 -0
  320. package/dist/lockfile.js +31 -4
  321. package/dist/lockfile.js.map +1 -1
  322. package/dist/oauth.d.ts +9 -0
  323. package/dist/oauth.js +33 -0
  324. package/dist/oauth.js.map +1 -1
  325. package/dist/oauthRoutes.d.ts +1 -1
  326. package/dist/oauthRoutes.js +5 -3
  327. package/dist/oauthRoutes.js.map +1 -1
  328. package/dist/orchestrator/childBridgeClient.d.ts +5 -0
  329. package/dist/orchestrator/childBridgeClient.js +28 -1
  330. package/dist/orchestrator/childBridgeClient.js.map +1 -1
  331. package/dist/orchestrator/childBridgeRegistry.js +29 -11
  332. package/dist/orchestrator/childBridgeRegistry.js.map +1 -1
  333. package/dist/orchestrator/orchestratorBridge.d.ts +33 -0
  334. package/dist/orchestrator/orchestratorBridge.js +70 -9
  335. package/dist/orchestrator/orchestratorBridge.js.map +1 -1
  336. package/dist/patchworkConfig.d.ts +44 -1
  337. package/dist/patchworkConfig.js +28 -3
  338. package/dist/patchworkConfig.js.map +1 -1
  339. package/dist/pluginLoader.js +10 -1
  340. package/dist/pluginLoader.js.map +1 -1
  341. package/dist/pluginWatcher.js +12 -14
  342. package/dist/pluginWatcher.js.map +1 -1
  343. package/dist/preToolUseHook.js +3 -2
  344. package/dist/preToolUseHook.js.map +1 -1
  345. package/dist/processTree.d.ts +34 -0
  346. package/dist/processTree.js +105 -0
  347. package/dist/processTree.js.map +1 -0
  348. package/dist/prompts.js +3 -3
  349. package/dist/prompts.js.map +1 -1
  350. package/dist/recipeOrchestration.d.ts +9 -0
  351. package/dist/recipeOrchestration.js +341 -24
  352. package/dist/recipeOrchestration.js.map +1 -1
  353. package/dist/recipeRoutes.d.ts +58 -2
  354. package/dist/recipeRoutes.js +744 -115
  355. package/dist/recipeRoutes.js.map +1 -1
  356. package/dist/recipes/RecipeOrchestrator.d.ts +2 -0
  357. package/dist/recipes/RecipeOrchestrator.js +6 -1
  358. package/dist/recipes/RecipeOrchestrator.js.map +1 -1
  359. package/dist/recipes/agentExecutor.d.ts +25 -5
  360. package/dist/recipes/agentExecutor.js.map +1 -1
  361. package/dist/recipes/chainedRunner.d.ts +2 -0
  362. package/dist/recipes/chainedRunner.js +156 -9
  363. package/dist/recipes/chainedRunner.js.map +1 -1
  364. package/dist/recipes/compiler.js +9 -5
  365. package/dist/recipes/compiler.js.map +1 -1
  366. package/dist/recipes/connectorPreflight.d.ts +66 -0
  367. package/dist/recipes/connectorPreflight.js +169 -0
  368. package/dist/recipes/connectorPreflight.js.map +1 -0
  369. package/dist/recipes/dependencyGraph.d.ts +9 -0
  370. package/dist/recipes/dependencyGraph.js +32 -7
  371. package/dist/recipes/dependencyGraph.js.map +1 -1
  372. package/dist/recipes/githubInstallSource.d.ts +135 -0
  373. package/dist/recipes/githubInstallSource.js +236 -0
  374. package/dist/recipes/githubInstallSource.js.map +1 -0
  375. package/dist/recipes/haltCategory.d.ts +119 -0
  376. package/dist/recipes/haltCategory.js +188 -0
  377. package/dist/recipes/haltCategory.js.map +1 -0
  378. package/dist/recipes/idempotencyKey.d.ts +154 -0
  379. package/dist/recipes/idempotencyKey.js +375 -0
  380. package/dist/recipes/idempotencyKey.js.map +1 -0
  381. package/dist/recipes/installer.js +58 -3
  382. package/dist/recipes/installer.js.map +1 -1
  383. package/dist/recipes/judgeSummary.d.ts +50 -0
  384. package/dist/recipes/judgeSummary.js +47 -0
  385. package/dist/recipes/judgeSummary.js.map +1 -0
  386. package/dist/recipes/judgeVerdict.d.ts +48 -0
  387. package/dist/recipes/judgeVerdict.js +174 -0
  388. package/dist/recipes/judgeVerdict.js.map +1 -0
  389. package/dist/recipes/migrations/index.d.ts +9 -0
  390. package/dist/recipes/migrations/index.js +133 -0
  391. package/dist/recipes/migrations/index.js.map +1 -1
  392. package/dist/recipes/names.d.ts +20 -0
  393. package/dist/recipes/names.js +25 -0
  394. package/dist/recipes/names.js.map +1 -1
  395. package/dist/recipes/parser.js +116 -6
  396. package/dist/recipes/parser.js.map +1 -1
  397. package/dist/recipes/replayRun.js +1 -1
  398. package/dist/recipes/replayRun.js.map +1 -1
  399. package/dist/recipes/runBudget.d.ts +70 -0
  400. package/dist/recipes/runBudget.js +109 -0
  401. package/dist/recipes/runBudget.js.map +1 -0
  402. package/dist/recipes/scheduler.d.ts +30 -0
  403. package/dist/recipes/scheduler.js +69 -19
  404. package/dist/recipes/scheduler.js.map +1 -1
  405. package/dist/recipes/schema.d.ts +65 -2
  406. package/dist/recipes/schemaGenerator.js +119 -7
  407. package/dist/recipes/schemaGenerator.js.map +1 -1
  408. package/dist/recipes/stepObservation.js +9 -0
  409. package/dist/recipes/stepObservation.js.map +1 -1
  410. package/dist/recipes/toolRegistry.js +33 -7
  411. package/dist/recipes/toolRegistry.js.map +1 -1
  412. package/dist/recipes/tools/airtable.d.ts +15 -0
  413. package/dist/recipes/tools/airtable.js +239 -0
  414. package/dist/recipes/tools/airtable.js.map +1 -0
  415. package/dist/recipes/tools/fanOut.d.ts +20 -0
  416. package/dist/recipes/tools/fanOut.js +199 -0
  417. package/dist/recipes/tools/fanOut.js.map +1 -0
  418. package/dist/recipes/tools/file.js +5 -2
  419. package/dist/recipes/tools/file.js.map +1 -1
  420. package/dist/recipes/tools/github.d.ts +1 -1
  421. package/dist/recipes/tools/github.js +75 -1
  422. package/dist/recipes/tools/github.js.map +1 -1
  423. package/dist/recipes/tools/gmail.js +27 -5
  424. package/dist/recipes/tools/gmail.js.map +1 -1
  425. package/dist/recipes/tools/googleDrive.js +64 -0
  426. package/dist/recipes/tools/googleDrive.js.map +1 -1
  427. package/dist/recipes/tools/http.d.ts +10 -0
  428. package/dist/recipes/tools/http.js +176 -0
  429. package/dist/recipes/tools/http.js.map +1 -0
  430. package/dist/recipes/tools/index.d.ts +7 -0
  431. package/dist/recipes/tools/index.js +7 -0
  432. package/dist/recipes/tools/index.js.map +1 -1
  433. package/dist/recipes/tools/resend.d.ts +8 -0
  434. package/dist/recipes/tools/resend.js +152 -0
  435. package/dist/recipes/tools/resend.js.map +1 -0
  436. package/dist/recipes/tools/sendgrid.d.ts +9 -0
  437. package/dist/recipes/tools/sendgrid.js +174 -0
  438. package/dist/recipes/tools/sendgrid.js.map +1 -0
  439. package/dist/recipes/tools/slack.js +1 -1
  440. package/dist/recipes/tools/twilio.d.ts +11 -0
  441. package/dist/recipes/tools/twilio.js +179 -0
  442. package/dist/recipes/tools/twilio.js.map +1 -0
  443. package/dist/recipes/tools/vercel.d.ts +9 -0
  444. package/dist/recipes/tools/vercel.js +145 -0
  445. package/dist/recipes/tools/vercel.js.map +1 -0
  446. package/dist/recipes/validation.d.ts +17 -0
  447. package/dist/recipes/validation.js +141 -11
  448. package/dist/recipes/validation.js.map +1 -1
  449. package/dist/recipes/workspaceRoot.d.ts +37 -0
  450. package/dist/recipes/workspaceRoot.js +73 -0
  451. package/dist/recipes/workspaceRoot.js.map +1 -0
  452. package/dist/recipes/yamlPositions.d.ts +56 -0
  453. package/dist/recipes/yamlPositions.js +183 -0
  454. package/dist/recipes/yamlPositions.js.map +1 -0
  455. package/dist/recipes/yamlRunner.d.ts +185 -8
  456. package/dist/recipes/yamlRunner.js +967 -224
  457. package/dist/recipes/yamlRunner.js.map +1 -1
  458. package/dist/recipesHttp.d.ts +19 -3
  459. package/dist/recipesHttp.js +67 -11
  460. package/dist/recipesHttp.js.map +1 -1
  461. package/dist/resources.js +21 -13
  462. package/dist/resources.js.map +1 -1
  463. package/dist/runLog.d.ts +58 -5
  464. package/dist/runLog.js +98 -21
  465. package/dist/runLog.js.map +1 -1
  466. package/dist/sanitizeParsedJson.d.ts +39 -0
  467. package/dist/sanitizeParsedJson.js +55 -0
  468. package/dist/sanitizeParsedJson.js.map +1 -0
  469. package/dist/schemas/recipe.v1.json +887 -198
  470. package/dist/server.d.ts +160 -3
  471. package/dist/server.js +1099 -122
  472. package/dist/server.js.map +1 -1
  473. package/dist/sessionCheckpoint.d.ts +8 -0
  474. package/dist/sessionCheckpoint.js +33 -3
  475. package/dist/sessionCheckpoint.js.map +1 -1
  476. package/dist/ssrfGuard.d.ts +16 -0
  477. package/dist/ssrfGuard.js +87 -4
  478. package/dist/ssrfGuard.js.map +1 -1
  479. package/dist/streamableHttp.d.ts +9 -4
  480. package/dist/streamableHttp.js +76 -20
  481. package/dist/streamableHttp.js.map +1 -1
  482. package/dist/telemetry.js +37 -19
  483. package/dist/telemetry.js.map +1 -1
  484. package/dist/testing/shadowRun.d.ts +52 -0
  485. package/dist/testing/shadowRun.js +71 -0
  486. package/dist/testing/shadowRun.js.map +1 -0
  487. package/dist/tools/batchLsp.d.ts +3 -0
  488. package/dist/tools/bridgeDoctor.d.ts +11 -0
  489. package/dist/tools/bridgeDoctor.js +48 -2
  490. package/dist/tools/bridgeDoctor.js.map +1 -1
  491. package/dist/tools/bridgeStatus.d.ts +0 -12
  492. package/dist/tools/bridgeStatus.js +2 -28
  493. package/dist/tools/bridgeStatus.js.map +1 -1
  494. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  495. package/dist/tools/clipboard.d.ts +2 -0
  496. package/dist/tools/closeTabs.d.ts +1 -0
  497. package/dist/tools/codeLens.d.ts +1 -0
  498. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  499. package/dist/tools/ctxGetTaskContext.d.ts +9 -0
  500. package/dist/tools/ctxGetTaskContext.js +50 -2
  501. package/dist/tools/ctxGetTaskContext.js.map +1 -1
  502. package/dist/tools/ctxQueryTraces.js +32 -24
  503. package/dist/tools/ctxQueryTraces.js.map +1 -1
  504. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  505. package/dist/tools/debug.d.ts +4 -0
  506. package/dist/tools/decorations.d.ts +2 -0
  507. package/dist/tools/detectUnusedCode.js +9 -7
  508. package/dist/tools/detectUnusedCode.js.map +1 -1
  509. package/dist/tools/documentLinks.d.ts +1 -0
  510. package/dist/tools/editText.d.ts +1 -0
  511. package/dist/tools/editText.js +2 -1
  512. package/dist/tools/editText.js.map +1 -1
  513. package/dist/tools/enrichCommit.d.ts +1 -0
  514. package/dist/tools/enrichStackTrace.js +11 -5
  515. package/dist/tools/enrichStackTrace.js.map +1 -1
  516. package/dist/tools/explainDiagnostic.d.ts +1 -0
  517. package/dist/tools/explainSymbol.d.ts +1 -0
  518. package/dist/tools/fileOperations.d.ts +3 -0
  519. package/dist/tools/fileOperations.js +2 -1
  520. package/dist/tools/fileOperations.js.map +1 -1
  521. package/dist/tools/fileWatcher.d.ts +2 -0
  522. package/dist/tools/fileWatcher.js +8 -2
  523. package/dist/tools/fileWatcher.js.map +1 -1
  524. package/dist/tools/findFiles.d.ts +1 -0
  525. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  526. package/dist/tools/fixAllLintErrors.js +10 -5
  527. package/dist/tools/fixAllLintErrors.js.map +1 -1
  528. package/dist/tools/foldingRanges.d.ts +1 -0
  529. package/dist/tools/formatDocument.d.ts +1 -0
  530. package/dist/tools/formatDocument.js +10 -5
  531. package/dist/tools/formatDocument.js.map +1 -1
  532. package/dist/tools/generateTests.d.ts +1 -0
  533. package/dist/tools/getAIComments.d.ts +1 -0
  534. package/dist/tools/getAnalyticsReport.js +5 -1
  535. package/dist/tools/getAnalyticsReport.js.map +1 -1
  536. package/dist/tools/getBufferContent.d.ts +1 -0
  537. package/dist/tools/getChangeImpact.d.ts +1 -0
  538. package/dist/tools/getChangeImpact.js +23 -14
  539. package/dist/tools/getChangeImpact.js.map +1 -1
  540. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  541. package/dist/tools/getCodeCoverage.d.ts +1 -0
  542. package/dist/tools/getCodeCoverage.js +32 -14
  543. package/dist/tools/getCodeCoverage.js.map +1 -1
  544. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  545. package/dist/tools/getDebugState.d.ts +1 -0
  546. package/dist/tools/getDiagnostics.js +32 -29
  547. package/dist/tools/getDiagnostics.js.map +1 -1
  548. package/dist/tools/getDiffFromHandoff.js +8 -2
  549. package/dist/tools/getDiffFromHandoff.js.map +1 -1
  550. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  551. package/dist/tools/getGitHotspots.d.ts +1 -0
  552. package/dist/tools/getGitStatus.js +6 -1
  553. package/dist/tools/getGitStatus.js.map +1 -1
  554. package/dist/tools/getImportedSignatures.d.ts +1 -0
  555. package/dist/tools/getImportedSignatures.js +18 -14
  556. package/dist/tools/getImportedSignatures.js.map +1 -1
  557. package/dist/tools/getPRTemplate.d.ts +1 -0
  558. package/dist/tools/getProjectContext.js +23 -10
  559. package/dist/tools/getProjectContext.js.map +1 -1
  560. package/dist/tools/getSymbolHistory.d.ts +1 -0
  561. package/dist/tools/getToolCapabilities.d.ts +10 -5
  562. package/dist/tools/getToolCapabilities.js +116 -204
  563. package/dist/tools/getToolCapabilities.js.map +1 -1
  564. package/dist/tools/getTypeSignature.d.ts +1 -0
  565. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  566. package/dist/tools/gitWrite.d.ts +11 -0
  567. package/dist/tools/github/actions.d.ts +2 -0
  568. package/dist/tools/github/composite.d.ts +4 -1
  569. package/dist/tools/github/composite.js +7 -7
  570. package/dist/tools/github/composite.js.map +1 -1
  571. package/dist/tools/github/issues.d.ts +4 -0
  572. package/dist/tools/github/pr.d.ts +13 -6
  573. package/dist/tools/github/pr.js +18 -11
  574. package/dist/tools/github/pr.js.map +1 -1
  575. package/dist/tools/handoffNote.d.ts +1 -0
  576. package/dist/tools/handoffNote.js +2 -1
  577. package/dist/tools/handoffNote.js.map +1 -1
  578. package/dist/tools/headless/lspClient.js +3 -0
  579. package/dist/tools/headless/lspClient.js.map +1 -1
  580. package/dist/tools/hoverAtCursor.d.ts +1 -0
  581. package/dist/tools/httpClient.d.ts +2 -0
  582. package/dist/tools/httpClient.js +38 -71
  583. package/dist/tools/httpClient.js.map +1 -1
  584. package/dist/tools/index.js +14 -10
  585. package/dist/tools/index.js.map +1 -1
  586. package/dist/tools/inlayHints.d.ts +1 -0
  587. package/dist/tools/launchQuickTask.d.ts +1 -0
  588. package/dist/tools/listClaudeTasks.d.ts +1 -0
  589. package/dist/tools/listClaudeTasks.js +10 -8
  590. package/dist/tools/listClaudeTasks.js.map +1 -1
  591. package/dist/tools/listTerminals.d.ts +1 -0
  592. package/dist/tools/lsp.d.ts +15 -0
  593. package/dist/tools/lsp.js +17 -0
  594. package/dist/tools/lsp.js.map +1 -1
  595. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  596. package/dist/tools/openDiff.d.ts +1 -0
  597. package/dist/tools/openDiff.js +4 -1
  598. package/dist/tools/openDiff.js.map +1 -1
  599. package/dist/tools/openFile.d.ts +1 -0
  600. package/dist/tools/openFile.js +4 -1
  601. package/dist/tools/openFile.js.map +1 -1
  602. package/dist/tools/openInBrowser.js +6 -1
  603. package/dist/tools/openInBrowser.js.map +1 -1
  604. package/dist/tools/organizeImports.d.ts +1 -0
  605. package/dist/tools/organizeImports.js +5 -3
  606. package/dist/tools/organizeImports.js.map +1 -1
  607. package/dist/tools/performanceReport.js +5 -3
  608. package/dist/tools/performanceReport.js.map +1 -1
  609. package/dist/tools/planPersistence.d.ts +3 -0
  610. package/dist/tools/planPersistence.js +4 -1
  611. package/dist/tools/planPersistence.js.map +1 -1
  612. package/dist/tools/previewEdit.d.ts +1 -0
  613. package/dist/tools/previewEdit.js +31 -5
  614. package/dist/tools/previewEdit.js.map +1 -1
  615. package/dist/tools/recentTracesDigest.js +54 -11
  616. package/dist/tools/recentTracesDigest.js.map +1 -1
  617. package/dist/tools/refactorAnalyze.d.ts +1 -0
  618. package/dist/tools/refactorExtractFunction.js +4 -1
  619. package/dist/tools/refactorExtractFunction.js.map +1 -1
  620. package/dist/tools/refactorPreview.d.ts +1 -0
  621. package/dist/tools/refactorPreview.js +10 -2
  622. package/dist/tools/refactorPreview.js.map +1 -1
  623. package/dist/tools/replaceBlock.d.ts +1 -0
  624. package/dist/tools/replaceBlock.js +2 -1
  625. package/dist/tools/replaceBlock.js.map +1 -1
  626. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  627. package/dist/tools/runClaudeTask.d.ts +1 -0
  628. package/dist/tools/runTests.js +15 -4
  629. package/dist/tools/runTests.js.map +1 -1
  630. package/dist/tools/screenshot.d.ts +1 -0
  631. package/dist/tools/screenshotAndAnnotate.js +6 -2
  632. package/dist/tools/screenshotAndAnnotate.js.map +1 -1
  633. package/dist/tools/searchAndReplace.d.ts +1 -0
  634. package/dist/tools/searchAndReplace.js +21 -12
  635. package/dist/tools/searchAndReplace.js.map +1 -1
  636. package/dist/tools/searchTools.d.ts +26 -0
  637. package/dist/tools/searchTools.js +62 -21
  638. package/dist/tools/searchTools.js.map +1 -1
  639. package/dist/tools/searchWorkspace.d.ts +1 -0
  640. package/dist/tools/selectionRanges.d.ts +1 -0
  641. package/dist/tools/semanticTokens.d.ts +1 -0
  642. package/dist/tools/signatureHelp.d.ts +1 -0
  643. package/dist/tools/spawnWorkspace.js +15 -7
  644. package/dist/tools/spawnWorkspace.js.map +1 -1
  645. package/dist/tools/terminal.d.ts +6 -0
  646. package/dist/tools/testRunners/pytest.js +6 -2
  647. package/dist/tools/testRunners/pytest.js.map +1 -1
  648. package/dist/tools/testRunners/vitestJest.js +3 -1
  649. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  650. package/dist/tools/testTraceToSource.d.ts +1 -0
  651. package/dist/tools/transaction.d.ts +13 -1
  652. package/dist/tools/transaction.js +63 -5
  653. package/dist/tools/transaction.js.map +1 -1
  654. package/dist/tools/typeHierarchy.d.ts +1 -0
  655. package/dist/tools/utils.d.ts +33 -0
  656. package/dist/tools/utils.js +122 -18
  657. package/dist/tools/utils.js.map +1 -1
  658. package/dist/tools/vscodeCommands.d.ts +2 -0
  659. package/dist/tools/vscodeTasks.d.ts +2 -0
  660. package/dist/tools/workspaceSettings.d.ts +1 -0
  661. package/dist/transport.d.ts +3 -1
  662. package/dist/transport.js +126 -52
  663. package/dist/transport.js.map +1 -1
  664. package/dist/winShim.d.ts +34 -0
  665. package/dist/winShim.js +94 -0
  666. package/dist/winShim.js.map +1 -0
  667. package/dist/wireHaltPushDispatch.d.ts +38 -0
  668. package/dist/wireHaltPushDispatch.js +71 -0
  669. package/dist/wireHaltPushDispatch.js.map +1 -0
  670. package/dist/writeFileAtomic.d.ts +23 -0
  671. package/dist/writeFileAtomic.js +121 -0
  672. package/dist/writeFileAtomic.js.map +1 -0
  673. package/package.json +23 -7
  674. package/scripts/postinstall.mjs +42 -2
  675. package/scripts/smoke/run-all.mjs +213 -0
  676. package/scripts/start-all.mjs +572 -0
  677. package/scripts/start-all.ps1 +209 -0
  678. package/scripts/start-all.sh +73 -17
  679. package/scripts/start-orchestrator.ps1 +158 -0
  680. package/scripts/start-remote.mjs +122 -0
  681. package/templates/automation-policies/recipe-authoring.json +1 -1
  682. package/templates/automation-policies/security-first.json +1 -1
  683. package/templates/automation-policies/strict-lint.json +1 -1
  684. package/templates/automation-policies/test-driven.json +1 -1
  685. package/templates/automation-policy.example.json +1 -1
  686. package/templates/co.patchwork-os.bridge.plist +2 -2
  687. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  688. package/templates/recipes/ctx-loop-test.yaml +1 -1
  689. package/templates/recipes/fix-errors-on-save.yaml +71 -0
  690. package/templates/recipes/morning-brief.yaml +5 -2
  691. package/templates/recipes/project-health-check.yaml +4 -1
  692. package/templates/recipes/sentry-to-linear.yaml +72 -38
  693. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  694. package/templates/recipes/webhook/customer-escalation.yaml +8 -9
  695. package/templates/recipes/webhook/meeting-prep.yaml +11 -5
  696. package/dist/commands/marketplace.d.ts +0 -16
  697. package/dist/commands/marketplace.js +0 -32
  698. package/dist/commands/marketplace.js.map +0 -1
  699. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  700. package/dist/recipes/legacyRecipeCompat.js +0 -131
  701. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -14,7 +14,10 @@
14
14
  * file.read — read file into `into` variable (optional: true ok)
15
15
  * git.log_since — run git log --oneline --since=<since> (injected for tests)
16
16
  * git.stale_branches — list branches with no activity in N days
17
- * diagnostics.get — stub: returns empty string (bridge not required)
17
+ * diagnostics.get — returns the bridge's LSP diagnostics; in CLI runs
18
+ * (no bridge available) returns a JSON {ok:false,error}
19
+ * payload that the step-error detector flags so the
20
+ * recipe halts rather than silently succeeding.
18
21
  *
19
22
  * Supported trigger types (for `patchwork recipe run`):
20
23
  * manual, cron — both run immediately via CLI
@@ -27,14 +30,30 @@ import path from "node:path";
27
30
  import { fileURLToPath } from "node:url";
28
31
  import { parse as parseYaml } from "yaml";
29
32
  import { captureFixture } from "../connectors/fixtureRecorder.js";
33
+ import { sanitizeEnv } from "../drivers/claude/envSanitizer.js";
34
+ import { isLoopbackOrPrivateEndpoint } from "../localEndpointGuard.js";
30
35
  import { loadConfig as loadPatchworkConfigSync } from "../patchworkConfig.js";
31
36
  import { findYamlRecipePath } from "../recipesHttp.js";
37
+ /**
38
+ * Local alias for `sanitizeParsedJson` from `src/sanitizeParsedJson.ts`.
39
+ * Kept under the old name so the existing callsites in this file don't
40
+ * need to be renamed. The shared module is the canonical home for the
41
+ * prototype-pollution scrub — see PR #568 + audit 2026-05-17 + the
42
+ * comment in that file for full rationale.
43
+ */
44
+ import { sanitizeParsedJson as sanitizeParsed } from "../sanitizeParsedJson.js";
45
+ import { ensureCmdShim } from "../winShim.js";
32
46
  import { executeAgent as _executeAgent, } from "./agentExecutor.js";
33
- import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./legacyRecipeCompat.js";
47
+ import { categoriseHaltReason } from "./haltCategory.js";
48
+ import { assertValidManualRunId, deriveScopeKey, WriteEffectLedger, } from "./idempotencyKey.js";
49
+ import { buildJudgeArtefactBlock, JUDGE_PROMPT_SUFFIX, parseJudgeVerdict, } from "./judgeVerdict.js";
50
+ import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./migrations/index.js";
34
51
  import { resolveRecipePath } from "./resolveRecipePath.js";
52
+ import { RunBudget } from "./runBudget.js";
35
53
  import { detectSilentFail } from "./stepObservation.js";
36
54
  // Import tool registry and trigger tool self-registration
37
55
  import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
56
+ import { resolveWorkspaceRoot } from "./workspaceRoot.js";
38
57
  import "./tools/index.js";
39
58
  /**
40
59
  * Bundled-templates directory used as a third allowed root for nested-recipe
@@ -111,7 +130,128 @@ export function evaluateExpect(result, expect) {
111
130
  }
112
131
  return failures;
113
132
  }
133
+ /**
134
+ * Lazy AJV for `step.expect.schema`. Initialised on first use so recipes
135
+ * without schema assertions don't pay the import/compile cost.
136
+ */
137
+ let _stepExpectAjv;
138
+ async function getStepExpectAjv() {
139
+ if (!_stepExpectAjv) {
140
+ const { createAjv2020 } = await import("../ajv2020.js");
141
+ _stepExpectAjv = createAjv2020({ strict: false, allErrors: true });
142
+ }
143
+ return _stepExpectAjv;
144
+ }
145
+ /**
146
+ * Stringify a step value for assertion purposes. Strings pass through;
147
+ * other values JSON.stringify so `matches`/`contains` see something stable.
148
+ */
149
+ function stringifyForAssert(value) {
150
+ if (typeof value === "string")
151
+ return value;
152
+ try {
153
+ return JSON.stringify(value);
154
+ }
155
+ catch {
156
+ return String(value);
157
+ }
158
+ }
159
+ /**
160
+ * Evaluate a per-step `expect` block against the step's output value.
161
+ * Returns the list of failure messages (empty = all assertions passed).
162
+ *
163
+ * Slice 2 of the agentic-workflow primitives. v1 supports
164
+ * schema/equals/matches/contains; `on_fail: judge` deliberately omitted —
165
+ * see comment on `StepExpect`.
166
+ */
167
+ export async function evaluateStepExpect(expect, value) {
168
+ const failures = [];
169
+ const asString = stringifyForAssert(value);
170
+ if (expect.equals !== undefined) {
171
+ const expected = expect.equals;
172
+ const expectedStr = typeof expected === "string" ? expected : stringifyForAssert(expected);
173
+ if (asString !== expectedStr) {
174
+ failures.push(`equals: expected ${JSON.stringify(expectedStr)}, got ${JSON.stringify(asString)}`);
175
+ }
176
+ }
177
+ if (expect.contains !== undefined) {
178
+ const needles = Array.isArray(expect.contains)
179
+ ? expect.contains
180
+ : [expect.contains];
181
+ for (const needle of needles) {
182
+ if (!asString.includes(needle)) {
183
+ failures.push(`contains: missing ${JSON.stringify(needle)}`);
184
+ }
185
+ }
186
+ }
187
+ if (expect.matches !== undefined) {
188
+ // Guard against ReDoS: limit pattern and input string length before
189
+ // compiling / executing user-supplied regex.
190
+ const MAX_PATTERN = 500;
191
+ const MAX_INPUT = 65_536; // 64 KB
192
+ if (expect.matches.length > MAX_PATTERN) {
193
+ failures.push(`matches: regex pattern too long (${expect.matches.length} chars, max ${MAX_PATTERN})`);
194
+ return failures;
195
+ }
196
+ let re;
197
+ try {
198
+ re = new RegExp(expect.matches);
199
+ }
200
+ catch (err) {
201
+ failures.push(`matches: invalid regex ${JSON.stringify(expect.matches)} (${err instanceof Error ? err.message : String(err)})`);
202
+ return failures;
203
+ }
204
+ const testInput = asString.length > MAX_INPUT ? asString.slice(0, MAX_INPUT) : asString;
205
+ if (!re.test(testInput)) {
206
+ failures.push(`matches: ${JSON.stringify(expect.matches)} did not match output`);
207
+ }
208
+ }
209
+ if (expect.schema !== undefined) {
210
+ let parsed;
211
+ try {
212
+ parsed = typeof value === "string" ? JSON.parse(value) : value;
213
+ }
214
+ catch {
215
+ failures.push(`schema: output is not valid JSON`);
216
+ return failures;
217
+ }
218
+ try {
219
+ const ajv = await getStepExpectAjv();
220
+ const validate = ajv.compile(expect.schema);
221
+ if (!validate(parsed)) {
222
+ const errs = (validate.errors ?? [])
223
+ .map((e) => `${e.instancePath || "/"} ${e.message ?? "invalid"}`)
224
+ .join("; ");
225
+ failures.push(`schema: ${errs || "validation failed"}`);
226
+ }
227
+ }
228
+ catch (err) {
229
+ failures.push(`schema: compile error (${err instanceof Error ? err.message : String(err)})`);
230
+ }
231
+ }
232
+ return failures;
233
+ }
114
234
  // Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
235
+ /**
236
+ * Phase 0β — separator-agnostic inbox-path detector. Extracted so the
237
+ * Windows path-separator behaviour can be unit-tested by injecting
238
+ * `path.win32` / `path.posix` without booting a real recipe runner.
239
+ *
240
+ * Returns true when `candidate` resolves to a direct child of
241
+ * `inboxDirAbs`, isn't a dotfile, and lives in (not above) the inbox
242
+ * dir. Both arguments must already be platform-appropriate absolute
243
+ * paths (resolve them with the same path module before calling).
244
+ */
245
+ export function isInboxPathFor(candidate, inboxDirAbs, pathMod) {
246
+ const target = pathMod.resolve(candidate);
247
+ const rel = pathMod.relative(inboxDirAbs, target);
248
+ if (!rel || rel.startsWith("..") || pathMod.isAbsolute(rel))
249
+ return false;
250
+ if (pathMod.basename(target).startsWith("."))
251
+ return false;
252
+ // Only direct children — `~/.patchwork/inbox/foo.md`, not nested.
253
+ return !rel.includes(pathMod.sep);
254
+ }
115
255
  function stripLeadingNarration(text) {
116
256
  const lines = text.split("\n");
117
257
  const firstMarkdown = lines.findIndex((l) => /^(#|>|`|\||[-*+] |\d+\. |\*\*)/.test(l.trimStart()));
@@ -222,7 +362,88 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
222
362
  ...envCtx,
223
363
  ...seedContext,
224
364
  };
225
- const stepDeps = resolveStepDeps(deps);
365
+ const stepDeps = resolveStepDeps(deps, { recipeName: recipe.name });
366
+ // Phase 0β — inbox provenance. When a recipe `file.write` / `file.append`
367
+ // step targets `~/.patchwork/inbox/`, prepend a YAML frontmatter block
368
+ // (first write only) recording recipe + run + trigger, and accumulate the
369
+ // delivered filename onto the run record's `inboxOutputs`. Old recipes /
370
+ // non-inbox paths pass through unchanged.
371
+ //
372
+ // Windows path-separator fix (CI repro 2026-05-20): the original
373
+ // implementation built the prefix as `${os.homedir()}/.patchwork/inbox/`
374
+ // and compared with `startsWith`, which failed on Windows where
375
+ // resolved absolute paths use `\` separators and `os.homedir()` returns
376
+ // `C:\Users\...`. Now we resolve both sides through `path.resolve()`
377
+ // and use `path.relative()` to detect containment so the comparison is
378
+ // separator-agnostic. Also case-insensitive on Win32 (NTFS).
379
+ const inboxDirAbs = path.resolve(path.join(os.homedir(), ".patchwork", "inbox"));
380
+ const inboxOutputs = [];
381
+ const isInboxPath = (abs) => isInboxPathFor(abs, inboxDirAbs, path);
382
+ const buildFrontmatter = () => {
383
+ const triggerKindAtWrite = yamlTriggerKind;
384
+ const lines = ["---", `recipe: ${recipe.name}`];
385
+ if (runSeq !== undefined)
386
+ lines.push(`runSeq: ${runSeq}`);
387
+ lines.push(`trigger: ${triggerKindAtWrite}`, `deliveredAt: ${new Date().toISOString()}`, "---", "", "");
388
+ return lines.join("\n");
389
+ };
390
+ const recordInboxDelivery = (abs) => {
391
+ inboxOutputs.push({
392
+ filename: path.basename(abs),
393
+ deliveredAt: Date.now(),
394
+ });
395
+ };
396
+ // Atomic read-or-default: a single `readFileSync` in a try/catch. No
397
+ // `existsSync`/`statSync` probe around the write — on Windows a stat
398
+ // immediately before write can race a concurrent fd holder and surface
399
+ // `EBUSY`/`EPERM`. The read either succeeds (file present) or throws
400
+ // ENOENT (treated as new file). Either way we never stat the same path
401
+ // we're about to write.
402
+ const readExistingOrEmpty = (abs) => {
403
+ try {
404
+ return readFileSync(abs, "utf-8");
405
+ }
406
+ catch {
407
+ return "";
408
+ }
409
+ };
410
+ const originalWrite = stepDeps.writeFile;
411
+ const originalAppend = stepDeps.appendFile;
412
+ stepDeps.writeFile = (p, content) => {
413
+ if (isInboxPath(p)) {
414
+ // First-write detection by content shape, not by stat. Empty string
415
+ // (ENOENT) and any file that does NOT already begin with `---\n`
416
+ // gets frontmatter; pre-frontmattered files are overwritten as-is
417
+ // so consumers can replay a recipe without doubling the header.
418
+ const existing = readExistingOrEmpty(p);
419
+ const hasFm = existing.startsWith("---\n");
420
+ const final = hasFm ? content : buildFrontmatter() + content;
421
+ originalWrite(p, final);
422
+ recordInboxDelivery(p);
423
+ return;
424
+ }
425
+ originalWrite(p, content);
426
+ };
427
+ stepDeps.appendFile = (p, content) => {
428
+ if (isInboxPath(p)) {
429
+ // file.append: never re-prepend. If file is brand-new, seed one
430
+ // frontmatter block so an append-only recipe still gets
431
+ // provenance. Same atomic read-or-default — no stat probe.
432
+ const existing = readExistingOrEmpty(p);
433
+ if (existing.length === 0) {
434
+ originalWrite(p, buildFrontmatter() + content);
435
+ }
436
+ else {
437
+ originalAppend(p, content);
438
+ }
439
+ recordInboxDelivery(p);
440
+ return;
441
+ }
442
+ originalAppend(p, content);
443
+ };
444
+ // PR2b: one per-run budget shared across all agent steps. Absent
445
+ // `recipe.budget` → no enforcement, no overhead.
446
+ const runBudget = new RunBudget(recipe.budget);
226
447
  // Open a `running`-state run-log entry so the dashboard sees the recipe
227
448
  // as in flight. Only when a long-lived `runLog` is provided (bridge path);
228
449
  // CLI runs fall back to `appendDirect` at end via the existing logDir
@@ -241,6 +462,9 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
241
462
  trigger: yamlTriggerKind,
242
463
  createdAt: recipeStartedAt,
243
464
  startedAt: recipeStartedAt,
465
+ ...(deps.manualRunId !== undefined && {
466
+ manualRunId: deps.manualRunId,
467
+ }),
244
468
  });
245
469
  }
246
470
  catch {
@@ -251,221 +475,572 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
251
475
  const stepResults = [];
252
476
  let stepsRun = 0;
253
477
  let runError;
254
- for (const step of recipe.steps) {
255
- // Handle agent steps separately
256
- if (step.agent) {
257
- const agentCfg = step.agent;
258
- const renderedPrompt = render(agentCfg.prompt, ctx);
259
- const intoKey = agentCfg.into ?? "agent_output";
260
- const stepId = intoKey;
261
- const stepStart = Date.now();
262
- let agentResult;
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;
478
+ // Bug (2): the flat runner historically recorded the first non-optional
479
+ // failure in `runError` but kept executing later steps — diverging from
480
+ // chainedRunner, which aborts on a fatal failure. This flag is set ONLY
481
+ // when a failure is fatal (non-optional AND fail-open semantics do not
482
+ // apply via step.optional / on_error.fallback=log_only|deliver_original).
483
+ // The loop checks it at the top and breaks, matching chainedRunner's
484
+ // abort-on-failure contract. Fail-open failures never set it, so
485
+ // log_only/deliver_original/optional steps still let the run continue.
486
+ let haltAfterFailure = false;
487
+ // Live-tail SSE broadcaster. Wrapped in a try/catch on every call so a
488
+ // misbehaving listener can never break the run (mirrors chainedRunner).
489
+ // No-ops when `activityLog` isn't wired (CLI runs, tests, mocks).
490
+ const broadcast = deps.activityLog;
491
+ const emit = (event, metadata) => {
492
+ if (!broadcast || runSeq === undefined || stepDeps.testMode)
493
+ return;
494
+ try {
495
+ broadcast.recordEvent(event, metadata);
496
+ }
497
+ catch {
498
+ /* live-tail must not break a recipe run */
499
+ }
500
+ };
501
+ // Emit recipe_started as soon as we have a runSeq. The dashboard
502
+ // RecipeRunInline component watches for this event to flip a row
503
+ // from "queued" to "running" without waiting for the first step.
504
+ emit("recipe_started", {
505
+ runSeq,
506
+ recipeName: recipe.name,
507
+ trigger: yamlTriggerKind,
508
+ totalSteps: recipe.steps.length,
509
+ ts: recipeStartedAt,
510
+ });
511
+ // Push live step results into the run-log ring so the dashboard's
512
+ // `/runs/[seq]` page surfaces verdicts + haltReasons mid-flight,
513
+ // instead of waiting for the whole recipe to finish via
514
+ // `completeRun`. The runLog ignores non-running entries; cron/webhook
515
+ // runs through the orchestrator path (where `runSeq` is undefined)
516
+ // skip this entirely.
517
+ const persistLiveStepResults = () => {
518
+ if (!deps.runLog || runSeq === undefined || stepDeps.testMode)
519
+ return;
520
+ try {
521
+ deps.runLog.updateRunSteps(runSeq, stepResults);
522
+ }
523
+ catch {
524
+ /* live-tail is best-effort; never break a recipe run for it */
525
+ }
526
+ };
527
+ // Track per-step start timestamps so done events carry durationMs
528
+ // without a second roundtrip.
529
+ const stepStartTs = new Map();
530
+ // Emit recipe_step_done for the step result just pushed onto
531
+ // `stepResults`. Every loop branch (skip / budget / agent / tool)
532
+ // pushes exactly one result before it ends, so the last element is
533
+ // always the current step. `stepId` mirrors recipe_step_start's
534
+ // `stepIdForEmit` so live consumers can correlate start↔done — the
535
+ // pushed result's own id can diverge for agent steps without `into`.
536
+ const emitStepDone = (stepIdForEmit) => {
537
+ const justPushed = stepResults[stepResults.length - 1];
538
+ if (!justPushed)
539
+ return;
540
+ const haltReason = justPushed.haltReason;
541
+ emit("recipe_step_done", {
542
+ runSeq,
543
+ recipeName: recipe.name,
544
+ stepId: stepIdForEmit,
545
+ tool: justPushed.tool,
546
+ status: justPushed.status,
547
+ durationMs: justPushed.durationMs,
548
+ ...(justPushed.error !== undefined && { error: justPushed.error }),
549
+ ...(haltReason !== undefined && {
550
+ haltReason,
551
+ haltCategory: justPushed.haltCategory ?? categoriseHaltReason(haltReason),
552
+ }),
553
+ ts: Date.now(),
554
+ });
555
+ };
556
+ // The step loop is wrapped so an uncaught throw from any unguarded
557
+ // call site (a `when`/prompt render on a malformed step, a path-jail
558
+ // re-check, etc.) cannot escape `runYamlRecipe` and strand the
559
+ // run-log entry at "running" forever. On throw we capture the
560
+ // message into `runError` and fall through to the normal
561
+ // finalization path, which marks the run "error".
562
+ try {
563
+ for (const step of recipe.steps) {
564
+ // Bug (2): abort on a prior fatal failure. chainedRunner throws (and
565
+ // stops) when a non-optional step fails; the flat runner used to keep
566
+ // going. Break here so later steps don't run on top of a failed
567
+ // dependency. Fail-open failures (step.optional / on_error.fallback=
568
+ // log_only|deliver_original) never set `haltAfterFailure`, so they
569
+ // still let the run continue exactly as before.
570
+ if (haltAfterFailure)
571
+ break;
572
+ const stepIdForEmit = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
573
+ const stepTs = Date.now();
574
+ stepStartTs.set(stepIdForEmit, stepTs);
575
+ emit("recipe_step_start", {
576
+ runSeq,
577
+ recipeName: recipe.name,
578
+ stepId: stepIdForEmit,
579
+ tool: step.agent ? "agent" : step.tool,
580
+ ts: stepTs,
581
+ });
582
+ // Evaluate `when` guard before running anything. Mirrors
583
+ // chainedRunner.ts:248-266 — render the template, then truthy-check the
584
+ // result (empty string, "0", "false", "null", "undefined" are falsy).
585
+ // A falsy guard records the step as `skipped`, increments stepsRun, and
586
+ // continues — it is NOT a failure. Bridge-dev iMessage recipes rely on
587
+ // this to suppress the iMessage agent step when phone is empty.
588
+ if (typeof step.when === "string" && step.when.length > 0) {
589
+ const rendered = render(step.when, ctx).trim().toLowerCase();
590
+ const truthy = !!rendered &&
591
+ rendered !== "0" &&
592
+ rendered !== "false" &&
593
+ rendered !== "null" &&
594
+ rendered !== "undefined";
595
+ if (!truthy) {
596
+ const skipId = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
283
597
  stepResults.push({
284
- id: stepId,
285
- tool: "agent",
286
- status: "error",
287
- error: reason,
288
- durationMs: Date.now() - stepStart,
598
+ id: skipId,
599
+ tool: step.agent ? "agent" : step.tool,
600
+ status: "skipped",
601
+ durationMs: 0,
602
+ });
603
+ stepsRun++;
604
+ persistLiveStepResults();
605
+ emit("recipe_step_done", {
606
+ runSeq,
607
+ recipeName: recipe.name,
608
+ stepId: skipId,
609
+ tool: step.agent ? "agent" : step.tool,
610
+ status: "skipped",
611
+ durationMs: 0,
612
+ ts: Date.now(),
289
613
  });
614
+ continue;
615
+ }
616
+ }
617
+ // Bug (3): per-recipe token budget gates ALL step types, not just
618
+ // agent steps. The admission check used to live inside the
619
+ // `if (step.agent)` branch, so once the budget was breached the run
620
+ // kept executing tool steps unbounded. Gate here — after the `when:`
621
+ // guard resolves truthy, before the agent/tool split — so a breach
622
+ // halts the run regardless of the next step's kind. Subscription
623
+ // drivers report no usage and fail open inside RunBudget, so this is
624
+ // a no-op until a measured agent step actually breaches the cap.
625
+ const budgetAdmission = runBudget.admit();
626
+ if (!budgetAdmission.admitted) {
627
+ const reason = budgetAdmission.reason ??
628
+ "Run exceeded its token budget — budget_exceeded.";
629
+ runError = runError ?? reason;
630
+ haltAfterFailure = true;
631
+ const budgetStepId = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
632
+ stepResults.push({
633
+ id: budgetStepId,
634
+ tool: step.agent ? "agent" : step.tool,
635
+ status: "error",
636
+ error: reason,
637
+ haltReason: reason,
638
+ haltCategory: "budget_exceeded",
639
+ durationMs: 0,
640
+ });
641
+ stepsRun++;
642
+ persistLiveStepResults();
643
+ emitStepDone(stepIdForEmit);
644
+ continue;
645
+ }
646
+ // Handle agent steps separately
647
+ if (step.agent) {
648
+ const agentCfg = step.agent;
649
+ const isJudge = agentCfg.kind === "judge";
650
+ // PR3a: judge prompt convention. Append the structured-verdict
651
+ // suffix and, when `reviews: <stepId>` is set, inject the
652
+ // upstream step's output as an <artefact> block.
653
+ let renderedPrompt = render(agentCfg.prompt, ctx);
654
+ if (isJudge) {
655
+ if (agentCfg.reviews) {
656
+ renderedPrompt += buildJudgeArtefactBlock(ctx[agentCfg.reviews]);
657
+ }
658
+ renderedPrompt += JUDGE_PROMPT_SUFFIX;
290
659
  }
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;
660
+ const intoKey = agentCfg.into ?? "agent_output";
661
+ const stepId = intoKey;
662
+ const stepStart = Date.now();
663
+ let agentResult;
664
+ // Bug (2): fail-open semantics for THIS agent step. Mirrors the
665
+ // tool-branch `failOpen` (step.optional OR recipe-level
666
+ // on_error.fallback=log_only|deliver_original). Used to decide
667
+ // whether an agent failure is fatal (sets `haltAfterFailure`, which
668
+ // aborts the run at the next loop top) or fail-open (records the
669
+ // error but lets the run continue, as before).
670
+ const agentFallback = recipe.on_error?.fallback;
671
+ const agentFallbackFailOpen = agentFallback === "log_only" || agentFallback === "deliver_original";
672
+ const failOpenAgent = step.optional === true || agentFallbackFailOpen;
673
+ // PR2b: per-recipe token budget. Admission is now checked once at the
674
+ // top of the loop (Bug (3)) so it gates tool steps too; here we only
675
+ // reconcile actual consumption after the call. Subscription drivers
676
+ // (Claude CLI, provider subprocess) report `usage === undefined` —
677
+ // `RunBudget.reconcile` records a fail-open warning per driver per
678
+ // run and continues.
679
+ try {
680
+ const agentReturn = await _executeAgent({
681
+ prompt: renderedPrompt,
682
+ driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
683
+ model: agentCfg.model,
684
+ ...(agentCfg.mcpAccess !== undefined && {
685
+ mcpAccess: agentCfg.mcpAccess,
686
+ }),
687
+ }, buildAgentExecutorDeps(stepDeps, deps));
688
+ agentResult = agentReturn.text;
689
+ runBudget.reconcile(agentCfg.driver === "api"
690
+ ? "anthropic"
691
+ : (agentCfg.driver ?? "auto"), agentReturn.usage);
692
+ // Catch both `[agent step failed: ...]` (existing) and the
693
+ // silent-fail patterns `[agent step skipped: ...]` etc. via the
694
+ // shared detector. Per-step opt-out via `silentFailDetection: false`.
695
+ const agentSilentFail = step.silentFailDetection !== false
696
+ ? detectSilentFail(agentResult)
697
+ : null;
698
+ if (agentResult.startsWith("[agent step failed:") ||
699
+ agentSilentFail) {
700
+ const reason = agentSilentFail
701
+ ? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
702
+ : agentResult;
703
+ runError = runError ?? reason;
704
+ if (!failOpenAgent)
705
+ haltAfterFailure = true;
296
706
  stepResults.push({
297
707
  id: stepId,
298
708
  tool: "agent",
299
709
  status: "error",
300
- error: errMsg,
710
+ error: reason,
711
+ haltReason: agentSilentFail
712
+ ? `Agent step "${stepId}" returned no usable output (silent-fail: ${agentSilentFail.reason}).`
713
+ : `Agent step "${stepId}" reported failure.`,
714
+ haltCategory: "agent_silent_fail",
301
715
  durationMs: Date.now() - stepStart,
302
716
  });
303
717
  }
304
718
  else {
305
- // Try to parse as JSON so dot-notation ({{meeting.field}}) works
719
+ const stripped = stripLeadingNarration(agentResult);
720
+ if (!stripped.trim()) {
721
+ const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
722
+ runError = runError ?? errMsg;
723
+ if (!failOpenAgent)
724
+ haltAfterFailure = true;
725
+ stepResults.push({
726
+ id: stepId,
727
+ tool: "agent",
728
+ status: "error",
729
+ error: errMsg,
730
+ haltReason: `Agent step "${stepId}" returned only narration or whitespace — no content.`,
731
+ haltCategory: "agent_narration_only",
732
+ durationMs: Date.now() - stepStart,
733
+ });
734
+ }
735
+ else {
736
+ // Try to parse as JSON so dot-notation ({{meeting.field}}) works
737
+ try {
738
+ const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
739
+ const parsed = sanitizeParsed(JSON.parse((jsonMatch[1] ?? "").trim()));
740
+ ctx[intoKey] = parsed;
741
+ }
742
+ catch {
743
+ ctx[intoKey] = stripped;
744
+ }
745
+ outputs.push(intoKey);
746
+ // PR3a: parse + stash the judge verdict on the step result.
747
+ // Augment-only: a `request_changes` verdict still yields
748
+ // `status: "ok"`. The verdict surfaces via the runlog +
749
+ // future PR3b dashboard panel, but never gates the run.
750
+ const judgeVerdict = isJudge
751
+ ? parseJudgeVerdict(stripped)
752
+ : undefined;
753
+ stepResults.push({
754
+ id: stepId,
755
+ tool: "agent",
756
+ status: "ok",
757
+ ...(judgeVerdict !== undefined && { judgeVerdict }),
758
+ durationMs: Date.now() - stepStart,
759
+ });
760
+ // Slice 2 — per-step expect eval. Runs on the value just
761
+ // committed to ctx[intoKey]. Halt failure flips the just-pushed
762
+ // result to error and rolls back the ctx commit so downstream
763
+ // steps don't see a value the recipe author rejected.
764
+ if (step.expect) {
765
+ const failures = await evaluateStepExpect(step.expect, ctx[intoKey]);
766
+ if (failures.length > 0) {
767
+ const onFail = step.expect.on_fail ?? "halt";
768
+ const last = stepResults[stepResults.length - 1];
769
+ if (last) {
770
+ if (onFail === "halt") {
771
+ last.status = "error";
772
+ last.error = `expect_failed: ${failures.join("; ")}`;
773
+ last.haltReason = `expect_failed in step "${stepId}": ${failures.join("; ")}`;
774
+ last.haltCategory = "expect_failed";
775
+ if (!failOpenAgent) {
776
+ runError = runError ?? last.haltReason;
777
+ haltAfterFailure = true;
778
+ }
779
+ delete ctx[intoKey];
780
+ }
781
+ else {
782
+ last.expectWarnings = failures;
783
+ }
784
+ }
785
+ }
786
+ }
787
+ }
788
+ }
789
+ }
790
+ catch (err) {
791
+ const msg = err instanceof Error ? err.message : String(err);
792
+ runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
793
+ if (!failOpenAgent)
794
+ haltAfterFailure = true;
795
+ stepResults.push({
796
+ id: stepId,
797
+ tool: "agent",
798
+ status: "error",
799
+ error: msg,
800
+ haltReason: `Agent step "${stepId}" threw before completing: ${msg}`,
801
+ haltCategory: "agent_threw",
802
+ durationMs: Date.now() - stepStart,
803
+ });
804
+ }
805
+ stepsRun++;
806
+ persistLiveStepResults();
807
+ emitStepDone(stepIdForEmit);
808
+ continue;
809
+ }
810
+ const stepStart = Date.now();
811
+ const stepId = step.into ?? `step_${stepsRun}`;
812
+ // Resolve retry policy: step-level overrides recipe-level.
813
+ const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
814
+ const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
815
+ let result = null;
816
+ let stepError;
817
+ // Bug (2): distinguish a HARD tool error (a thrown error or a
818
+ // `{ok:false}` JSON envelope) from a SOFT silent-fail detection
819
+ // (`{count:0,error}` connector envelopes, string placeholders). Only
820
+ // hard failures abort the run; soft silent-fail detections keep the
821
+ // run going so connector health-check recipes can still deliver the
822
+ // degraded payload downstream (a long-standing, tested contract —
823
+ // see "linear.list_issues — returns error payload" tests). Silent-fail
824
+ // detection is an observability augment; it was never meant to gate
825
+ // delivery for these envelopes.
826
+ let stepErrorIsSilentFail = false;
827
+ let thrownError;
828
+ let thrownErrorCode;
829
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
830
+ if (attempt > 0) {
831
+ await new Promise((r) => setTimeout(r, retryDelayMs));
832
+ }
833
+ stepError = undefined;
834
+ stepErrorIsSilentFail = false;
835
+ thrownError = undefined;
836
+ thrownErrorCode = undefined;
837
+ try {
838
+ // Slice (sandbox-alternative): per-step wall-clock timeout via
839
+ // Promise.race. The underlying tool keeps running in the
840
+ // background — this is a halt signal for the runner, not a
841
+ // process kill. The thrown error carries a `step_timeout`
842
+ // prefix so categoriseHaltReason maps it correctly.
843
+ const timeoutMs = typeof step.timeout_ms === "number" && step.timeout_ms > 0
844
+ ? step.timeout_ms
845
+ : 0;
846
+ if (timeoutMs > 0) {
847
+ let timer;
848
+ const timeoutPromise = new Promise((_, reject) => {
849
+ timer = setTimeout(() => {
850
+ reject(new Error(`step_timeout: exceeded ${timeoutMs}ms in step "${step.into ?? step.tool ?? "?"}"`));
851
+ }, timeoutMs);
852
+ });
306
853
  try {
307
- const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
308
- const parsed = JSON.parse((jsonMatch[1] ?? "").trim());
309
- ctx[intoKey] = parsed;
854
+ result = await Promise.race([
855
+ executeStep(step, ctx, stepDeps),
856
+ timeoutPromise,
857
+ ]);
858
+ }
859
+ finally {
860
+ if (timer)
861
+ clearTimeout(timer);
862
+ }
863
+ }
864
+ else {
865
+ result = await executeStep(step, ctx, stepDeps);
866
+ }
867
+ // Detect tool-level errors reported as JSON {ok: false, error: ...}
868
+ if (result !== null) {
869
+ try {
870
+ const parsed = JSON.parse(result);
871
+ if (parsed.ok === false && typeof parsed.error === "string") {
872
+ stepError = parsed.error;
873
+ }
310
874
  }
311
875
  catch {
312
- ctx[intoKey] = stripped;
876
+ /* non-JSON result is fine */
313
877
  }
314
- outputs.push(intoKey);
315
- stepResults.push({
316
- id: stepId,
317
- tool: "agent",
318
- status: "ok",
319
- durationMs: Date.now() - stepStart,
320
- });
321
878
  }
879
+ // Silent-fail detection: tools that return string placeholders
880
+ // (`(git branches unavailable)`, `[agent step skipped: ...]`)
881
+ // or empty list-tool error shapes (`{count:0,error:"..."}`)
882
+ // succeed with bad data — flag them as `error` so the runner
883
+ // doesn't quietly hand garbage to a downstream agent. Per-step
884
+ // opt-out via `silentFailDetection: false`.
885
+ if (!stepError &&
886
+ result !== null &&
887
+ step.silentFailDetection !== false) {
888
+ const detected = detectSilentFail(result);
889
+ if (detected) {
890
+ stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
891
+ stepErrorIsSilentFail = true;
892
+ }
893
+ }
894
+ }
895
+ catch (err) {
896
+ thrownError = err instanceof Error ? err.message : String(err);
897
+ // Preserve structured error codes (e.g. recipe_path_jail_escape)
898
+ // so callers and tests can branch on `err.code` per R2 M-4
899
+ // without scraping the message string.
900
+ const code = err?.code;
901
+ if (typeof code === "string")
902
+ thrownErrorCode = code;
903
+ result = null;
322
904
  }
905
+ if (!stepError && !thrownError)
906
+ break;
323
907
  }
324
- catch (err) {
325
- const msg = err instanceof Error ? err.message : String(err);
326
- runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
908
+ // Recipe-level fallback: log_only / deliver_original treat step failure
909
+ // as non-fatal (fail-open) same semantics as step-level optional: true.
910
+ const fallback = recipe.on_error?.fallback;
911
+ const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
912
+ const failOpen = step.optional === true || fallbackFailOpen;
913
+ if (thrownError) {
914
+ const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
327
915
  stepResults.push({
328
916
  id: stepId,
329
- tool: "agent",
917
+ tool: step.tool,
330
918
  status: "error",
331
- error: msg,
919
+ error: thrownError,
920
+ ...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
921
+ haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" threw${retryNote}: ${thrownError}`,
922
+ haltCategory: thrownErrorCode === "kill_switch_blocked"
923
+ ? "kill_switch"
924
+ : "tool_threw",
332
925
  durationMs: Date.now() - stepStart,
333
926
  });
927
+ if (!failOpen) {
928
+ runError = runError ?? `${step.tool} failed: ${thrownError}`;
929
+ haltAfterFailure = true;
930
+ }
931
+ else if (fallbackFailOpen && !step.optional) {
932
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
933
+ }
334
934
  }
335
- stepsRun++;
336
- continue;
337
- }
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));
935
+ else {
936
+ const finalStatus = result === null ? "skipped" : stepError ? "error" : "ok";
937
+ const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
938
+ stepResults.push({
939
+ id: stepId,
940
+ tool: step.tool,
941
+ status: finalStatus,
942
+ error: stepError,
943
+ ...(finalStatus === "error" && stepError
944
+ ? {
945
+ haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" reported an error${retryNote}: ${stepError}`,
946
+ haltCategory: "tool_error",
947
+ }
948
+ : {}),
949
+ durationMs: Date.now() - stepStart,
950
+ });
951
+ if (stepError) {
952
+ if (!failOpen) {
953
+ runError = runError ?? `${step.tool} failed: ${stepError}`;
954
+ // Soft silent-fail detections (connector error envelopes) record
955
+ // the error but must NOT abort the run — see the
956
+ // `stepErrorIsSilentFail` note above. Hard `{ok:false}` errors do.
957
+ if (!stepErrorIsSilentFail)
958
+ haltAfterFailure = true;
959
+ }
960
+ else if (fallbackFailOpen && !step.optional) {
961
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${stepError}`);
962
+ }
963
+ }
350
964
  }
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) {
965
+ stepsRun++;
966
+ if (result !== null) {
967
+ // Apply transform if present — render template with $result injected
968
+ if (step.transform) {
358
969
  try {
359
- const parsed = JSON.parse(result);
360
- if (parsed.ok === false && typeof parsed.error === "string") {
361
- stepError = parsed.error;
362
- }
970
+ result = render(step.transform, { ...ctx, $result: result });
363
971
  }
364
- catch {
365
- /* non-JSON result is fine */
972
+ catch (err) {
973
+ // warn but fall through with original result
974
+ console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
366
975
  }
367
976
  }
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}`;
977
+ // Slice 2 — per-step expect eval. Runs on the post-transform value
978
+ // (what would land in ctx) and only when the step otherwise succeeded.
979
+ // Halt failure flips the just-pushed result to error and suppresses
980
+ // the ctx commit by nulling `result` so the downstream `if (step.into)`
981
+ // block skips. Composes with `optional: true` / `on_error.fallback`.
982
+ if (step.expect && !thrownError && !stepError && result !== null) {
983
+ const failures = await evaluateStepExpect(step.expect, result);
984
+ if (failures.length > 0) {
985
+ const onFail = step.expect.on_fail ?? "halt";
986
+ const last = stepResults[stepResults.length - 1];
987
+ if (last) {
988
+ if (onFail === "halt") {
989
+ last.status = "error";
990
+ last.error = `expect_failed: ${failures.join("; ")}`;
991
+ last.haltReason = `expect_failed in step "${stepId}": ${failures.join("; ")}`;
992
+ last.haltCategory = "expect_failed";
993
+ if (!failOpen) {
994
+ runError = runError ?? last.haltReason;
995
+ haltAfterFailure = true;
996
+ }
997
+ result = null;
998
+ }
999
+ else {
1000
+ last.expectWarnings = failures;
1001
+ }
1002
+ }
380
1003
  }
381
1004
  }
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}`;
1005
+ if (result !== null && step.into) {
1006
+ ctx[step.into] = result;
1007
+ if (step.tool) {
1008
+ applyToolOutputContext(step.tool, step.into, result, ctx);
1009
+ }
428
1010
  }
429
- else if (fallbackFailOpen && !step.optional) {
430
- console.warn(`step ${stepId} failed but on_error.fallback=${fallback} treating as non-fatal: ${stepError}`);
1011
+ if (step.tool === "file.write" || step.tool === "file.append") {
1012
+ // R2 C-1 / F-02: re-validate the rendered path against the jail so a
1013
+ // template substitution that survived earlier checks (e.g. via a
1014
+ // chained sub-recipe deps override) cannot smuggle an out-of-jail
1015
+ // path into the run log / dashboard outputs list.
1016
+ const renderedPath = render(step.path, ctx);
1017
+ outputs.push(resolveRecipePath(renderedPath, {
1018
+ workspace: stepDeps.workdir,
1019
+ write: true,
1020
+ }));
431
1021
  }
432
1022
  }
1023
+ persistLiveStepResults();
1024
+ emitStepDone(stepIdForEmit);
433
1025
  }
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
- }
452
- if (step.tool === "file.write" || step.tool === "file.append") {
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
- }));
462
- }
1026
+ }
1027
+ catch (err) {
1028
+ const msg = err instanceof Error ? err.message : String(err);
1029
+ runError = runError ?? `recipe run aborted: ${msg}`;
1030
+ }
1031
+ // Evaluate expect block before persisting so failures are stored in the
1032
+ // run log. Guarded: a throw here must not skip finalization and strand
1033
+ // the run at "running".
1034
+ let assertionFailures = [];
1035
+ if (recipe.expect) {
1036
+ try {
1037
+ assertionFailures = evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect);
1038
+ }
1039
+ catch (err) {
1040
+ const msg = err instanceof Error ? err.message : String(err);
1041
+ runError = runError ?? `expect evaluation failed: ${msg}`;
463
1042
  }
464
1043
  }
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
1044
  // Write to RecipeRunLog so the dashboard Runs page shows this execution.
470
1045
  // Bridge path: completeRun on the running entry opened above (live-tail).
471
1046
  // CLI path: construct a local log + appendDirect (no live-tail).
@@ -481,6 +1056,9 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
481
1056
  tool: s.tool,
482
1057
  status: s.status,
483
1058
  error: s.error,
1059
+ ...(s.haltReason ? { haltReason: s.haltReason } : {}),
1060
+ ...(s.haltCategory ? { haltCategory: s.haltCategory } : {}),
1061
+ ...(s.judgeVerdict ? { judgeVerdict: s.judgeVerdict } : {}),
484
1062
  durationMs: s.durationMs,
485
1063
  }));
486
1064
  if (deps.runLog && runSeq !== undefined) {
@@ -492,6 +1070,23 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
492
1070
  outputTail,
493
1071
  ...(runError !== undefined && { errorMessage: runError }),
494
1072
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
1073
+ ...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
1074
+ });
1075
+ emit("recipe_done", {
1076
+ runSeq,
1077
+ recipeName: recipe.name,
1078
+ status: runError ? "error" : "done",
1079
+ durationMs: doneAt - recipeStartedAt,
1080
+ stepCount: finalStepResults.length,
1081
+ // A `done` run can still carry step errors — the runner
1082
+ // continues past a non-fatal step failure. Surface it so
1083
+ // live consumers can show "completed with errors".
1084
+ hadStepErrors: finalStepResults.some((s) => s.status === "error"),
1085
+ ...(runError !== undefined && { errorMessage: runError }),
1086
+ ...(assertionFailures.length > 0 && {
1087
+ assertionFailureCount: assertionFailures.length,
1088
+ }),
1089
+ ts: doneAt,
495
1090
  });
496
1091
  }
497
1092
  else {
@@ -512,6 +1107,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
512
1107
  errorMessage: runError,
513
1108
  stepResults: finalStepResults,
514
1109
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
1110
+ ...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
515
1111
  });
516
1112
  }
517
1113
  }
@@ -519,13 +1115,13 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
519
1115
  // Non-fatal — run log write failure should never break recipe execution
520
1116
  }
521
1117
  }
522
- // Notify via Slack if any step failed
523
- if (runError && !stepDeps.testMode) {
1118
+ // Notify via Slack if any step failed and on_error.notify is not explicitly disabled
1119
+ if (runError && !stepDeps.testMode && recipe.on_error?.notify !== false) {
524
1120
  try {
525
1121
  const { isConnected, postMessage } = await import("../connectors/slack.js");
526
1122
  if (isConnected()) {
527
- // Read notification channel from ~/.patchwork/config.json, fallback to first available
528
- let notifyChannel = "all-massappealdesigns";
1123
+ // Read notification channel from ~/.patchwork/config.json
1124
+ let notifyChannel = "";
529
1125
  try {
530
1126
  const cfgPath = path.join(os.homedir(), ".patchwork", "config.json");
531
1127
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
@@ -535,13 +1131,15 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
535
1131
  }
536
1132
  }
537
1133
  catch {
538
- /* use default */
1134
+ /* config unreadable — skip notification */
1135
+ }
1136
+ if (notifyChannel) {
1137
+ const failedSteps = stepResults
1138
+ .filter((s) => s.status === "error")
1139
+ .map((s) => `• ${s.tool ?? s.id}: ${s.error ?? "unknown error"}`)
1140
+ .join("\n");
1141
+ await postMessage(notifyChannel, `⚠️ *Recipe failed: ${recipe.name}*\n\n${failedSteps}\n\n_${new Date().toISOString()}_`);
539
1142
  }
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
1143
  }
546
1144
  }
547
1145
  catch {
@@ -566,11 +1164,19 @@ export async function executeStep(step, ctx, deps) {
566
1164
  // Check if tool is registered in the new registry
567
1165
  if (hasTool(toolId)) {
568
1166
  const tool = getTool(toolId);
569
- // Build params with template rendering for string values
1167
+ // Build params with template rendering for string values.
1168
+ // `do` is left raw: it carries a nested sub-step template (used by
1169
+ // `fan_out`) whose `{{item.*}}` placeholders must be rendered per-iter
1170
+ // with the loop variable in scope, not pre-rendered against the outer
1171
+ // ctx (which would resolve them to empty strings).
570
1172
  const params = {};
571
1173
  for (const [key, value] of Object.entries(step)) {
572
1174
  if (key === "tool" || key === "agent" || key === "into")
573
1175
  continue;
1176
+ if (key === "do") {
1177
+ params[key] = value;
1178
+ continue;
1179
+ }
574
1180
  params[key] = deepRender(value, ctx);
575
1181
  }
576
1182
  // Check if mock connector is available for this tool
@@ -612,7 +1218,7 @@ export function render(template, ctx) {
612
1218
  return "";
613
1219
  if (typeof val === "string") {
614
1220
  try {
615
- val = JSON.parse(val);
1221
+ val = sanitizeParsed(JSON.parse(val));
616
1222
  }
617
1223
  catch {
618
1224
  return "";
@@ -752,7 +1358,7 @@ export function defaultGitStaleBranches(days, workdir) {
752
1358
  }
753
1359
  }
754
1360
  /** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
755
- function resolveStepDeps(deps) {
1361
+ function resolveStepDeps(deps, scope) {
756
1362
  const workdir = deps.workdir ?? process.cwd();
757
1363
  // Defense-in-depth: even if a file.* tool somehow forgets to call
758
1364
  // resolveRecipePath in its execute(), the default StepDeps file ops will
@@ -810,19 +1416,44 @@ function resolveStepDeps(deps) {
810
1416
  }),
811
1417
  logDir: deps.logDir,
812
1418
  testMode: deps.testMode ?? false,
1419
+ // PR5a/b: per-attempt idempotency ledger. Disk-backed when
1420
+ // `ledgerDir` + `manualRunId` + recipe name are all available so a
1421
+ // retry of the same logical attempt re-uses prior records (resume
1422
+ // semantics). Falls back to pure in-memory dedup otherwise.
1423
+ writeEffectLedger: deps.ledgerDir && deps.manualRunId && scope?.recipeName
1424
+ ? new WriteEffectLedger({
1425
+ dir: deps.ledgerDir,
1426
+ // Hash both fields together — `${recipeName}:${manualRunId}`
1427
+ // is ambiguous when recipe names contain colons (which
1428
+ // RecipeRunLog.parseTrigger explicitly allows).
1429
+ scopeKey: deriveScopeKey(scope.recipeName, assertValidManualRunId(deps.manualRunId)),
1430
+ })
1431
+ : new WriteEffectLedger(),
813
1432
  };
814
1433
  }
1434
+ /**
1435
+ * Normalise the union return of a RunnerDeps caller into an `AgentResult`.
1436
+ * Test mocks / CLI overrides typically return a plain string; bridge
1437
+ * wrappers + real adapter paths return `{text, usage}` so PR2b's token
1438
+ * budget enforcer can read usage. Both shapes converge here.
1439
+ */
1440
+ function toAgentResult(v) {
1441
+ return typeof v === "string" ? { text: v } : v;
1442
+ }
815
1443
  function buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride) {
816
1444
  const claudeCliFn = claudeCodeFnOverride ?? stepDeps.claudeCodeFn;
817
1445
  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),
1446
+ anthropicFn: async (prompt, model) => toAgentResult(await stepDeps.claudeFn(prompt, model)),
1447
+ providerDriverFn: async (driver, prompt, model) => toAgentResult(await stepDeps.providerDriverFn(driver, prompt, model)),
1448
+ claudeCliFn: async (prompt, opts) => toAgentResult(await claudeCliFn(prompt, opts)),
1449
+ localFn: async (prompt, model) => toAgentResult(await stepDeps.localFn(prompt, model)),
822
1450
  probeClaudeCli: () => {
823
1451
  if (runnerDeps.claudeFn !== undefined)
824
1452
  return false;
825
- const probe = spawnSync("claude", ["--version"], {
1453
+ // Use the same resolution as defaultClaudeCodeFn so the auto-detect
1454
+ // branch in agentExecutor.ts doesn't probe "claude" via PATH and
1455
+ // then later fail to spawn the configured override (or vice versa).
1456
+ const probe = spawnSync(resolveClaudeBinary(), ["--version"], {
826
1457
  encoding: "utf-8",
827
1458
  timeout: 5000,
828
1459
  });
@@ -841,21 +1472,78 @@ function buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride) {
841
1472
  },
842
1473
  };
843
1474
  }
844
- function defaultClaudeCodeFn(prompt, _opts) {
1475
+ /**
1476
+ * Resolve the `claude` binary path with override precedence:
1477
+ * 1. PATCHWORK_CLAUDE_BINARY env var (set by the bridge LaunchAgent
1478
+ * or any wrapper script)
1479
+ * 2. `~/.patchwork/config.json` `claudeBinary` field
1480
+ * 3. plain `"claude"` (PATH lookup — pre-existing default)
1481
+ *
1482
+ * Resolved per-call, not memoised, so config edits + env-var changes
1483
+ * take effect on the next agent step without a bridge restart.
1484
+ */
1485
+ export function resolveClaudeBinary() {
1486
+ const envOverride = process.env.PATCHWORK_CLAUDE_BINARY;
1487
+ if (envOverride && envOverride.length > 0)
1488
+ return ensureCmdShim(envOverride);
1489
+ try {
1490
+ const cfg = loadPatchworkConfigSync();
1491
+ if (cfg.claudeBinary && cfg.claudeBinary.length > 0)
1492
+ return ensureCmdShim(cfg.claudeBinary);
1493
+ }
1494
+ catch {
1495
+ // ignore — fall through to the "claude" default
1496
+ }
1497
+ return ensureCmdShim("claude");
1498
+ }
1499
+ export function defaultClaudeCodeFn(prompt, opts) {
1500
+ const binary = resolveClaudeBinary();
1501
+ // Resolve a workspace cwd so the spawned `claude -p` doesn't inherit the
1502
+ // bridge LaunchAgent's `$HOME` (P2 from the 2026-05-20 research run).
1503
+ // When nothing resolves, surface a typed reason instead of silently
1504
+ // shelling out from the wrong directory.
1505
+ const workspace = resolveWorkspaceRoot();
1506
+ if (!workspace) {
1507
+ return Promise.resolve(`[agent step failed: recipe_no_workspace — no .git ancestor of "${process.cwd()}" and PATCHWORK_WORKSPACE not set. Set PATCHWORK_WORKSPACE in the bridge environment or add a 'workspace:' field to the recipe.]`);
1508
+ }
1509
+ // mcpAccess is plumbed through executeAgent → buildChainedDeps → here.
1510
+ // The default fn has no bridge MCP endpoint resolver (SubprocessDriver
1511
+ // owns that). Surface mcpAccess=true as a typed error rather than
1512
+ // silently falling back to no-MCP spawn — the recipe explicitly asked
1513
+ // for bridge tools and should be routed through SubprocessDriver via
1514
+ // the runtime injector instead.
1515
+ if (opts?.mcpAccess === true) {
1516
+ return Promise.resolve("[agent step failed: recipe_mcp_unsupported — defaultClaudeCodeFn does not support mcpAccess:true; route via SubprocessDriver or unset the mcpAccess flag on this step]");
1517
+ }
845
1518
  try {
846
- const result = spawnSync("claude", [
1519
+ const result = spawnSync(binary, [
847
1520
  "-p",
848
1521
  prompt,
1522
+ // --strict-mcp-config: never load ~/.claude.json or .mcp.json. Recipes
1523
+ // are sandboxed by default (mcpAccess defaults to false above). This
1524
+ // also prevents accidental session attachment when the parent process
1525
+ // had a bridge MCP entry in ~/.claude.json.
1526
+ "--strict-mcp-config",
849
1527
  "--system-prompt",
850
1528
  "You are a helpful assistant processing a recipe task. Use ONLY the data explicitly provided in the user message — treat it as ground truth. Do not call tools to look up git history, emails, or any other information; all necessary data is already included.",
851
1529
  "--no-session-persistence",
852
1530
  ], {
1531
+ cwd: workspace.path,
1532
+ // sanitizeEnv strips CLAUDECODE / CLAUDE_CODE_* / MCP_* from the
1533
+ // child so the spawn doesn't re-authenticate as, or nest under,
1534
+ // the parent Claude Code session. Mirrors SubprocessDriver.run
1535
+ // hygiene. Preserves CLAUDE_CODE_OAUTH_TOKEN (subscription auth).
1536
+ env: sanitizeEnv(process.env),
853
1537
  encoding: "utf-8",
854
- timeout: 120_000,
1538
+ timeout: 600_000,
855
1539
  maxBuffer: 10 * 1024 * 1024,
856
1540
  });
857
1541
  if (result.error) {
858
- return Promise.resolve("[agent step failed: claude CLI not found install Claude Code or set ANTHROPIC_API_KEY]");
1542
+ // Surface the configured binary path in the error so users diagnosing
1543
+ // ENOENT can see whether resolveClaudeBinary picked up their override.
1544
+ // Hint includes the env var + config field names so the fix is one
1545
+ // click away.
1546
+ return Promise.resolve(`[agent step failed: claude CLI not found at "${binary}" — install Claude Code, set PATCHWORK_CLAUDE_BINARY, or set ANTHROPIC_API_KEY]`);
859
1547
  }
860
1548
  if (result.status !== 0) {
861
1549
  return Promise.resolve(`[agent step failed: claude exited ${result.status}: ${result.stderr?.slice(0, 200) ?? ""}]`);
@@ -867,7 +1555,7 @@ function defaultClaudeCodeFn(prompt, _opts) {
867
1555
  }
868
1556
  }
869
1557
  /** Returns a providerDriverFn with a per-run driver cache (not shared across runs). */
870
- function makeProviderDriverFn() {
1558
+ export function makeProviderDriverFn() {
871
1559
  const cache = new Map();
872
1560
  return async function defaultProviderDriverFn(driverName, prompt, model) {
873
1561
  try {
@@ -884,10 +1572,11 @@ function makeProviderDriverFn() {
884
1572
  const timeoutMs = 300_000;
885
1573
  const startupTimeoutMs = 30_000;
886
1574
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
1575
+ const resolvedWorkspace = process.cwd();
887
1576
  try {
888
1577
  const result = await driver.run({
889
1578
  prompt,
890
- workspace: process.cwd(),
1579
+ workspace: resolvedWorkspace,
891
1580
  timeoutMs,
892
1581
  startupTimeoutMs,
893
1582
  signal: controller.signal,
@@ -897,6 +1586,16 @@ function makeProviderDriverFn() {
897
1586
  const detail = result.stderrTail ?? result.text ?? "";
898
1587
  return `[agent step failed: ${driverName} exited ${result.exitCode}${detail ? ` — ${detail.slice(0, 200)}` : ""}]`;
899
1588
  }
1589
+ // API drivers (OpenAI / Grok) never set exitCode. On failure they
1590
+ // resolve with `{ text: "", wasAborted?/errorMessage }` — surface the
1591
+ // real cause (timeout / 401 / 429) instead of the generic
1592
+ // "empty output" branch below, which swallows the actual reason.
1593
+ if (result.wasAborted) {
1594
+ return `[agent step failed: ${driverName} timed out or was cancelled]`;
1595
+ }
1596
+ if (result.errorMessage) {
1597
+ return `[agent step failed: ${driverName} — ${result.errorMessage.slice(0, 200)}]`;
1598
+ }
900
1599
  if (!result.text) {
901
1600
  return `[agent step failed: ${driverName} returned empty output (possible timeout or auth error)]`;
902
1601
  }
@@ -914,7 +1613,7 @@ function makeProviderDriverFn() {
914
1613
  async function defaultClaudeFn(prompt, model) {
915
1614
  const apiKey = process.env.ANTHROPIC_API_KEY;
916
1615
  if (!apiKey)
917
- return "[agent step skipped: ANTHROPIC_API_KEY not set]";
1616
+ return { text: "[agent step skipped: ANTHROPIC_API_KEY not set]" };
918
1617
  try {
919
1618
  const res = await fetch("https://api.anthropic.com/v1/messages", {
920
1619
  method: "POST",
@@ -936,20 +1635,44 @@ async function defaultClaudeFn(prompt, model) {
936
1635
  });
937
1636
  if (!res.ok) {
938
1637
  const text = await res.text().catch(() => res.statusText);
939
- return `[agent step failed: ${text}]`;
1638
+ return { text: `[agent step failed: ${text}]` };
940
1639
  }
1640
+ // PR2a: forward Anthropic API token counts so PR2b's RunBudget can
1641
+ // reconcile actual consumption. Optional both upstream (older API
1642
+ // versions) and downstream (subscription/CLI driver returns
1643
+ // undefined here).
941
1644
  const data = (await res.json());
942
- return data.content?.[0]?.text ?? "[agent step failed: empty response]";
1645
+ const text = data.content?.[0]?.text ?? "[agent step failed: empty response]";
1646
+ const inputTokens = data.usage?.input_tokens;
1647
+ const outputTokens = data.usage?.output_tokens;
1648
+ if (typeof inputTokens === "number" && typeof outputTokens === "number") {
1649
+ return { text, usage: { inputTokens, outputTokens } };
1650
+ }
1651
+ return { text };
943
1652
  }
944
1653
  catch (err) {
945
- return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
1654
+ return {
1655
+ text: `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`,
1656
+ };
946
1657
  }
947
1658
  }
948
- async function defaultLocalFn(prompt, model) {
1659
+ export async function defaultLocalFn(prompt, model) {
949
1660
  try {
950
1661
  const { createLocalAdapter } = await import("../adapters/local.js");
951
1662
  const { loadConfig: loadPatchworkConfig } = await import("../patchworkConfig.js");
952
1663
  const cfg = loadPatchworkConfig();
1664
+ // Anti-SSRF: the local adapter streams the prompt to `cfg.localEndpoint`
1665
+ // (dashboard/config-controlled). A `driver: local` recipe must not be
1666
+ // able to POST the prompt to an arbitrary public host. Mirror the
1667
+ // LocalApiDriver gate (src/drivers/local/index.ts): reject any non
1668
+ // loopback/private endpoint unless LOCAL_ENDPOINT_ALLOW_REMOTE=1.
1669
+ if (cfg.localEndpoint &&
1670
+ process.env.LOCAL_ENDPOINT_ALLOW_REMOTE !== "1" &&
1671
+ !isLoopbackOrPrivateEndpoint(cfg.localEndpoint)) {
1672
+ return {
1673
+ text: "[agent step failed: localEndpoint is a public host; set LOCAL_ENDPOINT_ALLOW_REMOTE=1 to override]",
1674
+ };
1675
+ }
953
1676
  const adapter = createLocalAdapter({
954
1677
  endpoint: cfg.localEndpoint,
955
1678
  defaultModel: cfg.localModel ?? model,
@@ -958,10 +1681,18 @@ async function defaultLocalFn(prompt, model) {
958
1681
  systemPrompt: "",
959
1682
  messages: [{ role: "user", content: prompt }],
960
1683
  });
961
- return result.text ?? "[agent step failed: empty response from local LLM]";
1684
+ const text = result.text ?? "[agent step failed: empty response from local LLM]";
1685
+ // PR2a: local adapters carry usage when the backing API (Ollama / LM
1686
+ // Studio) surfaces it; otherwise undefined.
1687
+ if (result.usage) {
1688
+ return { text, usage: result.usage };
1689
+ }
1690
+ return { text };
962
1691
  }
963
1692
  catch (err) {
964
- return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
1693
+ return {
1694
+ text: `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`,
1695
+ };
965
1696
  }
966
1697
  }
967
1698
  /**
@@ -1017,12 +1748,18 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
1017
1748
  const result = await executeStep(step, {}, stepDeps);
1018
1749
  return result ?? "";
1019
1750
  };
1020
- const executeAgent = async (prompt, model, driver, mcpAccess) => _executeAgent({
1021
- prompt,
1022
- model,
1023
- driver,
1024
- ...(mcpAccess !== undefined && { mcpAccess }),
1025
- }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
1751
+ const executeAgent = async (prompt, model, driver, mcpAccess) => {
1752
+ // chainedRunner's AgentExecutor contract still returns a plain string —
1753
+ // PR2b's token-budget consumer will plug in here as well, but for now
1754
+ // we discard `.usage`.
1755
+ const result = await _executeAgent({
1756
+ prompt,
1757
+ model,
1758
+ driver,
1759
+ ...(mcpAccess !== undefined && { mcpAccess }),
1760
+ }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
1761
+ return result.text;
1762
+ };
1026
1763
  // ---------------------------------------------------------------------
1027
1764
  // BEGIN A-PR2 EDIT BLOCK — `loadNestedRecipe` jail (dogfood F-04).
1028
1765
  //
@@ -1139,7 +1876,7 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
1139
1876
  TIME: now.toTimeString().slice(0, 5),
1140
1877
  ...seedContext,
1141
1878
  },
1142
- maxConcurrency: chainedRecipe.maxConcurrency ?? 4,
1879
+ maxConcurrency: Math.max(1, chainedRecipe.maxConcurrency ?? 4),
1143
1880
  maxDepth: chainedRecipe.maxDepth ?? 3,
1144
1881
  dryRun: deps.chainedOptions?.dryRun ?? false,
1145
1882
  sourcePath: deps.chainedOptions?.sourcePath,
@@ -1156,11 +1893,17 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
1156
1893
  }
1157
1894
  return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
1158
1895
  }
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);
1896
+ // For non-chained recipes, lift `runLog` AND `activityLog` from
1897
+ // chainedOptions onto the RunnerDeps so runYamlRecipe gets the
1898
+ // bridge's singletons too. The activityLog is what powers
1899
+ // recipe_started / recipe_step_start / recipe_step_done /
1900
+ // recipe_done SSE emission to dashboard subscribers.
1901
+ const lifted = { ...deps };
1902
+ if (deps.chainedOptions?.runLog)
1903
+ lifted.runLog = deps.chainedOptions.runLog;
1904
+ if (deps.chainedOptions?.activityLog)
1905
+ lifted.activityLog = deps.chainedOptions.activityLog;
1906
+ return runYamlRecipe(recipe, lifted, seedContext);
1164
1907
  }
1165
1908
  /** List all YAML recipes in a directory. Returns names. */
1166
1909
  export function listYamlRecipes(recipesDir) {