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

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 (301) 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 +2 -0
  19. package/dist/bridge.js +55 -130
  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/recipeOrchestration.d.ts +53 -0
  129. package/dist/recipeOrchestration.js +272 -0
  130. package/dist/recipeOrchestration.js.map +1 -0
  131. package/dist/recipes/RecipeOrchestrator.d.ts +40 -0
  132. package/dist/recipes/RecipeOrchestrator.js +51 -0
  133. package/dist/recipes/RecipeOrchestrator.js.map +1 -0
  134. package/dist/recipes/agentExecutor.d.ts +28 -0
  135. package/dist/recipes/agentExecutor.js +42 -0
  136. package/dist/recipes/agentExecutor.js.map +1 -0
  137. package/dist/recipes/chainedRunner.d.ts +140 -0
  138. package/dist/recipes/chainedRunner.js +539 -0
  139. package/dist/recipes/chainedRunner.js.map +1 -0
  140. package/dist/recipes/dependencyGraph.d.ts +39 -0
  141. package/dist/recipes/dependencyGraph.js +199 -0
  142. package/dist/recipes/dependencyGraph.js.map +1 -0
  143. package/dist/recipes/legacyRecipeCompat.d.ts +2 -0
  144. package/dist/recipes/legacyRecipeCompat.js +112 -0
  145. package/dist/recipes/legacyRecipeCompat.js.map +1 -0
  146. package/dist/recipes/manifest.d.ts +47 -0
  147. package/dist/recipes/manifest.js +141 -0
  148. package/dist/recipes/manifest.js.map +1 -0
  149. package/dist/recipes/nestedRecipeStep.d.ts +58 -0
  150. package/dist/recipes/nestedRecipeStep.js +95 -0
  151. package/dist/recipes/nestedRecipeStep.js.map +1 -0
  152. package/dist/recipes/outputRegistry.d.ts +28 -0
  153. package/dist/recipes/outputRegistry.js +52 -0
  154. package/dist/recipes/outputRegistry.js.map +1 -0
  155. package/dist/recipes/scheduler.d.ts +23 -7
  156. package/dist/recipes/scheduler.js +131 -41
  157. package/dist/recipes/scheduler.js.map +1 -1
  158. package/dist/recipes/schema.d.ts +17 -2
  159. package/dist/recipes/schemaGenerator.d.ts +28 -0
  160. package/dist/recipes/schemaGenerator.js +565 -0
  161. package/dist/recipes/schemaGenerator.js.map +1 -0
  162. package/dist/recipes/templateEngine.d.ts +62 -0
  163. package/dist/recipes/templateEngine.js +182 -0
  164. package/dist/recipes/templateEngine.js.map +1 -0
  165. package/dist/recipes/toolRegistry.d.ts +181 -0
  166. package/dist/recipes/toolRegistry.js +300 -0
  167. package/dist/recipes/toolRegistry.js.map +1 -0
  168. package/dist/recipes/tools/calendar.d.ts +6 -0
  169. package/dist/recipes/tools/calendar.js +61 -0
  170. package/dist/recipes/tools/calendar.js.map +1 -0
  171. package/dist/recipes/tools/confluence.d.ts +6 -0
  172. package/dist/recipes/tools/confluence.js +254 -0
  173. package/dist/recipes/tools/confluence.js.map +1 -0
  174. package/dist/recipes/tools/datadog.d.ts +6 -0
  175. package/dist/recipes/tools/datadog.js +239 -0
  176. package/dist/recipes/tools/datadog.js.map +1 -0
  177. package/dist/recipes/tools/diagnostics.d.ts +6 -0
  178. package/dist/recipes/tools/diagnostics.js +36 -0
  179. package/dist/recipes/tools/diagnostics.js.map +1 -0
  180. package/dist/recipes/tools/file.d.ts +6 -0
  181. package/dist/recipes/tools/file.js +170 -0
  182. package/dist/recipes/tools/file.js.map +1 -0
  183. package/dist/recipes/tools/git.d.ts +6 -0
  184. package/dist/recipes/tools/git.js +63 -0
  185. package/dist/recipes/tools/git.js.map +1 -0
  186. package/dist/recipes/tools/github.d.ts +6 -0
  187. package/dist/recipes/tools/github.js +91 -0
  188. package/dist/recipes/tools/github.js.map +1 -0
  189. package/dist/recipes/tools/gmail.d.ts +6 -0
  190. package/dist/recipes/tools/gmail.js +210 -0
  191. package/dist/recipes/tools/gmail.js.map +1 -0
  192. package/dist/recipes/tools/hubspot.d.ts +6 -0
  193. package/dist/recipes/tools/hubspot.js +232 -0
  194. package/dist/recipes/tools/hubspot.js.map +1 -0
  195. package/dist/recipes/tools/index.d.ts +22 -0
  196. package/dist/recipes/tools/index.js +25 -0
  197. package/dist/recipes/tools/index.js.map +1 -0
  198. package/dist/recipes/tools/intercom.d.ts +6 -0
  199. package/dist/recipes/tools/intercom.js +226 -0
  200. package/dist/recipes/tools/intercom.js.map +1 -0
  201. package/dist/recipes/tools/linear.d.ts +6 -0
  202. package/dist/recipes/tools/linear.js +83 -0
  203. package/dist/recipes/tools/linear.js.map +1 -0
  204. package/dist/recipes/tools/notion.d.ts +6 -0
  205. package/dist/recipes/tools/notion.js +278 -0
  206. package/dist/recipes/tools/notion.js.map +1 -0
  207. package/dist/recipes/tools/slack.d.ts +6 -0
  208. package/dist/recipes/tools/slack.js +72 -0
  209. package/dist/recipes/tools/slack.js.map +1 -0
  210. package/dist/recipes/tools/stripe.d.ts +6 -0
  211. package/dist/recipes/tools/stripe.js +265 -0
  212. package/dist/recipes/tools/stripe.js.map +1 -0
  213. package/dist/recipes/tools/zendesk.d.ts +6 -0
  214. package/dist/recipes/tools/zendesk.js +245 -0
  215. package/dist/recipes/tools/zendesk.js.map +1 -0
  216. package/dist/recipes/validation.d.ts +13 -0
  217. package/dist/recipes/validation.js +433 -0
  218. package/dist/recipes/validation.js.map +1 -0
  219. package/dist/recipes/yamlRunner.d.ts +87 -0
  220. package/dist/recipes/yamlRunner.js +693 -409
  221. package/dist/recipes/yamlRunner.js.map +1 -1
  222. package/dist/recipesHttp.d.ts +34 -6
  223. package/dist/recipesHttp.js +285 -15
  224. package/dist/recipesHttp.js.map +1 -1
  225. package/dist/riskTier.js +1 -0
  226. package/dist/riskTier.js.map +1 -1
  227. package/dist/runLog.d.ts +23 -0
  228. package/dist/runLog.js +56 -1
  229. package/dist/runLog.js.map +1 -1
  230. package/dist/schemas/dry-run-plan.v1.json +139 -0
  231. package/dist/schemas/recipe.v1.json +684 -0
  232. package/dist/server.d.ts +32 -1
  233. package/dist/server.js +980 -97
  234. package/dist/server.js.map +1 -1
  235. package/dist/streamableHttp.js +2 -0
  236. package/dist/streamableHttp.js.map +1 -1
  237. package/dist/tools/addLinearComment.d.ts +55 -0
  238. package/dist/tools/addLinearComment.js +72 -0
  239. package/dist/tools/addLinearComment.js.map +1 -0
  240. package/dist/tools/bridgeDoctor.js +2 -2
  241. package/dist/tools/bridgeDoctor.js.map +1 -1
  242. package/dist/tools/createLinearIssue.d.ts +84 -0
  243. package/dist/tools/createLinearIssue.js +146 -0
  244. package/dist/tools/createLinearIssue.js.map +1 -0
  245. package/dist/tools/fetchCalendarEvents.d.ts +94 -0
  246. package/dist/tools/fetchCalendarEvents.js +97 -0
  247. package/dist/tools/fetchCalendarEvents.js.map +1 -0
  248. package/dist/tools/fetchGithubIssue.d.ts +80 -0
  249. package/dist/tools/fetchGithubIssue.js +84 -0
  250. package/dist/tools/fetchGithubIssue.js.map +1 -0
  251. package/dist/tools/fetchGithubPR.d.ts +89 -0
  252. package/dist/tools/fetchGithubPR.js +96 -0
  253. package/dist/tools/fetchGithubPR.js.map +1 -0
  254. package/dist/tools/fetchSlackProfile.d.ts +43 -0
  255. package/dist/tools/fetchSlackProfile.js +46 -0
  256. package/dist/tools/fetchSlackProfile.js.map +1 -0
  257. package/dist/tools/getConnectorStatus.d.ts +58 -0
  258. package/dist/tools/getConnectorStatus.js +56 -0
  259. package/dist/tools/getConnectorStatus.js.map +1 -0
  260. package/dist/tools/github/actions.js +4 -2
  261. package/dist/tools/github/actions.js.map +1 -1
  262. package/dist/tools/github/composite.d.ts +339 -0
  263. package/dist/tools/github/composite.js +343 -0
  264. package/dist/tools/github/composite.js.map +1 -0
  265. package/dist/tools/github/index.d.ts +2 -1
  266. package/dist/tools/github/index.js +2 -1
  267. package/dist/tools/github/index.js.map +1 -1
  268. package/dist/tools/github/issues.js +8 -4
  269. package/dist/tools/github/issues.js.map +1 -1
  270. package/dist/tools/github/pr.d.ts +122 -0
  271. package/dist/tools/github/pr.js +195 -5
  272. package/dist/tools/github/pr.js.map +1 -1
  273. package/dist/tools/index.js +32 -1
  274. package/dist/tools/index.js.map +1 -1
  275. package/dist/tools/searchTools.js +1 -1
  276. package/dist/tools/searchTools.js.map +1 -1
  277. package/dist/tools/slackListChannels.d.ts +65 -0
  278. package/dist/tools/slackListChannels.js +70 -0
  279. package/dist/tools/slackListChannels.js.map +1 -0
  280. package/dist/tools/slackPostMessage.d.ts +57 -0
  281. package/dist/tools/slackPostMessage.js +77 -0
  282. package/dist/tools/slackPostMessage.js.map +1 -0
  283. package/dist/tools/testTraceToSource.js +2 -2
  284. package/dist/tools/testTraceToSource.js.map +1 -1
  285. package/dist/tools/updateLinearIssue.d.ts +89 -0
  286. package/dist/tools/updateLinearIssue.js +117 -0
  287. package/dist/tools/updateLinearIssue.js.map +1 -0
  288. package/dist/transport.d.ts +7 -1
  289. package/dist/transport.js +85 -11
  290. package/dist/transport.js.map +1 -1
  291. package/package.json +5 -2
  292. package/scripts/start-all.sh +56 -19
  293. package/templates/automation-policies/recipe-authoring.json +25 -0
  294. package/templates/automation-policy.example.json +6 -0
  295. package/templates/co.patchwork-os.bridge.plist +34 -0
  296. package/templates/recipes/ctx-loop-test.yaml +75 -0
  297. package/templates/recipes/lint-on-save.yaml +1 -2
  298. package/templates/recipes/morning-brief-slack.yaml +57 -0
  299. package/templates/recipes/morning-brief.yaml +14 -6
  300. package/templates/recipes/project-health-check.yaml +50 -0
  301. package/templates/recipes/sentry-to-linear.yaml +77 -0
