patchwork-os 0.2.0-alpha.3 → 0.2.0-alpha.30

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 (298) hide show
  1. package/README.bridge.md +6 -0
  2. package/README.md +40 -15
  3. package/deploy/bootstrap-vps.sh +184 -0
  4. package/deploy/deploy-dashboard.sh +174 -0
  5. package/deploy/deploy-landing.sh +79 -0
  6. package/dist/activationMetrics.d.ts +67 -0
  7. package/dist/activationMetrics.js +255 -0
  8. package/dist/activationMetrics.js.map +1 -0
  9. package/dist/approvalHttp.d.ts +24 -2
  10. package/dist/approvalHttp.js +150 -10
  11. package/dist/approvalHttp.js.map +1 -1
  12. package/dist/approvalQueue.d.ts +16 -1
  13. package/dist/approvalQueue.js +44 -3
  14. package/dist/approvalQueue.js.map +1 -1
  15. package/dist/automation.d.ts +20 -0
  16. package/dist/automation.js +54 -1
  17. package/dist/automation.js.map +1 -1
  18. package/dist/bridge.d.ts +7 -0
  19. package/dist/bridge.js +225 -35
  20. package/dist/bridge.js.map +1 -1
  21. package/dist/bridgeToken.js +57 -19
  22. package/dist/bridgeToken.js.map +1 -1
  23. package/dist/ccPermissions.js +6 -4
  24. package/dist/ccPermissions.js.map +1 -1
  25. package/dist/claudeOrchestrator.d.ts +1 -1
  26. package/dist/claudeOrchestrator.js +14 -8
  27. package/dist/claudeOrchestrator.js.map +1 -1
  28. package/dist/commands/launchd.d.ts +2 -0
  29. package/dist/commands/launchd.js +94 -0
  30. package/dist/commands/launchd.js.map +1 -0
  31. package/dist/commands/recipe.d.ts +258 -0
  32. package/dist/commands/recipe.js +1130 -0
  33. package/dist/commands/recipe.js.map +1 -0
  34. package/dist/commands/recipeInstall.d.ts +72 -0
  35. package/dist/commands/recipeInstall.js +339 -0
  36. package/dist/commands/recipeInstall.js.map +1 -0
  37. package/dist/config.d.ts +14 -1
  38. package/dist/config.js +99 -8
  39. package/dist/config.js.map +1 -1
  40. package/dist/connectors/baseConnector.d.ts +117 -0
  41. package/dist/connectors/baseConnector.js +213 -0
  42. package/dist/connectors/baseConnector.js.map +1 -0
  43. package/dist/connectors/confluence.d.ts +111 -0
  44. package/dist/connectors/confluence.js +406 -0
  45. package/dist/connectors/confluence.js.map +1 -0
  46. package/dist/connectors/datadog.d.ts +116 -0
  47. package/dist/connectors/datadog.js +385 -0
  48. package/dist/connectors/datadog.js.map +1 -0
  49. package/dist/connectors/fixtureLibrary.d.ts +21 -0
  50. package/dist/connectors/fixtureLibrary.js +70 -0
  51. package/dist/connectors/fixtureLibrary.js.map +1 -0
  52. package/dist/connectors/fixtureRecorder.d.ts +1 -0
  53. package/dist/connectors/fixtureRecorder.js +35 -0
  54. package/dist/connectors/fixtureRecorder.js.map +1 -0
  55. package/dist/connectors/github.d.ts +58 -8
  56. package/dist/connectors/github.js +312 -84
  57. package/dist/connectors/github.js.map +1 -1
  58. package/dist/connectors/gmail.d.ts +4 -1
  59. package/dist/connectors/gmail.js +79 -16
  60. package/dist/connectors/gmail.js.map +1 -1
  61. package/dist/connectors/googleCalendar.d.ts +60 -0
  62. package/dist/connectors/googleCalendar.js +345 -0
  63. package/dist/connectors/googleCalendar.js.map +1 -0
  64. package/dist/connectors/hubspot.d.ts +112 -0
  65. package/dist/connectors/hubspot.js +408 -0
  66. package/dist/connectors/hubspot.js.map +1 -0
  67. package/dist/connectors/intercom.d.ts +102 -0
  68. package/dist/connectors/intercom.js +402 -0
  69. package/dist/connectors/intercom.js.map +1 -0
  70. package/dist/connectors/jira.d.ts +98 -0
  71. package/dist/connectors/jira.js +379 -0
  72. package/dist/connectors/jira.js.map +1 -0
  73. package/dist/connectors/linear.d.ts +69 -19
  74. package/dist/connectors/linear.js +170 -129
  75. package/dist/connectors/linear.js.map +1 -1
  76. package/dist/connectors/mcpClient.d.ts +56 -0
  77. package/dist/connectors/mcpClient.js +189 -0
  78. package/dist/connectors/mcpClient.js.map +1 -0
  79. package/dist/connectors/mcpOAuth.d.ts +84 -0
  80. package/dist/connectors/mcpOAuth.js +389 -0
  81. package/dist/connectors/mcpOAuth.js.map +1 -0
  82. package/dist/connectors/mockConnector.d.ts +28 -0
  83. package/dist/connectors/mockConnector.js +81 -0
  84. package/dist/connectors/mockConnector.js.map +1 -0
  85. package/dist/connectors/notion.d.ts +143 -0
  86. package/dist/connectors/notion.js +424 -0
  87. package/dist/connectors/notion.js.map +1 -0
  88. package/dist/connectors/sentry.d.ts +17 -21
  89. package/dist/connectors/sentry.js +115 -131
  90. package/dist/connectors/sentry.js.map +1 -1
  91. package/dist/connectors/slack.d.ts +50 -0
  92. package/dist/connectors/slack.js +324 -0
  93. package/dist/connectors/slack.js.map +1 -0
  94. package/dist/connectors/stripe.d.ts +116 -0
  95. package/dist/connectors/stripe.js +379 -0
  96. package/dist/connectors/stripe.js.map +1 -0
  97. package/dist/connectors/tokenStorage.d.ts +35 -0
  98. package/dist/connectors/tokenStorage.js +459 -0
  99. package/dist/connectors/tokenStorage.js.map +1 -0
  100. package/dist/connectors/zendesk.d.ts +104 -0
  101. package/dist/connectors/zendesk.js +424 -0
  102. package/dist/connectors/zendesk.js.map +1 -0
  103. package/dist/drivers/gemini/index.d.ts +5 -1
  104. package/dist/drivers/gemini/index.js +39 -5
  105. package/dist/drivers/gemini/index.js.map +1 -1
  106. package/dist/drivers/index.d.ts +5 -0
  107. package/dist/drivers/index.js +1 -1
  108. package/dist/drivers/index.js.map +1 -1
  109. package/dist/featureFlags.d.ts +73 -0
  110. package/dist/featureFlags.js +203 -0
  111. package/dist/featureFlags.js.map +1 -0
  112. package/dist/fp/automationInterpreter.js +1 -0
  113. package/dist/fp/automationInterpreter.js.map +1 -1
  114. package/dist/fp/automationProgram.d.ts +1 -1
  115. package/dist/fp/automationProgram.js.map +1 -1
  116. package/dist/fp/policyParser.js +17 -0
  117. package/dist/fp/policyParser.js.map +1 -1
  118. package/dist/index.js +621 -61
  119. package/dist/index.js.map +1 -1
  120. package/dist/installGuard.d.ts +25 -0
  121. package/dist/installGuard.js +48 -0
  122. package/dist/installGuard.js.map +1 -0
  123. package/dist/oauth.d.ts +4 -1
  124. package/dist/oauth.js +50 -14
  125. package/dist/oauth.js.map +1 -1
  126. package/dist/patchworkConfig.d.ts +9 -0
  127. package/dist/patchworkConfig.js.map +1 -1
  128. package/dist/recipes/RecipeOrchestrator.d.ts +40 -0
  129. package/dist/recipes/RecipeOrchestrator.js +51 -0
  130. package/dist/recipes/RecipeOrchestrator.js.map +1 -0
  131. package/dist/recipes/agentExecutor.d.ts +28 -0
  132. package/dist/recipes/agentExecutor.js +42 -0
  133. package/dist/recipes/agentExecutor.js.map +1 -0
  134. package/dist/recipes/chainedRunner.d.ts +140 -0
  135. package/dist/recipes/chainedRunner.js +539 -0
  136. package/dist/recipes/chainedRunner.js.map +1 -0
  137. package/dist/recipes/dependencyGraph.d.ts +39 -0
  138. package/dist/recipes/dependencyGraph.js +199 -0
  139. package/dist/recipes/dependencyGraph.js.map +1 -0
  140. package/dist/recipes/legacyRecipeCompat.d.ts +2 -0
  141. package/dist/recipes/legacyRecipeCompat.js +112 -0
  142. package/dist/recipes/legacyRecipeCompat.js.map +1 -0
  143. package/dist/recipes/manifest.d.ts +47 -0
  144. package/dist/recipes/manifest.js +141 -0
  145. package/dist/recipes/manifest.js.map +1 -0
  146. package/dist/recipes/nestedRecipeStep.d.ts +58 -0
  147. package/dist/recipes/nestedRecipeStep.js +95 -0
  148. package/dist/recipes/nestedRecipeStep.js.map +1 -0
  149. package/dist/recipes/outputRegistry.d.ts +28 -0
  150. package/dist/recipes/outputRegistry.js +52 -0
  151. package/dist/recipes/outputRegistry.js.map +1 -0
  152. package/dist/recipes/scheduler.d.ts +23 -7
  153. package/dist/recipes/scheduler.js +131 -41
  154. package/dist/recipes/scheduler.js.map +1 -1
  155. package/dist/recipes/schema.d.ts +17 -2
  156. package/dist/recipes/schemaGenerator.d.ts +28 -0
  157. package/dist/recipes/schemaGenerator.js +565 -0
  158. package/dist/recipes/schemaGenerator.js.map +1 -0
  159. package/dist/recipes/templateEngine.d.ts +62 -0
  160. package/dist/recipes/templateEngine.js +182 -0
  161. package/dist/recipes/templateEngine.js.map +1 -0
  162. package/dist/recipes/toolRegistry.d.ts +181 -0
  163. package/dist/recipes/toolRegistry.js +300 -0
  164. package/dist/recipes/toolRegistry.js.map +1 -0
  165. package/dist/recipes/tools/calendar.d.ts +6 -0
  166. package/dist/recipes/tools/calendar.js +61 -0
  167. package/dist/recipes/tools/calendar.js.map +1 -0
  168. package/dist/recipes/tools/confluence.d.ts +6 -0
  169. package/dist/recipes/tools/confluence.js +254 -0
  170. package/dist/recipes/tools/confluence.js.map +1 -0
  171. package/dist/recipes/tools/datadog.d.ts +6 -0
  172. package/dist/recipes/tools/datadog.js +239 -0
  173. package/dist/recipes/tools/datadog.js.map +1 -0
  174. package/dist/recipes/tools/diagnostics.d.ts +6 -0
  175. package/dist/recipes/tools/diagnostics.js +36 -0
  176. package/dist/recipes/tools/diagnostics.js.map +1 -0
  177. package/dist/recipes/tools/file.d.ts +6 -0
  178. package/dist/recipes/tools/file.js +170 -0
  179. package/dist/recipes/tools/file.js.map +1 -0
  180. package/dist/recipes/tools/git.d.ts +6 -0
  181. package/dist/recipes/tools/git.js +63 -0
  182. package/dist/recipes/tools/git.js.map +1 -0
  183. package/dist/recipes/tools/github.d.ts +6 -0
  184. package/dist/recipes/tools/github.js +91 -0
  185. package/dist/recipes/tools/github.js.map +1 -0
  186. package/dist/recipes/tools/gmail.d.ts +6 -0
  187. package/dist/recipes/tools/gmail.js +210 -0
  188. package/dist/recipes/tools/gmail.js.map +1 -0
  189. package/dist/recipes/tools/hubspot.d.ts +6 -0
  190. package/dist/recipes/tools/hubspot.js +232 -0
  191. package/dist/recipes/tools/hubspot.js.map +1 -0
  192. package/dist/recipes/tools/index.d.ts +22 -0
  193. package/dist/recipes/tools/index.js +25 -0
  194. package/dist/recipes/tools/index.js.map +1 -0
  195. package/dist/recipes/tools/intercom.d.ts +6 -0
  196. package/dist/recipes/tools/intercom.js +226 -0
  197. package/dist/recipes/tools/intercom.js.map +1 -0
  198. package/dist/recipes/tools/linear.d.ts +6 -0
  199. package/dist/recipes/tools/linear.js +83 -0
  200. package/dist/recipes/tools/linear.js.map +1 -0
  201. package/dist/recipes/tools/notion.d.ts +6 -0
  202. package/dist/recipes/tools/notion.js +278 -0
  203. package/dist/recipes/tools/notion.js.map +1 -0
  204. package/dist/recipes/tools/slack.d.ts +6 -0
  205. package/dist/recipes/tools/slack.js +72 -0
  206. package/dist/recipes/tools/slack.js.map +1 -0
  207. package/dist/recipes/tools/stripe.d.ts +6 -0
  208. package/dist/recipes/tools/stripe.js +265 -0
  209. package/dist/recipes/tools/stripe.js.map +1 -0
  210. package/dist/recipes/tools/zendesk.d.ts +6 -0
  211. package/dist/recipes/tools/zendesk.js +245 -0
  212. package/dist/recipes/tools/zendesk.js.map +1 -0
  213. package/dist/recipes/validation.d.ts +13 -0
  214. package/dist/recipes/validation.js +433 -0
  215. package/dist/recipes/validation.js.map +1 -0
  216. package/dist/recipes/yamlRunner.d.ts +87 -0
  217. package/dist/recipes/yamlRunner.js +693 -409
  218. package/dist/recipes/yamlRunner.js.map +1 -1
  219. package/dist/recipesHttp.d.ts +34 -6
  220. package/dist/recipesHttp.js +285 -15
  221. package/dist/recipesHttp.js.map +1 -1
  222. package/dist/riskTier.js +1 -0
  223. package/dist/riskTier.js.map +1 -1
  224. package/dist/runLog.d.ts +23 -0
  225. package/dist/runLog.js +56 -1
  226. package/dist/runLog.js.map +1 -1
  227. package/dist/schemas/dry-run-plan.v1.json +139 -0
  228. package/dist/schemas/recipe.v1.json +684 -0
  229. package/dist/server.d.ts +32 -1
  230. package/dist/server.js +980 -97
  231. package/dist/server.js.map +1 -1
  232. package/dist/streamableHttp.js +2 -0
  233. package/dist/streamableHttp.js.map +1 -1
  234. package/dist/tools/addLinearComment.d.ts +55 -0
  235. package/dist/tools/addLinearComment.js +72 -0
  236. package/dist/tools/addLinearComment.js.map +1 -0
  237. package/dist/tools/bridgeDoctor.js +2 -2
  238. package/dist/tools/bridgeDoctor.js.map +1 -1
  239. package/dist/tools/createLinearIssue.d.ts +84 -0
  240. package/dist/tools/createLinearIssue.js +146 -0
  241. package/dist/tools/createLinearIssue.js.map +1 -0
  242. package/dist/tools/fetchCalendarEvents.d.ts +94 -0
  243. package/dist/tools/fetchCalendarEvents.js +97 -0
  244. package/dist/tools/fetchCalendarEvents.js.map +1 -0
  245. package/dist/tools/fetchGithubIssue.d.ts +80 -0
  246. package/dist/tools/fetchGithubIssue.js +84 -0
  247. package/dist/tools/fetchGithubIssue.js.map +1 -0
  248. package/dist/tools/fetchGithubPR.d.ts +89 -0
  249. package/dist/tools/fetchGithubPR.js +96 -0
  250. package/dist/tools/fetchGithubPR.js.map +1 -0
  251. package/dist/tools/fetchSlackProfile.d.ts +43 -0
  252. package/dist/tools/fetchSlackProfile.js +46 -0
  253. package/dist/tools/fetchSlackProfile.js.map +1 -0
  254. package/dist/tools/getConnectorStatus.d.ts +58 -0
  255. package/dist/tools/getConnectorStatus.js +56 -0
  256. package/dist/tools/getConnectorStatus.js.map +1 -0
  257. package/dist/tools/github/actions.js +4 -2
  258. package/dist/tools/github/actions.js.map +1 -1
  259. package/dist/tools/github/composite.d.ts +339 -0
  260. package/dist/tools/github/composite.js +343 -0
  261. package/dist/tools/github/composite.js.map +1 -0
  262. package/dist/tools/github/index.d.ts +2 -1
  263. package/dist/tools/github/index.js +2 -1
  264. package/dist/tools/github/index.js.map +1 -1
  265. package/dist/tools/github/issues.js +8 -4
  266. package/dist/tools/github/issues.js.map +1 -1
  267. package/dist/tools/github/pr.d.ts +122 -0
  268. package/dist/tools/github/pr.js +195 -5
  269. package/dist/tools/github/pr.js.map +1 -1
  270. package/dist/tools/index.js +32 -1
  271. package/dist/tools/index.js.map +1 -1
  272. package/dist/tools/searchTools.js +1 -1
  273. package/dist/tools/searchTools.js.map +1 -1
  274. package/dist/tools/slackListChannels.d.ts +65 -0
  275. package/dist/tools/slackListChannels.js +70 -0
  276. package/dist/tools/slackListChannels.js.map +1 -0
  277. package/dist/tools/slackPostMessage.d.ts +57 -0
  278. package/dist/tools/slackPostMessage.js +77 -0
  279. package/dist/tools/slackPostMessage.js.map +1 -0
  280. package/dist/tools/testTraceToSource.js +2 -2
  281. package/dist/tools/testTraceToSource.js.map +1 -1
  282. package/dist/tools/updateLinearIssue.d.ts +89 -0
  283. package/dist/tools/updateLinearIssue.js +117 -0
  284. package/dist/tools/updateLinearIssue.js.map +1 -0
  285. package/dist/transport.d.ts +7 -1
  286. package/dist/transport.js +85 -11
  287. package/dist/transport.js.map +1 -1
  288. package/package.json +5 -2
  289. package/scripts/start-all.sh +56 -19
  290. package/templates/automation-policies/recipe-authoring.json +25 -0
  291. package/templates/automation-policy.example.json +6 -0
  292. package/templates/co.patchwork-os.bridge.plist +34 -0
  293. package/templates/recipes/ctx-loop-test.yaml +75 -0
  294. package/templates/recipes/lint-on-save.yaml +1 -2
  295. package/templates/recipes/morning-brief-slack.yaml +57 -0
  296. package/templates/recipes/morning-brief.yaml +14 -6
  297. package/templates/recipes/project-health-check.yaml +50 -0
  298. package/templates/recipes/sentry-to-linear.yaml +77 -0
