patchwork-os 0.2.0-beta.0 → 0.2.0-beta.10

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