@@ -0,0 +1,1130 @@
1
+ /**
2
+ * Recipe CLI commands — new, lint, test, watch, record, fmt
3
+ *
4
+ * Implements the A2 CLI UX milestone for recipe authoring.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync, } from "node:fs";
7
+ import os from "node:os";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
11
+ import "../recipes/tools/index.js";
12
+ import { loadFixtureLibrary } from "../connectors/fixtureLibrary.js";
13
+ import { MockConnector } from "../connectors/mockConnector.js";
14
+ import { normalizeRecipeForRuntime } from "../recipes/legacyRecipeCompat.js";
15
+ import { generateSchemaSet, writeSchemas } from "../recipes/schemaGenerator.js";
16
+ import { getTool, isConnectorNamespace, seedToolOutputPreviewContext, } from "../recipes/toolRegistry.js";
17
+ import { validateRecipeDefinition, } from "../recipes/validation.js";
18
+ import { buildChainedDeps, dispatchRecipe, loadYamlRecipe, render, runYamlRecipe, } from "../recipes/yamlRunner.js";
19
+ import { findYamlRecipePath } from "../recipesHttp.js";
20
+ const RECIPES_DIR = join(os.homedir(), ".patchwork", "recipes");
21
+ const FIXTURES_DIR = join(os.homedir(), ".patchwork", "fixtures");
22
+ const RECIPE_SCHEMA_HEADER = "# yaml-language-server: $schema=https://patchworkos.com/schema/recipe.v1.json";
23
+ const RECIPE_API_VERSION = "patchwork.sh/v1";
24
+ // ============================================================================
25
+ // patchwork recipe new
26
+ // ============================================================================
27
+ const TEMPLATES = {
28
+ minimal: `apiVersion: ${RECIPE_API_VERSION}
29
+ name: {{name}}
30
+ description: {{description}}
31
+ trigger:
32
+ type: manual
33
+ steps:
34
+ - tool: file.write
35
+ path: ~/.patchwork/inbox/{{name}}.md
36
+ content: "Hello from {{name}}\\n"
37
+ `,
38
+ daily: `apiVersion: ${RECIPE_API_VERSION}
39
+ name: {{name}}
40
+ description: {{description}}
41
+ trigger:
42
+ type: cron
43
+ at: "0 9 * * 1-5"
44
+ steps:
45
+ - tool: git.log_since
46
+ since: "24h"
47
+ into: commits
48
+ - agent:
49
+ prompt: |
50
+ Summarize these commits for a daily standup:
51
+ {{commits}}
52
+ into: summary
53
+ - tool: file.write
54
+ path: ~/.patchwork/inbox/{{name}}-{{date}}.md
55
+ content: "# {{name}}\\n\\n{{summary}}\\n"
56
+ `,
57
+ inbox: `apiVersion: ${RECIPE_API_VERSION}
58
+ name: {{name}}
59
+ description: {{description}}
60
+ trigger:
61
+ type: manual
62
+ steps:
63
+ - tool: gmail.fetch_unread
64
+ since: "24h"
65
+ max: 20
66
+ into: unread
67
+ - tool: github.list_issues
68
+ assignee: "@me"
69
+ max: 10
70
+ into: issues
71
+ - agent:
72
+ prompt: |
73
+ Summarize my inbox. Unread emails: {{unread}}.
74
+ Assigned issues: {{issues}}.
75
+ into: summary
76
+ - tool: file.write
77
+ path: ~/.patchwork/inbox/{{name}}-{{date}}.md
78
+ content: "# {{name}}\\n\\n{{summary}}\\n"
79
+ `,
80
+ };
81
+ export function runNew(options) {
82
+ if (!options.name) {
83
+ throw new Error("Recipe name is required");
84
+ }
85
+ if (!options.description) {
86
+ throw new Error("Recipe description is required");
87
+ }
88
+ const templateKey = options.template ?? "minimal";
89
+ const template = TEMPLATES[templateKey];
90
+ if (!template) {
91
+ throw new Error(`Unknown template: "${templateKey}". ` +
92
+ `Available: ${Object.keys(TEMPLATES).join(", ")}`);
93
+ }
94
+ const today = new Date().toISOString().split("T")[0] ?? "";
95
+ const body = template
96
+ .replace(/\{\{name\}\}/g, options.name)
97
+ .replace(/\{\{description\}\}/g, options.description)
98
+ .replace(/\{\{date\}\}/g, today);
99
+ const content = `${RECIPE_SCHEMA_HEADER}\n${body}`;
100
+ const outputDir = options.outputDir ?? RECIPES_DIR;
101
+ if (!existsSync(outputDir)) {
102
+ mkdirSync(outputDir, { recursive: true });
103
+ }
104
+ const outputPath = join(outputDir, `${options.name}.yaml`);
105
+ if (existsSync(outputPath)) {
106
+ throw new Error(`Recipe already exists: ${outputPath}`);
107
+ }
108
+ writeFileSync(outputPath, content);
109
+ return { path: outputPath, content };
110
+ }
111
+ export function listTemplates() {
112
+ return Object.keys(TEMPLATES);
113
+ }
114
+ export async function runSchema(outputDir) {
115
+ const resolvedOutputDir = resolve(outputDir);
116
+ const schemas = generateSchemaSet();
117
+ const filesWritten = [];
118
+ await writeSchemas(resolvedOutputDir, schemas, (filePath, content) => {
119
+ const dir = dirname(filePath);
120
+ if (!existsSync(dir)) {
121
+ mkdirSync(dir, { recursive: true });
122
+ }
123
+ writeFileSync(filePath, content);
124
+ filesWritten.push(filePath);
125
+ });
126
+ return {
127
+ outputDir: resolvedOutputDir,
128
+ filesWritten,
129
+ };
130
+ }
131
+ /**
132
+ * Lint a recipe file against the schema.
133
+ * Falls back to basic YAML parsing if schema linting is disabled.
134
+ */
135
+ export function runLint(recipePath) {
136
+ // Check file exists
137
+ if (!existsSync(recipePath)) {
138
+ return {
139
+ valid: false,
140
+ issues: [{ level: "error", message: `File not found: ${recipePath}` }],
141
+ warnings: 0,
142
+ errors: 1,
143
+ };
144
+ }
145
+ let content;
146
+ try {
147
+ content = readFileSync(recipePath, "utf-8");
148
+ }
149
+ catch (err) {
150
+ return {
151
+ valid: false,
152
+ issues: [
153
+ {
154
+ level: "error",
155
+ message: `Failed to read file: ${err instanceof Error ? err.message : String(err)}`,
156
+ },
157
+ ],
158
+ warnings: 0,
159
+ errors: 1,
160
+ };
161
+ }
162
+ let parsed;
163
+ try {
164
+ parsed = parseYaml(content);
165
+ }
166
+ catch (err) {
167
+ return {
168
+ valid: false,
169
+ issues: [
170
+ {
171
+ level: "error",
172
+ message: `YAML parse error: ${err instanceof Error ? err.message : String(err)}`,
173
+ },
174
+ ],
175
+ warnings: 0,
176
+ errors: 1,
177
+ };
178
+ }
179
+ const result = validateRecipeDefinition(parsed);
180
+ // For chained recipes, check that chain: file references resolve on disk.
181
+ const chainIssues = lintChainRefs(parsed, recipePath);
182
+ if (chainIssues.length > 0) {
183
+ result.issues.push(...chainIssues);
184
+ result.errors += chainIssues.filter((i) => i.level === "error").length;
185
+ result.warnings += chainIssues.filter((i) => i.level === "warning").length;
186
+ if (result.errors > 0) {
187
+ result.valid = false;
188
+ }
189
+ }
190
+ return result;
191
+ }
192
+ /**
193
+ * Walk chained recipe steps, check that chain:/recipe: refs resolve on disk,
194
+ * and recursively lint any child recipe that does resolve.
195
+ *
196
+ * `visited` tracks absolute paths already linted in this call chain to prevent
197
+ * infinite recursion when two recipes chain each other.
198
+ */
199
+ function lintChainRefs(parsed, recipePath, visited = new Set()) {
200
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
201
+ return [];
202
+ const r = parsed;
203
+ const trigger = r.trigger && typeof r.trigger === "object"
204
+ ? r.trigger
205
+ : undefined;
206
+ if (trigger?.type !== "chained")
207
+ return [];
208
+ const steps = Array.isArray(r.steps)
209
+ ? r.steps
210
+ : [];
211
+ const recipeDir = dirname(recipePath);
212
+ const issues = [];
213
+ // Mark the current recipe as visited before descending.
214
+ const absPath = resolve(recipePath);
215
+ visited.add(absPath);
216
+ for (let i = 0; i < steps.length; i++) {
217
+ const step = steps[i];
218
+ if (!step)
219
+ continue;
220
+ issues.push(...lintStep(step, i + 1, recipeDir, visited));
221
+ }
222
+ return issues;
223
+ }
224
+ /**
225
+ * Check a single step (or recurse into its parallel: children).
226
+ * `stepLabel` is the 1-based position string used in issue messages.
227
+ */
228
+ function lintStep(step, stepLabel, recipeDir, visited) {
229
+ const issues = [];
230
+ // Recurse into parallel: groups — each child is checked independently.
231
+ if (Array.isArray(step.parallel)) {
232
+ for (let j = 0; j < step.parallel.length; j++) {
233
+ const child = step.parallel[j];
234
+ if (!child || typeof child !== "object" || Array.isArray(child))
235
+ continue;
236
+ issues.push(...lintStep(child, stepLabel, recipeDir, visited));
237
+ }
238
+ return issues;
239
+ }
240
+ const ref = typeof step.chain === "string"
241
+ ? step.chain
242
+ : typeof step.recipe === "string"
243
+ ? step.recipe
244
+ : null;
245
+ if (!ref)
246
+ return issues;
247
+ const field = typeof step.chain === "string" ? "chain" : "recipe";
248
+ // Refs that look like file paths (extension or separator) → resolve relative to recipe dir.
249
+ const looksLikePath = /\.ya?ml$/i.test(ref) ||
250
+ ref.startsWith("./") ||
251
+ ref.startsWith("../") ||
252
+ /[\\/]/.test(ref);
253
+ if (looksLikePath) {
254
+ const resolved = /^\//.test(ref) ? ref : resolve(recipeDir, ref);
255
+ const candidates = /\.ya?ml$/i.test(resolved)
256
+ ? [resolved]
257
+ : [`${resolved}.yaml`, `${resolved}.yml`, resolved];
258
+ const childPath = candidates.find(existsSync) ?? null;
259
+ if (!childPath) {
260
+ issues.push({
261
+ level: "error",
262
+ message: `Step ${stepLabel}: '${field}: ${ref}' — file not found relative to recipe directory (${recipeDir})`,
263
+ });
264
+ return issues;
265
+ }
266
+ issues.push(...lintChildRecipe(childPath, field, ref, stepLabel, visited));
267
+ return issues;
268
+ }
269
+ // Named ref (no extension, no separator) → check ~/.patchwork/recipes/.
270
+ // Emit a warning rather than error: the recipe may be installed on the
271
+ // deploy target but not the author's machine.
272
+ if (existsSync(RECIPES_DIR)) {
273
+ const found = findYamlRecipePath(RECIPES_DIR, ref) ??
274
+ (existsSync(join(RECIPES_DIR, ref)) ? join(RECIPES_DIR, ref) : null);
275
+ if (!found) {
276
+ issues.push({
277
+ level: "warning",
278
+ message: `Step ${stepLabel}: '${field}: ${ref}' — recipe not found in ${RECIPES_DIR}`,
279
+ });
280
+ }
281
+ else {
282
+ issues.push(...lintChildRecipe(found, field, ref, stepLabel, visited));
283
+ }
284
+ }
285
+ return issues;
286
+ }
287
+ /**
288
+ * Read, parse, and validate a resolved child recipe path. Skips the file if
289
+ * it has already been visited (cycle). Issues are prefixed with the parent
290
+ * step context so the author knows where the problem originates.
291
+ */
292
+ function lintChildRecipe(childPath, field, ref, stepNumber, visited) {
293
+ const absChild = resolve(childPath);
294
+ if (visited.has(absChild))
295
+ return []; // cycle — already linted
296
+ let childParsed;
297
+ try {
298
+ childParsed = parseYaml(readFileSync(childPath, "utf-8"));
299
+ }
300
+ catch (err) {
301
+ return [
302
+ {
303
+ level: "error",
304
+ message: `Step ${stepNumber}: '${field}: ${ref}' — could not read child recipe: ${err instanceof Error ? err.message : String(err)}`,
305
+ },
306
+ ];
307
+ }
308
+ const childResult = validateRecipeDefinition(childParsed);
309
+ const childChainIssues = lintChainRefs(childParsed, childPath, visited);
310
+ return [
311
+ ...childResult.issues.map((issue) => ({
312
+ ...issue,
313
+ message: `Step ${stepNumber}: '${field}: ${ref}' — child recipe invalid: ${issue.message}`,
314
+ })),
315
+ ...childChainIssues.map((issue) => ({
316
+ ...issue,
317
+ message: `Step ${stepNumber}: '${field}: ${ref}' — ${issue.message}`,
318
+ })),
319
+ ];
320
+ }
321
+ /**
322
+ * Format/normalize a recipe file.
323
+ * - Normalizes YAML formatting
324
+ * - Sorts keys in consistent order
325
+ * - Validates and re-serializes
326
+ */
327
+ export function runFmt(recipePath, options = {}) {
328
+ const content = readFileSync(recipePath, "utf-8");
329
+ const { header: schemaHeader } = extractSchemaHeader(content);
330
+ const recipe = normalizeRecipeForRuntime(parseYaml(content), console.warn);
331
+ // Normalize key order
332
+ const normalized = {};
333
+ const keyOrder = [
334
+ "apiVersion",
335
+ "version",
336
+ "name",
337
+ "description",
338
+ "trigger",
339
+ "context",
340
+ "steps",
341
+ "expect",
342
+ "output",
343
+ "on_error",
344
+ ];
345
+ for (const key of keyOrder) {
346
+ if (key in recipe) {
347
+ normalized[key] = recipe[key];
348
+ }
349
+ }
350
+ // Add any extra keys at the end
351
+ for (const key of Object.keys(recipe)) {
352
+ if (!keyOrder.includes(key)) {
353
+ normalized[key] = recipe[key];
354
+ }
355
+ }
356
+ // Re-serialize with consistent formatting
357
+ const formattedBody = stringifyYaml(normalized, {
358
+ indent: 2,
359
+ lineWidth: 100,
360
+ });
361
+ const formatted = schemaHeader
362
+ ? `${schemaHeader}\n${formattedBody}`
363
+ : formattedBody;
364
+ const changed = formatted.trim() !== content.trim();
365
+ if (!options.check) {
366
+ writeFileSync(recipePath, formatted);
367
+ }
368
+ return { formatted, changed };
369
+ }
370
+ /**
371
+ * Watch a recipe file and re-run `runFmt` on every save (debounced).
372
+ * Mirrors runPreflightWatch / runTestWatch — composes runWatch + runFmt.
373
+ * Returns a stop function.
374
+ */
375
+ export function runFmtWatch(options) {
376
+ const { recipePath, check, onResult, onError, debounceMs, watchFactory } = options;
377
+ return runWatch({
378
+ recipePath,
379
+ onChange: async () => {
380
+ const result = runFmt(recipePath, { check });
381
+ await onResult(result);
382
+ },
383
+ ...(onError ? { onError } : {}),
384
+ ...(debounceMs !== undefined ? { debounceMs } : {}),
385
+ ...(watchFactory ? { watchFactory } : {}),
386
+ });
387
+ }
388
+ function extractSchemaHeader(content) {
389
+ if (content.startsWith(`${RECIPE_SCHEMA_HEADER}\n`)) {
390
+ return { header: RECIPE_SCHEMA_HEADER };
391
+ }
392
+ return {};
393
+ }
394
+ export async function runRecipe(recipeRef, options = {}) {
395
+ const recipePath = resolveRecipePath(recipeRef);
396
+ const recipe = loadYamlRecipe(recipePath);
397
+ const triggerType = recipe.trigger?.type;
398
+ if (options.step && triggerType === "chained") {
399
+ throw new Error(`Single-step execution is not supported for chained recipes: ${recipe.name}`);
400
+ }
401
+ const selection = options.step
402
+ ? selectRecipeStep(recipe, options.step)
403
+ : undefined;
404
+ const recipeToRun = selection
405
+ ? { ...recipe, steps: [selection.step] }
406
+ : recipe;
407
+ const runnerDeps = {
408
+ ...options.deps,
409
+ workdir: options.workdir ?? options.deps?.workdir ?? process.cwd(),
410
+ };
411
+ if (options.dryRun) {
412
+ throw new Error("runRecipeDryPlan must be used for dry-run execution");
413
+ }
414
+ const result = await dispatchRecipe(recipeToRun, {
415
+ ...runnerDeps,
416
+ chainedDeps: buildChainedDeps(runnerDeps),
417
+ chainedOptions: { sourcePath: recipePath },
418
+ }, options.vars ?? {});
419
+ return {
420
+ recipe,
421
+ recipePath,
422
+ result,
423
+ ...(selection
424
+ ? {
425
+ stepSelection: {
426
+ query: selection.query,
427
+ matchedBy: selection.matchedBy,
428
+ matchedValue: selection.matchedValue,
429
+ },
430
+ }
431
+ : {}),
432
+ };
433
+ }
434
+ export function summarizeRecipeExecution(result) {
435
+ if ("stepsRun" in result) {
436
+ return {
437
+ ok: !result.errorMessage,
438
+ steps: result.stepsRun,
439
+ outputs: result.outputs,
440
+ ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
441
+ };
442
+ }
443
+ return {
444
+ ok: result.success,
445
+ steps: result.summary.total,
446
+ outputs: [],
447
+ ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
448
+ failed: result.summary.failed,
449
+ skipped: result.summary.skipped,
450
+ };
451
+ }
452
+ /**
453
+ * Normalize either a yamlRunner RunResult or a chainedRunner ChainedRunResult
454
+ * into the RunStepResult[] shape expected by RecipeRunLog.appendDirect.
455
+ * Returns undefined when the result has no step-level detail.
456
+ */
457
+ export function extractRunLogStepResults(result) {
458
+ if ("stepsRun" in result) {
459
+ // yamlRunner: stepResults is already StepResult[]
460
+ if (!Array.isArray(result.stepResults))
461
+ return undefined;
462
+ return result.stepResults.map((s) => ({
463
+ id: s.id,
464
+ ...(s.tool ? { tool: s.tool } : {}),
465
+ status: s.status,
466
+ ...(s.error ? { error: s.error } : {}),
467
+ durationMs: s.durationMs,
468
+ }));
469
+ }
470
+ // chainedRunner: stepResults is Map<string, ChainedStepRunResult>
471
+ return [...result.stepResults.entries()].map(([id, s]) => ({
472
+ id,
473
+ status: s.skipped ? "skipped" : s.success ? "ok" : "error",
474
+ durationMs: s.durationMs ?? 0,
475
+ ...(s.error ? { error: s.error.message } : {}),
476
+ }));
477
+ }
478
+ export function formatRunReport(result, recipeName) {
479
+ const lines = [];
480
+ const hr = "─".repeat(48);
481
+ if ("stepsRun" in result) {
482
+ // Simple (non-chained) recipe — compact summary
483
+ const ok = !result.errorMessage;
484
+ lines.push(`${ok ? "✓" : "✗"} ${recipeName} — ${result.stepsRun} step(s)`);
485
+ if (result.outputs.length > 0) {
486
+ for (const o of result.outputs)
487
+ lines.push(` → ${o}`);
488
+ }
489
+ if (result.errorMessage)
490
+ lines.push(` Error: ${result.errorMessage}`);
491
+ return lines.join("\n");
492
+ }
493
+ // Chained recipe — per-step table
494
+ const { stepResults, summary } = result;
495
+ const overallOk = result.success;
496
+ lines.push(hr);
497
+ lines.push(`Recipe: ${recipeName}`);
498
+ lines.push(hr);
499
+ for (const [id, step] of stepResults) {
500
+ const icon = step.skipped ? "↷" : step.success ? "✓" : "✗";
501
+ const dur = step.durationMs !== undefined ? ` (${step.durationMs}ms)` : "";
502
+ const err = step.error ? ` → ${step.error.message}` : "";
503
+ lines.push(` ${icon} ${id}${dur}${err}`);
504
+ }
505
+ lines.push(hr);
506
+ const parts = [`${summary.succeeded} ok`];
507
+ if (summary.skipped > 0)
508
+ parts.push(`${summary.skipped} skipped`);
509
+ if (summary.failed > 0)
510
+ parts.push(`${summary.failed} failed`);
511
+ lines.push(`${overallOk ? "✓" : "✗"} ${parts.join(" · ")}`);
512
+ return lines.join("\n");
513
+ }
514
+ export async function runWatchedRecipe(recipePath, options = {}) {
515
+ const lint = runLint(recipePath);
516
+ if (!lint.valid) {
517
+ return { lint };
518
+ }
519
+ const run = await runRecipe(recipePath, options);
520
+ return {
521
+ lint,
522
+ run,
523
+ summary: summarizeRecipeExecution(run.result),
524
+ };
525
+ }
526
+ /**
527
+ * Stable JSON schema version for machine-readable dry-run plans.
528
+ * Bump on breaking shape changes; consumers (dashboard run timeline, external tools)
529
+ * should gate on this field.
530
+ */
531
+ export const DRY_RUN_PLAN_SCHEMA_VERSION = 1;
532
+ function enrichStepFromRegistry(step) {
533
+ if (step.type !== "tool" || !step.tool) {
534
+ return step;
535
+ }
536
+ const namespace = step.tool.split(".")[0];
537
+ const registered = getTool(step.tool);
538
+ const enriched = { ...step };
539
+ if (namespace)
540
+ enriched.namespace = namespace;
541
+ enriched.resolved = Boolean(registered);
542
+ if (registered) {
543
+ enriched.isWrite = registered.isWrite;
544
+ enriched.isConnector = registered.isConnector === true;
545
+ if (enriched.risk === undefined) {
546
+ enriched.risk = registered.riskDefault;
547
+ }
548
+ }
549
+ return enriched;
550
+ }
551
+ function summarizePlanSteps(steps) {
552
+ const connectors = new Set();
553
+ let hasWrite = false;
554
+ for (const step of steps) {
555
+ if (step.isConnector && step.namespace)
556
+ connectors.add(step.namespace);
557
+ if (step.isWrite)
558
+ hasWrite = true;
559
+ }
560
+ return {
561
+ connectorNamespaces: [...connectors].sort(),
562
+ hasWriteSteps: hasWrite,
563
+ };
564
+ }
565
+ export async function runRecipeDryPlan(recipeRef, options = {}) {
566
+ const recipePath = resolveRecipePath(recipeRef);
567
+ const recipe = loadYamlRecipe(recipePath);
568
+ const triggerType = recipe.trigger?.type;
569
+ const selection = options.step
570
+ ? selectRecipeStep(recipe, options.step)
571
+ : undefined;
572
+ const recipeToPlan = selection
573
+ ? { ...recipe, steps: [selection.step] }
574
+ : recipe;
575
+ const generatedAt = new Date().toISOString();
576
+ if (triggerType === "chained") {
577
+ const { generateExecutionPlan } = await import("../recipes/chainedRunner.js");
578
+ const plan = generateExecutionPlan(recipeToPlan);
579
+ const steps = plan.steps.map((step) => {
580
+ const base = { id: step.id, type: step.type };
581
+ if (step.optional !== undefined)
582
+ base.optional = step.optional;
583
+ if (step.dependencies)
584
+ base.dependencies = step.dependencies;
585
+ if (step.condition !== undefined)
586
+ base.condition = step.condition;
587
+ if (step.risk !== undefined)
588
+ base.risk = step.risk;
589
+ const raw = step;
590
+ if (typeof raw.tool === "string")
591
+ base.tool = raw.tool;
592
+ if (typeof raw.into === "string")
593
+ base.into = raw.into;
594
+ return enrichStepFromRegistry(base);
595
+ });
596
+ return {
597
+ schemaVersion: DRY_RUN_PLAN_SCHEMA_VERSION,
598
+ generatedAt,
599
+ recipe: recipe.name,
600
+ mode: "dry-run",
601
+ triggerType,
602
+ ...(selection ? { stepSelection: toStepSelection(selection) } : {}),
603
+ steps,
604
+ parallelGroups: plan.parallelGroups,
605
+ maxDepth: plan.maxDepth,
606
+ ...summarizePlanSteps(steps),
607
+ };
608
+ }
609
+ const steps = buildSimpleRecipeDryRunSteps(recipeToPlan, options.vars ?? {}).map(enrichStepFromRegistry);
610
+ return {
611
+ schemaVersion: DRY_RUN_PLAN_SCHEMA_VERSION,
612
+ generatedAt,
613
+ recipe: recipe.name,
614
+ mode: "dry-run",
615
+ triggerType: typeof triggerType === "string" ? triggerType : "manual",
616
+ ...(selection ? { stepSelection: toStepSelection(selection) } : {}),
617
+ steps,
618
+ ...summarizePlanSteps(steps),
619
+ };
620
+ }
621
+ /**
622
+ * Static policy check over a recipe: lint + dry-plan + unresolved/write/fixture checks.
623
+ * No connector calls, no agent calls — safe to run in CI.
624
+ */
625
+ export async function runPreflight(recipeRef, options = {}) {
626
+ const recipePath = resolveRecipePath(recipeRef);
627
+ const issues = [];
628
+ const lint = runLint(recipePath);
629
+ for (const issue of lint.issues) {
630
+ issues.push({
631
+ level: issue.level,
632
+ code: issue.level === "error" ? "lint-error" : "lint-warning",
633
+ message: issue.message,
634
+ });
635
+ }
636
+ const plan = await runRecipeDryPlan(recipeRef, options);
637
+ const requireWriteAck = options.requireWriteAck ?? true;
638
+ const allowlist = new Set(options.allowWrites ?? []);
639
+ for (const step of plan.steps) {
640
+ if (step.type === "tool" && step.tool && step.resolved === false) {
641
+ issues.push({
642
+ level: "error",
643
+ code: "unresolved-tool",
644
+ message: `Tool "${step.tool}" is not registered`,
645
+ stepId: step.id,
646
+ tool: step.tool,
647
+ ...(step.namespace ? { namespace: step.namespace } : {}),
648
+ });
649
+ }
650
+ if (requireWriteAck &&
651
+ step.isWrite === true &&
652
+ step.tool &&
653
+ !allowlist.has(step.tool) &&
654
+ !(step.namespace && allowlist.has(step.namespace))) {
655
+ issues.push({
656
+ level: "error",
657
+ code: "unacknowledged-write",
658
+ message: `Step "${step.id}" performs a write via "${step.tool}" but is not acknowledged via allowWrites`,
659
+ stepId: step.id,
660
+ tool: step.tool,
661
+ ...(step.namespace ? { namespace: step.namespace } : {}),
662
+ });
663
+ }
664
+ }
665
+ if (options.requireFixtures && plan.connectorNamespaces) {
666
+ const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
667
+ for (const ns of plan.connectorNamespaces) {
668
+ const library = loadFixtureLibrary(join(fixturesDir, `${ns}.json`));
669
+ if (!library) {
670
+ issues.push({
671
+ level: "error",
672
+ code: "missing-fixture",
673
+ message: `Missing fixture library for connector "${ns}" at ${fixturesDir}/${ns}.json`,
674
+ namespace: ns,
675
+ });
676
+ }
677
+ }
678
+ }
679
+ const ok = !issues.some((issue) => issue.level === "error");
680
+ return { ok, recipe: plan.recipe, issues, plan };
681
+ }
682
+ /**
683
+ * Watch a recipe file and run preflight on every save (debounced). Composes
684
+ * runWatch + runPreflight so editor integrations get live policy feedback
685
+ * without spawning the CLI per keystroke.
686
+ *
687
+ * Returns a stop function. If a preflight is in-flight when a new save lands,
688
+ * at most one rerun is queued (matches runWatch semantics).
689
+ */
690
+ export function runPreflightWatch(options) {
691
+ const { recipePath, onResult, onError, debounceMs, watchFactory, ...preflightOptions } = options;
692
+ const watchOptions = {
693
+ recipePath,
694
+ onChange: async () => {
695
+ const result = await runPreflight(recipePath, preflightOptions);
696
+ await onResult(result);
697
+ },
698
+ ...(onError ? { onError } : {}),
699
+ ...(debounceMs !== undefined ? { debounceMs } : {}),
700
+ ...(watchFactory ? { watchFactory } : {}),
701
+ };
702
+ return runWatch(watchOptions);
703
+ }
704
+ function resolveRecipePath(recipeRef) {
705
+ const directPath = resolve(recipeRef);
706
+ if (existsSync(directPath) && statSync(directPath).isFile()) {
707
+ return directPath;
708
+ }
709
+ const bundledDir = fileURLToPath(new URL("../../templates/recipes", import.meta.url));
710
+ const normalizedRef = recipeRef.replace(/\.(yaml|yml|json)$/i, "");
711
+ const candidates = [
712
+ join(RECIPES_DIR, `${normalizedRef}.yaml`),
713
+ join(RECIPES_DIR, `${normalizedRef}.yml`),
714
+ join(RECIPES_DIR, `${normalizedRef}.json`),
715
+ join(bundledDir, `${normalizedRef}.yaml`),
716
+ join(bundledDir, `${normalizedRef}.yml`),
717
+ ];
718
+ for (const candidate of candidates) {
719
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
720
+ return candidate;
721
+ }
722
+ }
723
+ throw new Error(`recipe "${basename(recipeRef)}" not found in ${RECIPES_DIR}`);
724
+ }
725
+ function selectRecipeStep(recipe, query) {
726
+ const matches = recipe.steps
727
+ .map((step) => {
728
+ const match = matchRecipeStep(step, query);
729
+ return match ? { ...match, query, step } : undefined;
730
+ })
731
+ .filter((match) => Boolean(match));
732
+ if (matches.length === 0) {
733
+ throw new Error(`Step "${query}" not found in recipe "${recipe.name}"`);
734
+ }
735
+ if (matches.length > 1) {
736
+ const labels = matches
737
+ .map((match) => `${match.matchedBy}:${match.matchedValue}`)
738
+ .join(", ");
739
+ throw new Error(`Step "${query}" is ambiguous in recipe "${recipe.name}": ${labels}`);
740
+ }
741
+ const [match] = matches;
742
+ if (!match) {
743
+ throw new Error(`Step "${query}" not found in recipe "${recipe.name}"`);
744
+ }
745
+ return match;
746
+ }
747
+ function matchRecipeStep(step, query) {
748
+ const id = typeof step.id === "string" ? step.id : undefined;
749
+ if (id === query) {
750
+ return { matchedBy: "id", matchedValue: id };
751
+ }
752
+ const into = getStepInto(step);
753
+ if (into === query) {
754
+ return { matchedBy: "into", matchedValue: into };
755
+ }
756
+ const tool = typeof step.tool === "string" ? step.tool : undefined;
757
+ if (tool === query) {
758
+ return { matchedBy: "tool", matchedValue: tool };
759
+ }
760
+ return null;
761
+ }
762
+ function getStepInto(step) {
763
+ if (typeof step.into === "string" && step.into) {
764
+ return step.into;
765
+ }
766
+ if (step.agent &&
767
+ typeof step.agent === "object" &&
768
+ typeof step.agent.into === "string" &&
769
+ step.agent.into) {
770
+ return step.agent.into;
771
+ }
772
+ return undefined;
773
+ }
774
+ function toStepSelection(selection) {
775
+ return {
776
+ query: selection.query,
777
+ matchedBy: selection.matchedBy,
778
+ matchedValue: selection.matchedValue,
779
+ };
780
+ }
781
+ function buildSimpleRecipeDryRunSteps(recipe, vars) {
782
+ const now = new Date();
783
+ const ctx = {
784
+ date: now.toISOString().slice(0, 10),
785
+ time: now.toTimeString().slice(0, 5),
786
+ ...vars,
787
+ };
788
+ return recipe.steps.map((step, index) => {
789
+ const id = (typeof step.id === "string" && step.id) ||
790
+ getStepInto(step) ||
791
+ step.tool ||
792
+ `step_${index}`;
793
+ if (step.agent) {
794
+ const prompt = render(step.agent.prompt, ctx);
795
+ const into = getStepInto(step);
796
+ if (into) {
797
+ ctx[into] = `[dry-run:${id}]`;
798
+ }
799
+ return {
800
+ id,
801
+ type: "agent",
802
+ into,
803
+ optional: step.optional,
804
+ prompt,
805
+ };
806
+ }
807
+ const params = {};
808
+ for (const [key, value] of Object.entries(step)) {
809
+ if (key === "tool" || key === "agent" || key === "into" || key === "id") {
810
+ continue;
811
+ }
812
+ params[key] = typeof value === "string" ? render(value, ctx) : value;
813
+ }
814
+ const into = getStepInto(step);
815
+ if (into) {
816
+ ctx[into] = `[dry-run:${id}]`;
817
+ if (step.tool) {
818
+ seedToolOutputPreviewContext(step.tool, into, id, ctx);
819
+ }
820
+ }
821
+ return {
822
+ id,
823
+ type: "tool",
824
+ tool: step.tool,
825
+ into,
826
+ optional: step.optional,
827
+ params,
828
+ };
829
+ });
830
+ }
831
+ export async function runRecord(recipePath, options = {}) {
832
+ const lint = runLint(recipePath);
833
+ const issues = [...lint.issues];
834
+ const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
835
+ let recordedFixtures = [];
836
+ let stepsRun = 0;
837
+ let outputs = [];
838
+ if (issues.every((issue) => issue.level !== "error")) {
839
+ try {
840
+ const recipe = loadYamlRecipe(recipePath);
841
+ recordedFixtures = getRequiredFixtureNamespaces(recipe.steps);
842
+ const run = await runYamlRecipe(recipe, {
843
+ ...options.deps,
844
+ recordFixturesDir: fixturesDir,
845
+ });
846
+ stepsRun = run.stepsRun;
847
+ outputs = run.outputs;
848
+ if (run.errorMessage) {
849
+ issues.push({
850
+ level: "error",
851
+ message: run.errorMessage,
852
+ });
853
+ }
854
+ }
855
+ catch (err) {
856
+ issues.push({
857
+ level: "error",
858
+ message: err instanceof Error ? err.message : String(err),
859
+ });
860
+ }
861
+ }
862
+ const errors = issues.filter((issue) => issue.level === "error").length;
863
+ const warnings = issues.filter((issue) => issue.level === "warning").length;
864
+ return {
865
+ valid: errors === 0,
866
+ issues,
867
+ warnings,
868
+ errors,
869
+ recordedFixtures,
870
+ stepsRun,
871
+ outputs,
872
+ };
873
+ }
874
+ export async function runTest(recipePath, options = {}) {
875
+ const lint = runLint(recipePath);
876
+ const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
877
+ const issues = [...lint.issues];
878
+ let requiredFixtures = [];
879
+ let stepsRun = 0;
880
+ let outputs = [];
881
+ let assertionFailures = [];
882
+ if (existsSync(recipePath)) {
883
+ try {
884
+ const recipe = parseYaml(readFileSync(recipePath, "utf-8"));
885
+ requiredFixtures = getRequiredFixtureNamespaces(recipe.steps ?? []);
886
+ }
887
+ catch {
888
+ requiredFixtures = [];
889
+ }
890
+ }
891
+ const missingFixtures = requiredFixtures.filter((provider) => !existsSync(join(fixturesDir, `${provider}.json`)));
892
+ for (const provider of missingFixtures) {
893
+ issues.push({
894
+ level: "error",
895
+ message: `Missing fixture library for connector '${provider}' at ${join(fixturesDir, `${provider}.json`)}`,
896
+ });
897
+ }
898
+ if (issues.every((issue) => issue.level !== "error")) {
899
+ try {
900
+ const recipe = loadYamlRecipe(recipePath);
901
+ const triggerType = recipe.trigger?.type;
902
+ if (triggerType === "chained") {
903
+ // Chained recipes: run through chainedRunner with mocked tool + agent executors
904
+ const { runChainedRecipe } = await import("../recipes/chainedRunner.js");
905
+ const { evaluateExpect } = await import("../recipes/yamlRunner.js");
906
+ const chainedRecipe = recipe;
907
+ const recipeRecord = recipe;
908
+ const run = await runChainedRecipe(chainedRecipe, {
909
+ env: process.env,
910
+ maxConcurrency: recipeRecord.maxConcurrency ?? 4,
911
+ maxDepth: recipeRecord.maxDepth ?? 3,
912
+ dryRun: false,
913
+ sourcePath: recipePath,
914
+ }, {
915
+ executeTool: async (tool) => `[mock:${tool}]`,
916
+ executeAgent: async () => "[mock agent output]",
917
+ loadNestedRecipe: async () => null,
918
+ });
919
+ stepsRun = run.summary.total;
920
+ if (run.errorMessage) {
921
+ issues.push({ level: "error", message: run.errorMessage });
922
+ }
923
+ // Evaluate expect: block against chained run results
924
+ const expectBlock = recipeRecord.expect;
925
+ if (expectBlock) {
926
+ const failures = evaluateExpect({
927
+ stepsRun: run.summary.total,
928
+ outputs: [],
929
+ context: run.context,
930
+ errorMessage: run.errorMessage,
931
+ }, expectBlock);
932
+ assertionFailures = failures;
933
+ for (const failure of failures) {
934
+ issues.push({ level: "error", message: failure.message });
935
+ }
936
+ }
937
+ }
938
+ else {
939
+ const mockConnectors = createMockToolConnectors(recipe.steps, fixturesDir);
940
+ const run = await runYamlRecipe(recipe, {
941
+ testMode: true,
942
+ mockConnectors,
943
+ readFile: (filePath) => readFileSync(filePath, "utf-8"),
944
+ writeFile: () => { },
945
+ appendFile: () => { },
946
+ mkdir: () => { },
947
+ gitLogSince: () => "[mock git log]",
948
+ gitStaleBranches: () => "[mock stale branches]",
949
+ getDiagnostics: () => "[mock diagnostics]",
950
+ claudeFn: async () => "[mock agent output]",
951
+ claudeCodeFn: async () => "[mock agent output]",
952
+ providerDriverFn: async () => "[mock agent output]",
953
+ });
954
+ stepsRun = run.stepsRun;
955
+ outputs = run.outputs;
956
+ if (run.assertionFailures && run.assertionFailures.length > 0) {
957
+ assertionFailures = run.assertionFailures;
958
+ for (const failure of run.assertionFailures) {
959
+ issues.push({ level: "error", message: failure.message });
960
+ }
961
+ }
962
+ if (run.errorMessage) {
963
+ issues.push({ level: "error", message: run.errorMessage });
964
+ }
965
+ }
966
+ }
967
+ catch (err) {
968
+ issues.push({
969
+ level: "error",
970
+ message: err instanceof Error ? err.message : String(err),
971
+ });
972
+ }
973
+ }
974
+ const errors = issues.filter((issue) => issue.level === "error").length;
975
+ const warnings = issues.filter((issue) => issue.level === "warning").length;
976
+ return {
977
+ valid: errors === 0,
978
+ issues,
979
+ warnings,
980
+ errors,
981
+ requiredFixtures,
982
+ missingFixtures,
983
+ stepsRun,
984
+ outputs,
985
+ assertionFailures,
986
+ };
987
+ }
988
+ /**
989
+ * Watch a recipe file and re-run `patchwork recipe test` on every save (debounced).
990
+ * Mirrors runPreflightWatch — composes runWatch + runTest.
991
+ * Returns a stop function.
992
+ */
993
+ export function runTestWatch(options) {
994
+ const { recipePath, fixturesDir, onResult, onError, debounceMs, watchFactory, } = options;
995
+ return runWatch({
996
+ recipePath,
997
+ onChange: async () => {
998
+ const result = await runTest(recipePath, { fixturesDir });
999
+ await onResult(result);
1000
+ },
1001
+ ...(onError ? { onError } : {}),
1002
+ ...(debounceMs !== undefined ? { debounceMs } : {}),
1003
+ ...(watchFactory ? { watchFactory } : {}),
1004
+ });
1005
+ }
1006
+ function getRequiredFixtureNamespaces(steps) {
1007
+ const namespaces = new Set();
1008
+ for (const step of steps) {
1009
+ const tool = step.tool;
1010
+ if (typeof tool !== "string") {
1011
+ continue;
1012
+ }
1013
+ const namespace = tool.split(".")[0];
1014
+ if (namespace && isConnectorNamespace(namespace)) {
1015
+ namespaces.add(namespace);
1016
+ }
1017
+ }
1018
+ return [...namespaces].sort();
1019
+ }
1020
+ function createMockToolConnectors(steps, fixturesDir) {
1021
+ const providerConnectors = new Map();
1022
+ const toolConnectors = {};
1023
+ for (const step of steps) {
1024
+ const tool = step.tool;
1025
+ if (typeof tool !== "string") {
1026
+ continue;
1027
+ }
1028
+ const [namespace, operation] = tool.split(".");
1029
+ if (!namespace || !operation || !isConnectorNamespace(namespace)) {
1030
+ continue;
1031
+ }
1032
+ let connector = providerConnectors.get(namespace);
1033
+ if (!connector) {
1034
+ connector = new MockConnector(namespace, {
1035
+ fixturePath: join(fixturesDir, `${namespace}.json`),
1036
+ });
1037
+ providerConnectors.set(namespace, connector);
1038
+ }
1039
+ toolConnectors[tool] = {
1040
+ invoke: async (_unusedOperation, input) => {
1041
+ const output = await connector.invoke(operation, input);
1042
+ return (typeof output === "string" ? output : JSON.stringify(output));
1043
+ },
1044
+ };
1045
+ }
1046
+ return toolConnectors;
1047
+ }
1048
+ function normalizeChangedFile(changedFile) {
1049
+ if (typeof changedFile === "string") {
1050
+ return changedFile;
1051
+ }
1052
+ if (changedFile instanceof Buffer) {
1053
+ return changedFile.toString();
1054
+ }
1055
+ return null;
1056
+ }
1057
+ export function runWatch(options) {
1058
+ const dir = dirname(resolve(options.recipePath));
1059
+ const filename = basename(options.recipePath);
1060
+ const debounceMs = options.debounceMs ?? 300;
1061
+ const watchFactory = options.watchFactory ??
1062
+ ((watchPath, watchOptions, listener) => watch(watchPath, watchOptions, listener));
1063
+ let debounceTimer = null;
1064
+ let running = false;
1065
+ let rerunQueued = false;
1066
+ let stopped = false;
1067
+ const handleError = (err) => {
1068
+ options.onError?.(err instanceof Error ? err : new Error(String(err)));
1069
+ };
1070
+ const finishChange = () => {
1071
+ running = false;
1072
+ if (stopped || !rerunQueued) {
1073
+ return;
1074
+ }
1075
+ rerunQueued = false;
1076
+ executeChange();
1077
+ };
1078
+ const executeChange = () => {
1079
+ if (stopped) {
1080
+ return;
1081
+ }
1082
+ if (running) {
1083
+ rerunQueued = true;
1084
+ return;
1085
+ }
1086
+ running = true;
1087
+ try {
1088
+ const changeResult = options.onChange();
1089
+ void Promise.resolve(changeResult)
1090
+ .catch(handleError)
1091
+ .finally(finishChange);
1092
+ }
1093
+ catch (err) {
1094
+ handleError(err);
1095
+ finishChange();
1096
+ }
1097
+ };
1098
+ const scheduleChange = () => {
1099
+ if (stopped) {
1100
+ return;
1101
+ }
1102
+ if (running) {
1103
+ rerunQueued = true;
1104
+ return;
1105
+ }
1106
+ if (debounceTimer) {
1107
+ clearTimeout(debounceTimer);
1108
+ }
1109
+ debounceTimer = setTimeout(() => {
1110
+ debounceTimer = null;
1111
+ executeChange();
1112
+ }, debounceMs);
1113
+ };
1114
+ const watcher = watchFactory(dir, { recursive: false }, (_eventType, changedFile) => {
1115
+ const changedName = normalizeChangedFile(changedFile);
1116
+ if (changedName === filename) {
1117
+ scheduleChange();
1118
+ }
1119
+ });
1120
+ // Return cleanup function
1121
+ return () => {
1122
+ stopped = true;
1123
+ if (debounceTimer) {
1124
+ clearTimeout(debounceTimer);
1125
+ debounceTimer = null;
1126
+ }
1127
+ watcher.close();
1128
+ };
1129
+ }
1130
+ //# sourceMappingURL=recipe.js.map