@@ -25,16 +25,89 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, write
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
27
  import { parse as parseYaml } from "yaml";
28
+ import { captureFixture } from "../connectors/fixtureRecorder.js";
29
+ import { findYamlRecipePath } from "../recipesHttp.js";
30
+ import { executeAgent as _executeAgent, } from "./agentExecutor.js";
31
+ import { normalizeRecipeForRuntime } from "./legacyRecipeCompat.js";
32
+ // Import tool registry and trigger tool self-registration
33
+ import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
34
+ import "./tools/index.js";
35
+ export function evaluateExpect(result, expect) {
36
+ const failures = [];
37
+ if (expect.stepsRun !== undefined && result.stepsRun !== expect.stepsRun) {
38
+ failures.push({
39
+ assertion: "stepsRun",
40
+ expected: expect.stepsRun,
41
+ actual: result.stepsRun,
42
+ message: `Expected stepsRun=${expect.stepsRun}, got ${result.stepsRun}`,
43
+ });
44
+ }
45
+ if (expect.errorMessage !== undefined) {
46
+ const expected = expect.errorMessage ?? null;
47
+ const actual = result.errorMessage ?? null;
48
+ if (expected !== actual) {
49
+ failures.push({
50
+ assertion: "errorMessage",
51
+ expected,
52
+ actual,
53
+ message: expected === null
54
+ ? `Expected clean run (no error), got: ${actual}`
55
+ : `Expected error "${expected}", got: ${actual === null ? "(none)" : actual}`,
56
+ });
57
+ }
58
+ }
59
+ if (expect.outputs !== undefined) {
60
+ for (const key of expect.outputs) {
61
+ if (!result.outputs.includes(key)) {
62
+ failures.push({
63
+ assertion: "outputs",
64
+ expected: key,
65
+ actual: result.outputs,
66
+ message: `Expected output key "${key}" not found in [${result.outputs.join(", ")}]`,
67
+ });
68
+ }
69
+ }
70
+ }
71
+ if (expect.context !== undefined) {
72
+ for (const [key, expectedVal] of Object.entries(expect.context)) {
73
+ const actual = result.context[key];
74
+ if (actual === undefined) {
75
+ failures.push({
76
+ assertion: `context.${key}`,
77
+ expected: expectedVal,
78
+ actual: undefined,
79
+ message: `Expected context key "${key}" to equal "${expectedVal}", but key is missing`,
80
+ });
81
+ }
82
+ else if (!actual.includes(expectedVal)) {
83
+ failures.push({
84
+ assertion: `context.${key}`,
85
+ expected: expectedVal,
86
+ actual,
87
+ message: `Expected context["${key}"] to contain "${expectedVal}", got "${actual}"`,
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return failures;
93
+ }
94
+ // Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
95
+ function stripLeadingNarration(text) {
96
+ const lines = text.split("\n");
97
+ const firstMarkdown = lines.findIndex((l) => /^(#|>|`|\||[-*+] |\d+\. |\*\*)/.test(l.trimStart()));
98
+ return firstMarkdown > 0 ? lines.slice(firstMarkdown).join("\n") : text;
99
+ }
28
100
  export function loadYamlRecipe(filePath) {
29
101
  const text = readFileSync(filePath, "utf-8");
30
102
  const raw = parseYaml(text);
31
103
  return validateYamlRecipe(raw);
32
104
  }
33
105
  export function validateYamlRecipe(raw) {
34
- if (typeof raw !== "object" || raw === null) {
106
+ const normalized = normalizeRecipeForRuntime(raw, console.warn);
107
+ if (typeof normalized !== "object" || normalized === null) {
35
108
  throw new Error("recipe must be an object");
36
109
  }
37
- const r = raw;
110
+ const r = normalized;
38
111
  if (typeof r.name !== "string" || !r.name) {
39
112
  throw new Error("recipe.name required");
40
113
  }
@@ -44,124 +117,486 @@ export function validateYamlRecipe(raw) {
44
117
  if (!Array.isArray(r.steps) || r.steps.length === 0) {
45
118
  throw new Error("recipe.steps must be a non-empty array");
46
119
  }
120
+ if (r.servers !== undefined &&
121
+ (!Array.isArray(r.servers) ||
122
+ r.servers.some((s) => typeof s !== "string"))) {
123
+ throw new Error("recipe.servers must be an array of strings if present");
124
+ }
47
125
  return r;
48
126
  }
127
+ /** Track already-loaded plugin specs to avoid double-loading within a process. */
128
+ const loadedPluginSpecs = new Set();
129
+ /**
130
+ * Load plugin specs declared in `recipe.servers` and register their tools into
131
+ * the recipe tool registry. Errors per-spec are logged as warnings — never fatal.
132
+ */
133
+ export async function loadRecipeServers(specs) {
134
+ const toLoad = specs.filter((s) => !loadedPluginSpecs.has(s));
135
+ if (toLoad.length === 0)
136
+ return;
137
+ let loadPluginsFull;
138
+ try {
139
+ ({ loadPluginsFull } = await import("../pluginLoader.js"));
140
+ }
141
+ catch (err) {
142
+ console.warn(`[recipe servers] failed to import pluginLoader: ${err instanceof Error ? err.message : String(err)}`);
143
+ return;
144
+ }
145
+ const minimalConfig = {
146
+ workspace: process.cwd(),
147
+ workspaceFolders: [process.cwd()],
148
+ commandTimeout: 30_000,
149
+ maxResultSize: 1_048_576,
150
+ };
151
+ const minimalLogger = {
152
+ info: (msg) => console.info(`[recipe servers] ${msg}`),
153
+ warn: (msg) => console.warn(`[recipe servers] ${msg}`),
154
+ error: (msg) => console.error(`[recipe servers] ${msg}`),
155
+ debug: (_msg) => { },
156
+ };
157
+ for (const spec of toLoad) {
158
+ try {
159
+ const loaded = await loadPluginsFull([spec], minimalConfig, minimalLogger);
160
+ let toolCount = 0;
161
+ for (const plugin of loaded) {
162
+ const pluginTools = plugin.tools.map((t) => ({
163
+ name: t.schema.name,
164
+ handler: t.handler,
165
+ schema: t.schema,
166
+ }));
167
+ toolCount += registerPluginTools(pluginTools);
168
+ }
169
+ loadedPluginSpecs.add(spec);
170
+ if (toolCount > 0) {
171
+ console.info(`[recipe servers] loaded "${spec}" — ${toolCount} tool(s) registered`);
172
+ }
173
+ }
174
+ catch (err) {
175
+ console.warn(`[recipe servers] failed to load "${spec}": ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ }
178
+ }
49
179
  export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
180
+ if (recipe.servers?.length) {
181
+ await loadRecipeServers(recipe.servers);
182
+ }
50
183
  const now = deps.now ? deps.now() : new Date();
51
184
  const ctx = {
52
185
  date: now.toISOString().slice(0, 10),
53
186
  time: now.toTimeString().slice(0, 5),
54
187
  ...seedContext,
55
188
  };
56
- const readFile = deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8"));
57
- const writeFile = deps.writeFile ??
58
- ((p, content) => {
59
- const abs = expandHome(p);
60
- mkdirSync(path.dirname(abs), { recursive: true });
61
- writeFileSync(abs, content);
62
- });
63
- const appendFile = deps.appendFile ??
64
- ((p, content) => {
65
- const abs = expandHome(p);
66
- mkdirSync(path.dirname(abs), { recursive: true });
67
- appendFileSync(abs, content);
68
- });
69
- const mkdir = deps.mkdir ??
70
- ((p) => mkdirSync(expandHome(p), { recursive: true }));
189
+ const stepDeps = resolveStepDeps(deps);
71
190
  const outputs = [];
191
+ const stepResults = [];
72
192
  let stepsRun = 0;
73
- const workdir = deps.workdir ?? process.cwd();
74
- const stepDeps = {
75
- readFile,
76
- writeFile,
77
- appendFile,
78
- mkdir,
79
- workdir,
80
- gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
81
- gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
82
- getDiagnostics: deps.getDiagnostics ?? (() => ""),
83
- fetchFn: deps.fetchFn ?? globalThis.fetch,
84
- claudeFn: deps.claudeFn ?? defaultClaudeFn,
85
- claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
86
- getGmailToken: deps.getGmailToken ??
87
- (async () => {
88
- const { getValidAccessToken } = await import("../connectors/gmail.js");
89
- return getValidAccessToken();
90
- }),
91
- };
193
+ let runError;
92
194
  for (const step of recipe.steps) {
93
195
  // Handle agent steps separately
94
196
  if (step.agent) {
95
197
  const agentCfg = step.agent;
96
198
  const renderedPrompt = render(agentCfg.prompt, ctx);
97
- const model = agentCfg.model ?? "claude-haiku-4-5-20251001";
98
199
  const intoKey = agentCfg.into ?? "agent_output";
200
+ const stepId = intoKey;
201
+ const stepStart = Date.now();
99
202
  let agentResult;
100
- if (agentCfg.driver === "claude-code") {
101
- agentResult = await stepDeps.claudeCodeFn(renderedPrompt);
102
- }
103
- else if (agentCfg.driver === "api") {
104
- agentResult = await stepDeps.claudeFn(renderedPrompt, model);
105
- }
106
- else {
107
- // Default driver: use API path. If no ANTHROPIC_API_KEY and caller did not provide a
108
- // custom claudeFn (i.e. using the built-in default that returns a skip message), probe
109
- // for the claude CLI and fall back automatically.
110
- const usingDefaultClaudeFn = deps.claudeFn === undefined;
111
- if (!process.env.ANTHROPIC_API_KEY && usingDefaultClaudeFn) {
112
- const probe = spawnSync("claude", ["--version"], {
113
- encoding: "utf-8",
114
- timeout: 5000,
203
+ try {
204
+ agentResult = await _executeAgent({
205
+ prompt: renderedPrompt,
206
+ driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
207
+ model: agentCfg.model,
208
+ }, buildAgentExecutorDeps(stepDeps, deps));
209
+ if (agentResult.startsWith("[agent step failed:")) {
210
+ runError = runError ?? agentResult;
211
+ stepResults.push({
212
+ id: stepId,
213
+ tool: "agent",
214
+ status: "error",
215
+ error: agentResult,
216
+ durationMs: Date.now() - stepStart,
115
217
  });
116
- if (!probe.error) {
117
- agentResult = await stepDeps.claudeCodeFn(renderedPrompt);
218
+ }
219
+ else {
220
+ const stripped = stripLeadingNarration(agentResult);
221
+ if (!stripped.trim()) {
222
+ const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
223
+ runError = runError ?? errMsg;
224
+ stepResults.push({
225
+ id: stepId,
226
+ tool: "agent",
227
+ status: "error",
228
+ error: errMsg,
229
+ durationMs: Date.now() - stepStart,
230
+ });
118
231
  }
119
232
  else {
120
- agentResult = await stepDeps.claudeFn(renderedPrompt, model);
233
+ ctx[intoKey] = stripped;
234
+ outputs.push(intoKey);
235
+ stepResults.push({
236
+ id: stepId,
237
+ tool: "agent",
238
+ status: "ok",
239
+ durationMs: Date.now() - stepStart,
240
+ });
121
241
  }
122
242
  }
123
- else {
124
- agentResult = await stepDeps.claudeFn(renderedPrompt, model);
125
- }
126
243
  }
127
- ctx[intoKey] = agentResult;
128
- outputs.push(intoKey);
244
+ catch (err) {
245
+ const msg = err instanceof Error ? err.message : String(err);
246
+ runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
247
+ stepResults.push({
248
+ id: stepId,
249
+ tool: "agent",
250
+ status: "error",
251
+ error: msg,
252
+ durationMs: Date.now() - stepStart,
253
+ });
254
+ }
129
255
  stepsRun++;
130
256
  continue;
131
257
  }
132
- const result = await executeStep(step, ctx, stepDeps);
133
- stepsRun++;
134
- if (result !== null) {
135
- if (step.into) {
136
- ctx[step.into] = result;
137
- // For Gmail steps, also expose flat dot-notation keys for render()
138
- const isGmailStep = step.tool === "gmail.fetch_unread" ||
139
- step.tool === "gmail.search" ||
140
- step.tool === "gmail.fetch_thread";
141
- if (isGmailStep) {
258
+ const stepStart = Date.now();
259
+ const stepId = step.into ?? step.tool ?? `step_${stepsRun}`;
260
+ // Resolve retry policy: step-level overrides recipe-level.
261
+ const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
262
+ const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
263
+ let result = null;
264
+ let stepError;
265
+ let thrownError;
266
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
267
+ if (attempt > 0) {
268
+ await new Promise((r) => setTimeout(r, retryDelayMs));
269
+ }
270
+ stepError = undefined;
271
+ thrownError = undefined;
272
+ try {
273
+ result = await executeStep(step, ctx, stepDeps);
274
+ // Detect tool-level errors reported as JSON {ok: false, error: ...}
275
+ if (result !== null) {
142
276
  try {
143
277
  const parsed = JSON.parse(result);
144
- for (const [k, v] of Object.entries(parsed)) {
145
- if (typeof v === "string" || typeof v === "number") {
146
- ctx[`${step.into}.${k}`] = String(v);
147
- }
148
- }
149
- // Also expose messages array as JSON string for agent prompts
150
- if (Array.isArray(parsed.messages)) {
151
- ctx[`${step.into}.json`] = JSON.stringify(parsed.messages);
278
+ if (parsed.ok === false && typeof parsed.error === "string") {
279
+ stepError = parsed.error;
152
280
  }
153
281
  }
154
282
  catch {
155
- // non-JSON result, skip
283
+ /* non-JSON result is fine */
156
284
  }
157
285
  }
158
286
  }
287
+ catch (err) {
288
+ thrownError = err instanceof Error ? err.message : String(err);
289
+ result = null;
290
+ }
291
+ if (!stepError && !thrownError)
292
+ break;
293
+ }
294
+ // Recipe-level fallback: log_only / deliver_original treat step failure
295
+ // as non-fatal (fail-open) — same semantics as step-level optional: true.
296
+ const fallback = recipe.on_error?.fallback;
297
+ const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
298
+ const failOpen = step.optional === true || fallbackFailOpen;
299
+ if (thrownError) {
300
+ stepResults.push({
301
+ id: stepId,
302
+ tool: step.tool,
303
+ status: "error",
304
+ error: thrownError,
305
+ durationMs: Date.now() - stepStart,
306
+ });
307
+ if (!failOpen) {
308
+ runError = runError ?? `${step.tool} failed: ${thrownError}`;
309
+ }
310
+ else if (fallbackFailOpen && !step.optional) {
311
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
312
+ }
313
+ }
314
+ else {
315
+ stepResults.push({
316
+ id: stepId,
317
+ tool: step.tool,
318
+ status: result === null ? "skipped" : stepError ? "error" : "ok",
319
+ error: stepError,
320
+ durationMs: Date.now() - stepStart,
321
+ });
322
+ if (stepError) {
323
+ if (!failOpen) {
324
+ runError = runError ?? `${step.tool} failed: ${stepError}`;
325
+ }
326
+ else if (fallbackFailOpen && !step.optional) {
327
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${stepError}`);
328
+ }
329
+ }
330
+ }
331
+ stepsRun++;
332
+ if (result !== null) {
333
+ // Apply transform if present — render template with $result injected
334
+ if (step.transform) {
335
+ try {
336
+ result = render(step.transform, { ...ctx, $result: result });
337
+ }
338
+ catch (err) {
339
+ // warn but fall through with original result
340
+ console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
341
+ }
342
+ }
343
+ if (step.into) {
344
+ ctx[step.into] = result;
345
+ if (step.tool) {
346
+ applyToolOutputContext(step.tool, step.into, result, ctx);
347
+ }
348
+ }
159
349
  if (step.tool === "file.write" || step.tool === "file.append") {
160
350
  outputs.push(render(step.path, ctx));
161
351
  }
162
352
  }
163
353
  }
164
- return { recipe: recipe.name, stepsRun, outputs, context: ctx };
354
+ // Evaluate expect block before persisting so failures are stored in the run log
355
+ const assertionFailures = recipe.expect
356
+ ? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
357
+ : [];
358
+ // Write to RecipeRunLog so the dashboard Runs page shows this execution
359
+ if (!stepDeps.testMode) {
360
+ try {
361
+ const { RecipeRunLog } = await import("../runLog.js");
362
+ const { homedir } = await import("node:os");
363
+ const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
364
+ const log = new RecipeRunLog({ dir: resolvedLogDir });
365
+ const trigger = recipe.trigger?.type ?? "manual";
366
+ const createdAt = now.getTime();
367
+ const doneAt = Date.now();
368
+ const outputTail = stepResults
369
+ .map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
370
+ .join("\n")
371
+ .slice(0, 2000);
372
+ log.appendDirect({
373
+ taskId: `yaml:${recipe.name}:${createdAt}`,
374
+ recipeName: recipe.name,
375
+ trigger: (["cron", "webhook", "recipe"].includes(trigger)
376
+ ? trigger
377
+ : "recipe"),
378
+ status: runError ? "error" : "done",
379
+ createdAt,
380
+ startedAt: createdAt,
381
+ doneAt,
382
+ durationMs: doneAt - createdAt,
383
+ outputTail,
384
+ errorMessage: runError,
385
+ stepResults: stepResults.map((s) => ({
386
+ id: s.id,
387
+ tool: s.tool,
388
+ status: s.status,
389
+ error: s.error,
390
+ durationMs: s.durationMs,
391
+ })),
392
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
393
+ });
394
+ }
395
+ catch {
396
+ // Non-fatal — run log write failure should never break recipe execution
397
+ }
398
+ }
399
+ // Notify via Slack if any step failed
400
+ if (runError && !stepDeps.testMode) {
401
+ try {
402
+ const { isConnected, postMessage } = await import("../connectors/slack.js");
403
+ if (isConnected()) {
404
+ // Read notification channel from ~/.patchwork/config.json, fallback to first available
405
+ let notifyChannel = "all-massappealdesigns";
406
+ try {
407
+ const cfgPath = path.join(os.homedir(), ".patchwork", "config.json");
408
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
409
+ const notifications = cfg.notifications;
410
+ if (typeof notifications?.slackChannel === "string") {
411
+ notifyChannel = notifications.slackChannel;
412
+ }
413
+ }
414
+ catch {
415
+ /* use default */
416
+ }
417
+ const failedSteps = stepResults
418
+ .filter((s) => s.status === "error")
419
+ .map((s) => `• ${s.tool ?? s.id}: ${s.error ?? "unknown error"}`)
420
+ .join("\n");
421
+ await postMessage(notifyChannel, `⚠️ *Recipe failed: ${recipe.name}*\n\n${failedSteps}\n\n_${new Date().toISOString()}_`);
422
+ }
423
+ }
424
+ catch {
425
+ // Non-fatal — notification failure should never mask the original error
426
+ }
427
+ }
428
+ return {
429
+ recipe: recipe.name,
430
+ stepsRun,
431
+ outputs,
432
+ context: ctx,
433
+ stepResults,
434
+ errorMessage: runError,
435
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
436
+ };
437
+ }
438
+ export async function executeStep(step, ctx, deps) {
439
+ const toolId = step.tool;
440
+ if (!toolId) {
441
+ return null;
442
+ }
443
+ // Check if tool is registered in the new registry
444
+ if (hasTool(toolId)) {
445
+ const tool = getTool(toolId);
446
+ // Build params with template rendering for string values
447
+ const params = {};
448
+ for (const [key, value] of Object.entries(step)) {
449
+ if (key === "tool" || key === "agent" || key === "into")
450
+ continue;
451
+ if (typeof value === "string") {
452
+ params[key] = render(value, ctx);
453
+ }
454
+ else {
455
+ params[key] = value;
456
+ }
457
+ }
458
+ // Check if mock connector is available for this tool
459
+ if (deps.mockConnectors?.[toolId]) {
460
+ return deps.mockConnectors[toolId].invoke("execute", params);
461
+ }
462
+ if (tool &&
463
+ deps.recordFixturesDir &&
464
+ tool.namespace !== "file" &&
465
+ tool.namespace !== "git" &&
466
+ tool.namespace !== "diagnostics") {
467
+ return captureFixture(path.join(deps.recordFixturesDir, `${tool.namespace}.json`), tool.namespace, toolId.split(".")[1] ?? toolId, params, async () => executeTool(toolId, { params, step, ctx, deps }));
468
+ }
469
+ return executeTool(toolId, { params, step, ctx, deps });
470
+ }
471
+ // Unknown tool — skip, don't throw (forward compat)
472
+ return null;
473
+ }
474
+ /** Minimal `{{ expr }}` renderer — replaces against flat context map. */
475
+ export function render(template, ctx) {
476
+ return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
477
+ const key = expr.trim();
478
+ return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
479
+ });
480
+ }
481
+ function expandHome(p) {
482
+ if (p.startsWith("~/"))
483
+ return path.join(os.homedir(), p.slice(2));
484
+ return p;
485
+ }
486
+ function parseSinceToGitArg(since) {
487
+ const m = /^(\d+)(h|d)$/i.exec(since.trim());
488
+ if (!m)
489
+ return since;
490
+ const [, num, unit = "h"] = m;
491
+ return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
492
+ }
493
+ function defaultGitLogSince(since, workdir) {
494
+ try {
495
+ const sinceArg = parseSinceToGitArg(since);
496
+ const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
497
+ cwd: workdir ?? process.cwd(),
498
+ encoding: "utf-8",
499
+ timeout: 5000,
500
+ });
501
+ if (result.error || result.status !== 0)
502
+ return "(git log unavailable)";
503
+ return (result.stdout ?? "").trim();
504
+ }
505
+ catch {
506
+ return "(git log unavailable)";
507
+ }
508
+ }
509
+ function defaultGitStaleBranches(days, workdir) {
510
+ try {
511
+ const cutoff = new Date(Date.now() - days * 86_400_000)
512
+ .toISOString()
513
+ .slice(0, 10);
514
+ const r = spawnSync("git", [
515
+ "branch",
516
+ "--no-column",
517
+ "--sort=-committerdate",
518
+ "--format=%(refname:short)",
519
+ `--since=${cutoff}`,
520
+ ], {
521
+ cwd: workdir ?? process.cwd(),
522
+ encoding: "utf-8",
523
+ timeout: 5000,
524
+ });
525
+ if (r.error || r.status !== 0)
526
+ return "(git branches unavailable)";
527
+ return (r.stdout ?? "").trim();
528
+ }
529
+ catch {
530
+ return "(git branches unavailable)";
531
+ }
532
+ }
533
+ /** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
534
+ function resolveStepDeps(deps) {
535
+ const workdir = deps.workdir ?? process.cwd();
536
+ return {
537
+ readFile: deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8")),
538
+ writeFile: deps.writeFile ??
539
+ ((p, content) => {
540
+ const abs = expandHome(p);
541
+ mkdirSync(path.dirname(abs), { recursive: true });
542
+ writeFileSync(abs, content);
543
+ }),
544
+ appendFile: deps.appendFile ??
545
+ ((p, content) => {
546
+ const abs = expandHome(p);
547
+ mkdirSync(path.dirname(abs), { recursive: true });
548
+ appendFileSync(abs, content);
549
+ }),
550
+ mkdir: deps.mkdir ??
551
+ ((p) => mkdirSync(expandHome(p), { recursive: true })),
552
+ workdir,
553
+ gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
554
+ gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
555
+ getDiagnostics: deps.getDiagnostics ?? (() => ""),
556
+ fetchFn: deps.fetchFn ?? globalThis.fetch,
557
+ claudeFn: deps.claudeFn ?? defaultClaudeFn,
558
+ claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
559
+ localFn: deps.localFn ?? defaultLocalFn,
560
+ providerDriverFn: deps.providerDriverFn ?? makeProviderDriverFn(),
561
+ mockConnectors: deps.mockConnectors ?? {},
562
+ recordFixturesDir: deps.recordFixturesDir,
563
+ getGmailToken: deps.getGmailToken ??
564
+ (async () => {
565
+ const { getValidAccessToken } = await import("../connectors/gmail.js");
566
+ return getValidAccessToken();
567
+ }),
568
+ logDir: deps.logDir,
569
+ testMode: deps.testMode ?? false,
570
+ };
571
+ }
572
+ function buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride) {
573
+ const claudeCliFn = claudeCodeFnOverride ?? stepDeps.claudeCodeFn;
574
+ return {
575
+ anthropicFn: (prompt, model) => stepDeps.claudeFn(prompt, model),
576
+ providerDriverFn: (driver, prompt, model) => stepDeps.providerDriverFn(driver, prompt, model),
577
+ claudeCliFn: (prompt) => claudeCliFn(prompt),
578
+ localFn: (prompt, model) => stepDeps.localFn(prompt, model),
579
+ probeClaudeCli: () => {
580
+ if (runnerDeps.claudeFn !== undefined)
581
+ return false;
582
+ const probe = spawnSync("claude", ["--version"], {
583
+ encoding: "utf-8",
584
+ timeout: 5000,
585
+ });
586
+ return !probe.error;
587
+ },
588
+ loadPatchworkConfig: () => {
589
+ try {
590
+ // Lazy sync load — patchworkConfig exports a synchronous loadConfig.
591
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
592
+ const { loadConfig } = require("../patchworkConfig.js");
593
+ return loadConfig();
594
+ }
595
+ catch {
596
+ return {};
597
+ }
598
+ },
599
+ };
165
600
  }
166
601
  function defaultClaudeCodeFn(prompt) {
167
602
  try {
@@ -188,6 +623,51 @@ function defaultClaudeCodeFn(prompt) {
188
623
  return Promise.resolve(`[agent step failed: ${err instanceof Error ? err.message : String(err)}]`);
189
624
  }
190
625
  }
626
+ /** Returns a providerDriverFn with a per-run driver cache (not shared across runs). */
627
+ function makeProviderDriverFn() {
628
+ const cache = new Map();
629
+ return async function defaultProviderDriverFn(driverName, prompt, model) {
630
+ try {
631
+ let driver = cache.get(driverName);
632
+ if (!driver) {
633
+ const { createDriver } = await import("../drivers/index.js");
634
+ const d = createDriver(driverName, { binary: "claude", antBinary: "ant" }, () => { });
635
+ if (!d)
636
+ return `[agent step failed: ${driverName} driver returned null]`;
637
+ driver = d;
638
+ cache.set(driverName, driver);
639
+ }
640
+ const controller = new AbortController();
641
+ const timeoutMs = 300_000;
642
+ const startupTimeoutMs = 30_000;
643
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
644
+ try {
645
+ const result = await driver.run({
646
+ prompt,
647
+ workspace: process.cwd(),
648
+ timeoutMs,
649
+ startupTimeoutMs,
650
+ signal: controller.signal,
651
+ model,
652
+ });
653
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
654
+ const detail = result.stderrTail ?? result.text ?? "";
655
+ return `[agent step failed: ${driverName} exited ${result.exitCode}${detail ? ` — ${detail.slice(0, 200)}` : ""}]`;
656
+ }
657
+ if (!result.text) {
658
+ return `[agent step failed: ${driverName} returned empty output (possible timeout or auth error)]`;
659
+ }
660
+ return result.text;
661
+ }
662
+ finally {
663
+ clearTimeout(timeout);
664
+ }
665
+ }
666
+ catch (err) {
667
+ return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
668
+ }
669
+ };
670
+ }
191
671
  async function defaultClaudeFn(prompt, model) {
192
672
  const apiKey = process.env.ANTHROPIC_API_KEY;
193
673
  if (!apiKey)
@@ -222,348 +702,152 @@ async function defaultClaudeFn(prompt, model) {
222
702
  return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
223
703
  }
224
704
  }
225
- async function executeStep(step, ctx, deps) {
226
- switch (step.tool) {
227
- case "file.read": {
228
- const p = render(step.path, ctx);
229
- try {
230
- return deps.readFile(p);
231
- }
232
- catch {
233
- if (step.optional)
234
- return "";
235
- throw new Error(`file.read: could not read ${p}`);
236
- }
237
- }
238
- case "file.write": {
239
- const p = render(step.path, ctx);
240
- const content = render(step.content, ctx);
241
- deps.writeFile(p, content);
242
- return content;
243
- }
244
- case "file.append": {
245
- const p = render(step.path, ctx);
246
- const content = render(step.content, ctx);
247
- const when = step.when;
248
- if (when && !evalWhen(when, ctx))
249
- return null;
250
- deps.appendFile(p, content);
251
- return content;
252
- }
253
- case "git.log_since": {
254
- const since = render(String(step.since ?? "24h"), ctx);
255
- return deps.gitLogSince(since, deps.workdir);
256
- }
257
- case "git.stale_branches": {
258
- const days = typeof step.days === "number" ? step.days : 30;
259
- return deps.gitStaleBranches(days, deps.workdir);
260
- }
261
- case "diagnostics.get": {
262
- const uri = render(String(step.uri ?? ""), ctx);
263
- return deps.getDiagnostics(uri);
264
- }
265
- case "gmail.fetch_unread": {
266
- const since = render(String(step.since ?? "24h"), ctx);
267
- const MAX_GMAIL_RESULTS = 50;
268
- const max = Math.min(typeof step.max === "number" ? step.max : 20, MAX_GMAIL_RESULTS);
269
- const query = `is:unread newer_than:${sinceToGmailQuery(since)}`;
270
- return gmailSearch(query, max, deps);
271
- }
272
- case "gmail.search": {
273
- const query = render(String(step.query ?? ""), ctx);
274
- const MAX_GMAIL_RESULTS = 50;
275
- const max = Math.min(typeof step.max === "number" ? step.max : 10, MAX_GMAIL_RESULTS);
276
- return gmailSearch(query, max, deps);
277
- }
278
- case "gmail.fetch_thread": {
279
- const id = render(String(step.id ?? ""), ctx);
280
- return gmailFetchThread(id, deps);
281
- }
282
- case "github.list_issues": {
283
- const { listIssues } = await import("../connectors/github.js");
284
- const repo = step.repo ? render(String(step.repo), ctx) : undefined;
285
- const assignee = step.assignee
286
- ? render(String(step.assignee), ctx)
287
- : "@me";
288
- const limit = typeof step.max === "number" ? step.max : 20;
289
- const issues = listIssues({ repo, assignee, limit });
290
- return JSON.stringify({ count: issues.length, issues });
705
+ async function defaultLocalFn(prompt, model) {
706
+ try {
707
+ const { createLocalAdapter } = await import("../adapters/local.js");
708
+ const { loadConfig: loadPatchworkConfig } = await import("../patchworkConfig.js");
709
+ const cfg = loadPatchworkConfig();
710
+ const adapter = createLocalAdapter({
711
+ endpoint: cfg.localEndpoint,
712
+ defaultModel: cfg.localModel ?? model,
713
+ });
714
+ const result = await adapter.complete({
715
+ systemPrompt: "",
716
+ messages: [{ role: "user", content: prompt }],
717
+ });
718
+ return result.text ?? "[agent step failed: empty response from local LLM]";
719
+ }
720
+ catch (err) {
721
+ return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
722
+ }
723
+ }
724
+ /**
725
+ * Build ExecutionDeps for ChainedRecipeRunner backed by the yamlRunner step
726
+ * handlers. This lets chained recipes use the same tool set (file.*, git.*,
727
+ * gmail.*, github.*, linear.*, diagnostics.*) as simple YAML recipes.
728
+ *
729
+ * Pass the result as `chainedDeps` when calling `dispatchRecipe` or
730
+ * `runChainedRecipe` so that `executeTool` is properly wired.
731
+ */
732
+ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
733
+ const stepDeps = resolveStepDeps(runnerDeps);
734
+ function normalizeNestedRecipeLookupName(ref) {
735
+ return ref.trim().replace(/\.ya?ml$/i, "");
736
+ }
737
+ function tryLoadRecipeFile(filePath) {
738
+ if (!existsSync(filePath))
739
+ return null;
740
+ try {
741
+ const recipe = loadYamlRecipe(filePath);
742
+ return { recipe, sourcePath: filePath };
291
743
  }
292
- case "github.list_prs": {
293
- const { listPRs } = await import("../connectors/github.js");
294
- const repo = step.repo ? render(String(step.repo), ctx) : undefined;
295
- const author = step.author ? render(String(step.author), ctx) : "@me";
296
- const limit = typeof step.max === "number" ? step.max : 20;
297
- const prs = listPRs({ repo, author, limit });
298
- return JSON.stringify({ count: prs.length, prs });
744
+ catch {
745
+ return null;
299
746
  }
300
- case "linear.list_issues": {
301
- const { loadTokens, linearQuery } = await import("../connectors/linear.js");
302
- const tokens = loadTokens();
303
- if (!tokens) {
304
- return JSON.stringify({
305
- count: 0,
306
- issues: [],
307
- error: "Linear not connected",
308
- });
309
- }
310
- const teamKey = step.team ? render(String(step.team), ctx) : undefined;
311
- const assigneeMe = step.assignee === "@me" || step.assignee === undefined;
312
- const stateFilter = step.state
313
- ? render(String(step.state), ctx)
314
- : "started,unstarted";
315
- const limit = typeof step.max === "number" ? step.max : 20;
316
- const stateTypes = stateFilter.split(",").map((s) => s.trim());
317
- const stateFilter_gql = stateTypes
318
- .map((t) => `{ type: { eq: "${t}" } }`)
319
- .join(", ");
320
- const teamFilter = teamKey ? `, team: { key: { eq: "${teamKey}" } }` : "";
321
- const assigneeFilter = assigneeMe
322
- ? `, assignee: { isMe: { eq: true } }`
323
- : "";
324
- const query = `
325
- query ListIssues($limit: Int!) {
326
- issues(
327
- first: $limit
328
- filter: {
329
- state: { type: { in: [${stateTypes.map((t) => `"${t}"`).join(", ")}] } }
330
- ${teamKey ? `team: { key: { eq: "${teamKey}" } }` : ""}
331
- ${assigneeMe ? `assignee: { isMe: { eq: true } }` : ""}
332
- }
333
- orderBy: updatedAt
334
- ) {
335
- nodes {
336
- identifier
337
- title
338
- state { name type }
339
- priority
340
- priorityLabel
341
- url
342
- assignee { name }
343
- team { key name }
344
- updatedAt
747
+ }
748
+ const executeTool = async (tool, params) => {
749
+ // Construct a YamlStep-compatible object so we can reuse executeStep.
750
+ const step = { tool, ...params };
751
+ // executeStep uses a RunContext for {{}} rendering — by the time executeTool
752
+ // is called the chained runner has already resolved templates, so we pass
753
+ // an empty context (no double-rendering).
754
+ const result = await executeStep(step, {}, stepDeps);
755
+ return result ?? "";
756
+ };
757
+ const executeAgent = async (prompt, model, driver) => _executeAgent({ prompt, model, driver }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
758
+ const loadNestedRecipe = async (name, parentSourcePath) => {
759
+ const lookupName = normalizeNestedRecipeLookupName(name);
760
+ if (parentSourcePath) {
761
+ const parentDir = path.dirname(parentSourcePath);
762
+ const pathLike = path.isAbsolute(name) ||
763
+ name.startsWith("./") ||
764
+ name.startsWith("../") ||
765
+ /[\\/]/.test(name) ||
766
+ /\.ya?ml$/i.test(name);
767
+ if (pathLike) {
768
+ const resolvedBase = path.isAbsolute(name)
769
+ ? path.resolve(name)
770
+ : path.resolve(parentDir, name);
771
+ const candidates = /\.ya?ml$/i.test(resolvedBase)
772
+ ? [resolvedBase]
773
+ : [`${resolvedBase}.yaml`, `${resolvedBase}.yml`, resolvedBase];
774
+ for (const candidate of candidates) {
775
+ const loaded = tryLoadRecipeFile(candidate);
776
+ if (loaded)
777
+ return loaded;
778
+ }
345
779
  }
346
- }
347
780
  }
348
- `;
349
- void stateFilter_gql;
350
- void teamFilter;
351
- void assigneeFilter;
781
+ const { homedir } = await import("node:os");
782
+ const recipesDir = path.join(homedir(), ".patchwork", "recipes");
783
+ // Check for manifest-based package directory first.
784
+ // Supports both plain names ("morning-brief") and scoped names ("@acme/morning-brief").
785
+ const pkgDirCandidates = [
786
+ path.join(recipesDir, lookupName),
787
+ // scoped: @acme/morning-brief → recipesDir/@acme/morning-brief
788
+ ];
789
+ for (const pkgDir of pkgDirCandidates) {
352
790
  try {
353
- const data = await linearQuery(query, { limit }, tokens.api_key);
354
- const issues = data.issues.nodes;
355
- return JSON.stringify({ count: issues.length, issues });
791
+ const { loadManifestFromDir } = await import("./manifest.js");
792
+ const manifest = loadManifestFromDir(pkgDir);
793
+ if (manifest) {
794
+ const mainPath = path.join(pkgDir, manifest.recipes.main);
795
+ const loaded = tryLoadRecipeFile(mainPath);
796
+ if (loaded)
797
+ return loaded;
798
+ }
356
799
  }
357
- catch (err) {
358
- return JSON.stringify({
359
- count: 0,
360
- issues: [],
361
- error: err instanceof Error ? err.message : String(err),
362
- });
800
+ catch {
801
+ // not a manifest dir — try flat file candidates
363
802
  }
364
803
  }
365
- default:
366
- // Unknown tool — skip, don't throw (forward compat)
367
- return null;
368
- }
369
- }
370
- /** Minimal `{{ expr }}` renderer — replaces against flat context map. */
371
- export function render(template, ctx) {
372
- return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
373
- const key = expr.trim();
374
- return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
375
- });
804
+ const candidate = findYamlRecipePath(recipesDir, lookupName);
805
+ if (candidate) {
806
+ const loaded = tryLoadRecipeFile(candidate);
807
+ if (loaded)
808
+ return loaded;
809
+ }
810
+ return null;
811
+ };
812
+ return { executeTool, executeAgent, loadNestedRecipe };
376
813
  }
377
814
  /**
378
- * Evaluate simple `N > 0 || M > 0` guards after template rendering.
379
- * Supports: numeric literals, >, <, >=, <=, ==, !=, ||, &&, !.
380
- * Returns true (run step) for anything it can't parse.
815
+ * Dispatch a loaded recipe to the appropriate runner.
816
+ *
817
+ * Recipes with `trigger.type: "chained"` are routed to the ChainedRecipeRunner
818
+ * (parallel execution, template variables, nested recipes, dry-run).
819
+ * All other recipes use the existing synchronous yamlRunner path.
820
+ *
821
+ * `chainedDeps` is only required when the recipe is chained; omit for simple recipes.
381
822
  */
382
- function evalWhen(when, ctx) {
383
- try {
384
- const expanded = render(when, ctx).trim();
385
- // Only handle the `N op M` and `expr || expr` / `expr && expr` patterns.
386
- const orParts = expanded.split("||");
387
- if (orParts.length > 1) {
388
- return orParts.some((p) => evalWhen(p.trim(), {}));
389
- }
390
- const andParts = expanded.split("&&");
391
- if (andParts.length > 1) {
392
- return andParts.every((p) => evalWhen(p.trim(), {}));
393
- }
394
- const m = /^(-?[\d.]+)\s*(>|<|>=|<=|==|!=)\s*(-?[\d.]+)$/.exec(expanded);
395
- if (!m)
396
- return true;
397
- const [, lhs, op, rhs] = m;
398
- const l = Number(lhs);
399
- const r = Number(rhs);
400
- switch (op) {
401
- case ">":
402
- return l > r;
403
- case "<":
404
- return l < r;
405
- case ">=":
406
- return l >= r;
407
- case "<=":
408
- return l <= r;
409
- case "==":
410
- return l === r;
411
- case "!=":
412
- return l !== r;
413
- default:
414
- return true;
823
+ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
824
+ const triggerType = recipe.trigger
825
+ ?.type;
826
+ if (triggerType === "chained") {
827
+ const { runChainedRecipe } = await import("./chainedRunner.js");
828
+ const chainedRecipe = recipe;
829
+ const now = deps.now ? deps.now() : new Date();
830
+ const options = {
831
+ env: {
832
+ ...process.env,
833
+ DATE: now.toISOString().slice(0, 10),
834
+ TIME: now.toTimeString().slice(0, 5),
835
+ ...seedContext,
836
+ },
837
+ maxConcurrency: chainedRecipe.maxConcurrency ?? 4,
838
+ maxDepth: chainedRecipe.maxDepth ?? 3,
839
+ dryRun: deps.chainedOptions?.dryRun ?? false,
840
+ sourcePath: deps.chainedOptions?.sourcePath,
841
+ onStepStart: deps.chainedOptions?.onStepStart,
842
+ onStepComplete: deps.chainedOptions?.onStepComplete,
843
+ runLogDir: deps.chainedOptions?.runLogDir,
844
+ };
845
+ if (!deps.chainedDeps) {
846
+ throw new Error("chainedDeps required for chained recipes (provide executeTool, executeAgent, loadNestedRecipe)");
415
847
  }
848
+ return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
416
849
  }
417
- catch {
418
- return true;
419
- }
420
- }
421
- function sinceToGmailQuery(since) {
422
- // "24h" → "1d", "7d" → "7d", "1h" → "1d" (round up)
423
- const m = /^(\d+)(h|d)$/.exec(since.trim().toLowerCase());
424
- if (!m)
425
- return "1d";
426
- const [, num, unit] = m;
427
- if (unit === "d")
428
- return `${num}d`;
429
- // hours → round up to days (min 1d)
430
- const days = Math.max(1, Math.ceil(Number(num) / 24));
431
- return `${days}d`;
432
- }
433
- function getHeader(headers, name) {
434
- return (headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ??
435
- "");
436
- }
437
- async function gmailSearch(query, max, deps) {
438
- const errorResult = (msg) => JSON.stringify({ count: 0, messages: [], error: msg });
439
- let token;
440
- try {
441
- token = await deps.getGmailToken();
442
- }
443
- catch {
444
- return errorResult("Gmail not connected");
445
- }
446
- try {
447
- const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${max}`;
448
- const listRes = await deps.fetchFn(listUrl, {
449
- headers: { Authorization: `Bearer ${token}` },
450
- });
451
- if (!listRes.ok)
452
- return errorResult("Gmail API error");
453
- const listJson = (await listRes.json());
454
- const ids = listJson.messages ?? [];
455
- const messages = await Promise.all(ids.slice(0, max).map(async (m) => {
456
- const detailUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=Subject,From,Date`;
457
- const detailRes = await deps.fetchFn(detailUrl, {
458
- headers: { Authorization: `Bearer ${token}` },
459
- });
460
- if (!detailRes.ok)
461
- return { id: m.id, subject: "", from: "", date: "", snippet: "" };
462
- const detail = (await detailRes.json());
463
- const hdrs = detail.payload?.headers ?? [];
464
- return {
465
- id: detail.id,
466
- subject: getHeader(hdrs, "Subject"),
467
- from: getHeader(hdrs, "From"),
468
- date: getHeader(hdrs, "Date"),
469
- snippet: detail.snippet ?? "",
470
- };
471
- }));
472
- const result = { count: messages.length, messages };
473
- return JSON.stringify(result);
474
- }
475
- catch {
476
- return errorResult("Gmail fetch failed");
477
- }
478
- }
479
- async function gmailFetchThread(id, deps) {
480
- const errorResult = (msg) => JSON.stringify({ subject: "", messages: [], error: msg });
481
- let token;
482
- try {
483
- token = await deps.getGmailToken();
484
- }
485
- catch {
486
- return errorResult("Gmail not connected");
487
- }
488
- try {
489
- const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${id}?format=metadata&metadataHeaders=Subject,From,Date`;
490
- const res = await deps.fetchFn(url, {
491
- headers: { Authorization: `Bearer ${token}` },
492
- });
493
- if (!res.ok)
494
- return errorResult("Gmail API error");
495
- const thread = (await res.json());
496
- const msgs = thread.messages ?? [];
497
- const firstHdrs = msgs[0]?.payload?.headers ?? [];
498
- const subject = getHeader(firstHdrs, "Subject");
499
- const messages = msgs.map((m) => {
500
- const hdrs = m.payload?.headers ?? [];
501
- return {
502
- from: getHeader(hdrs, "From"),
503
- date: getHeader(hdrs, "Date"),
504
- body_snippet: m.snippet ?? "",
505
- };
506
- });
507
- const result = { subject, messages };
508
- return JSON.stringify(result);
509
- }
510
- catch {
511
- return errorResult("Gmail fetch failed");
512
- }
513
- }
514
- function expandHome(p) {
515
- if (p.startsWith("~/"))
516
- return path.join(os.homedir(), p.slice(2));
517
- return p;
518
- }
519
- function parseSinceToGitArg(since) {
520
- const m = /^(\d+)(h|d)$/i.exec(since.trim());
521
- if (!m)
522
- return since;
523
- const [, num, unit = "h"] = m;
524
- return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
525
- }
526
- function defaultGitLogSince(since, workdir) {
527
- try {
528
- const sinceArg = parseSinceToGitArg(since);
529
- const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
530
- cwd: workdir ?? process.cwd(),
531
- encoding: "utf-8",
532
- timeout: 5000,
533
- });
534
- if (result.error || result.status !== 0)
535
- return "(git log unavailable)";
536
- return (result.stdout ?? "").trim();
537
- }
538
- catch {
539
- return "(git log unavailable)";
540
- }
541
- }
542
- function defaultGitStaleBranches(days, workdir) {
543
- try {
544
- const cutoff = new Date(Date.now() - days * 86_400_000)
545
- .toISOString()
546
- .slice(0, 10);
547
- const r = spawnSync("git", ["branch", "--format=%(refname:short) %(committerdate:short)"], {
548
- cwd: workdir ?? process.cwd(),
549
- encoding: "utf-8",
550
- timeout: 5000,
551
- });
552
- const branches = r.error || r.status !== 0 ? "" : (r.stdout ?? "").trim();
553
- if (!branches)
554
- return "(no local branches)";
555
- return (branches
556
- .split("\n")
557
- .filter((line) => {
558
- const parts = line.trim().split(/\s+/);
559
- const dateStr = parts[1];
560
- return dateStr && dateStr < cutoff;
561
- })
562
- .join("\n") || "(none older than 30 days)");
563
- }
564
- catch {
565
- return "(git unavailable)";
566
- }
850
+ return runYamlRecipe(recipe, deps, seedContext);
567
851
  }
568
852
  /** List all YAML recipes in a directory. Returns names. */
569
853
  export function listYamlRecipes(recipesDir) {