patchwork-os 0.2.0-alpha.21 → 0.2.0-alpha.22

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 (169) hide show
  1. package/README.md +26 -12
  2. package/deploy/bootstrap-vps.sh +184 -0
  3. package/dist/approvalHttp.js +6 -1
  4. package/dist/approvalHttp.js.map +1 -1
  5. package/dist/automation.d.ts +20 -0
  6. package/dist/automation.js +35 -0
  7. package/dist/automation.js.map +1 -1
  8. package/dist/bridge.js +22 -4
  9. package/dist/bridge.js.map +1 -1
  10. package/dist/bridgeToken.js +57 -19
  11. package/dist/bridgeToken.js.map +1 -1
  12. package/dist/commands/recipe.d.ts +256 -0
  13. package/dist/commands/recipe.js +1313 -0
  14. package/dist/commands/recipe.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.js +9 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/connectors/baseConnector.d.ts +117 -0
  19. package/dist/connectors/baseConnector.js +213 -0
  20. package/dist/connectors/baseConnector.js.map +1 -0
  21. package/dist/connectors/confluence.d.ts +111 -0
  22. package/dist/connectors/confluence.js +406 -0
  23. package/dist/connectors/confluence.js.map +1 -0
  24. package/dist/connectors/fixtureLibrary.d.ts +21 -0
  25. package/dist/connectors/fixtureLibrary.js +70 -0
  26. package/dist/connectors/fixtureLibrary.js.map +1 -0
  27. package/dist/connectors/fixtureRecorder.d.ts +1 -0
  28. package/dist/connectors/fixtureRecorder.js +35 -0
  29. package/dist/connectors/fixtureRecorder.js.map +1 -0
  30. package/dist/connectors/github.js +2 -11
  31. package/dist/connectors/github.js.map +1 -1
  32. package/dist/connectors/gmail.js +23 -7
  33. package/dist/connectors/gmail.js.map +1 -1
  34. package/dist/connectors/googleCalendar.js +23 -7
  35. package/dist/connectors/googleCalendar.js.map +1 -1
  36. package/dist/connectors/jira.d.ts +98 -0
  37. package/dist/connectors/jira.js +379 -0
  38. package/dist/connectors/jira.js.map +1 -0
  39. package/dist/connectors/linear.js +2 -11
  40. package/dist/connectors/linear.js.map +1 -1
  41. package/dist/connectors/mcpOAuth.d.ts +1 -0
  42. package/dist/connectors/mcpOAuth.js +30 -4
  43. package/dist/connectors/mcpOAuth.js.map +1 -1
  44. package/dist/connectors/mockConnector.d.ts +28 -0
  45. package/dist/connectors/mockConnector.js +81 -0
  46. package/dist/connectors/mockConnector.js.map +1 -0
  47. package/dist/connectors/notion.d.ts +143 -0
  48. package/dist/connectors/notion.js +424 -0
  49. package/dist/connectors/notion.js.map +1 -0
  50. package/dist/connectors/sentry.js +2 -11
  51. package/dist/connectors/sentry.js.map +1 -1
  52. package/dist/connectors/slack.js +50 -15
  53. package/dist/connectors/slack.js.map +1 -1
  54. package/dist/connectors/tokenStorage.d.ts +35 -0
  55. package/dist/connectors/tokenStorage.js +394 -0
  56. package/dist/connectors/tokenStorage.js.map +1 -0
  57. package/dist/connectors/zendesk.d.ts +104 -0
  58. package/dist/connectors/zendesk.js +424 -0
  59. package/dist/connectors/zendesk.js.map +1 -0
  60. package/dist/featureFlags.d.ts +73 -0
  61. package/dist/featureFlags.js +203 -0
  62. package/dist/featureFlags.js.map +1 -0
  63. package/dist/fp/automationInterpreter.js +1 -0
  64. package/dist/fp/automationInterpreter.js.map +1 -1
  65. package/dist/fp/automationProgram.d.ts +1 -1
  66. package/dist/fp/automationProgram.js.map +1 -1
  67. package/dist/fp/policyParser.js +17 -0
  68. package/dist/fp/policyParser.js.map +1 -1
  69. package/dist/index.js +508 -36
  70. package/dist/index.js.map +1 -1
  71. package/dist/oauth.d.ts +4 -1
  72. package/dist/oauth.js +50 -14
  73. package/dist/oauth.js.map +1 -1
  74. package/dist/recipes/chainedRunner.d.ts +104 -0
  75. package/dist/recipes/chainedRunner.js +359 -0
  76. package/dist/recipes/chainedRunner.js.map +1 -0
  77. package/dist/recipes/dependencyGraph.d.ts +39 -0
  78. package/dist/recipes/dependencyGraph.js +199 -0
  79. package/dist/recipes/dependencyGraph.js.map +1 -0
  80. package/dist/recipes/legacyRecipeCompat.d.ts +1 -0
  81. package/dist/recipes/legacyRecipeCompat.js +97 -0
  82. package/dist/recipes/legacyRecipeCompat.js.map +1 -0
  83. package/dist/recipes/nestedRecipeStep.d.ts +58 -0
  84. package/dist/recipes/nestedRecipeStep.js +95 -0
  85. package/dist/recipes/nestedRecipeStep.js.map +1 -0
  86. package/dist/recipes/outputRegistry.d.ts +28 -0
  87. package/dist/recipes/outputRegistry.js +52 -0
  88. package/dist/recipes/outputRegistry.js.map +1 -0
  89. package/dist/recipes/schemaGenerator.d.ts +28 -0
  90. package/dist/recipes/schemaGenerator.js +484 -0
  91. package/dist/recipes/schemaGenerator.js.map +1 -0
  92. package/dist/recipes/templateEngine.d.ts +62 -0
  93. package/dist/recipes/templateEngine.js +182 -0
  94. package/dist/recipes/templateEngine.js.map +1 -0
  95. package/dist/recipes/toolRegistry.d.ts +181 -0
  96. package/dist/recipes/toolRegistry.js +300 -0
  97. package/dist/recipes/toolRegistry.js.map +1 -0
  98. package/dist/recipes/tools/calendar.d.ts +6 -0
  99. package/dist/recipes/tools/calendar.js +61 -0
  100. package/dist/recipes/tools/calendar.js.map +1 -0
  101. package/dist/recipes/tools/confluence.d.ts +6 -0
  102. package/dist/recipes/tools/confluence.js +254 -0
  103. package/dist/recipes/tools/confluence.js.map +1 -0
  104. package/dist/recipes/tools/diagnostics.d.ts +6 -0
  105. package/dist/recipes/tools/diagnostics.js +36 -0
  106. package/dist/recipes/tools/diagnostics.js.map +1 -0
  107. package/dist/recipes/tools/file.d.ts +6 -0
  108. package/dist/recipes/tools/file.js +170 -0
  109. package/dist/recipes/tools/file.js.map +1 -0
  110. package/dist/recipes/tools/git.d.ts +6 -0
  111. package/dist/recipes/tools/git.js +63 -0
  112. package/dist/recipes/tools/git.js.map +1 -0
  113. package/dist/recipes/tools/github.d.ts +6 -0
  114. package/dist/recipes/tools/github.js +91 -0
  115. package/dist/recipes/tools/github.js.map +1 -0
  116. package/dist/recipes/tools/gmail.d.ts +6 -0
  117. package/dist/recipes/tools/gmail.js +210 -0
  118. package/dist/recipes/tools/gmail.js.map +1 -0
  119. package/dist/recipes/tools/index.d.ts +18 -0
  120. package/dist/recipes/tools/index.js +21 -0
  121. package/dist/recipes/tools/index.js.map +1 -0
  122. package/dist/recipes/tools/linear.d.ts +6 -0
  123. package/dist/recipes/tools/linear.js +83 -0
  124. package/dist/recipes/tools/linear.js.map +1 -0
  125. package/dist/recipes/tools/notion.d.ts +6 -0
  126. package/dist/recipes/tools/notion.js +278 -0
  127. package/dist/recipes/tools/notion.js.map +1 -0
  128. package/dist/recipes/tools/slack.d.ts +6 -0
  129. package/dist/recipes/tools/slack.js +72 -0
  130. package/dist/recipes/tools/slack.js.map +1 -0
  131. package/dist/recipes/tools/zendesk.d.ts +6 -0
  132. package/dist/recipes/tools/zendesk.js +245 -0
  133. package/dist/recipes/tools/zendesk.js.map +1 -0
  134. package/dist/recipes/yamlRunner.d.ts +71 -7
  135. package/dist/recipes/yamlRunner.js +406 -439
  136. package/dist/recipes/yamlRunner.js.map +1 -1
  137. package/dist/riskTier.js +1 -0
  138. package/dist/riskTier.js.map +1 -1
  139. package/dist/runLog.d.ts +18 -0
  140. package/dist/runLog.js +5 -0
  141. package/dist/runLog.js.map +1 -1
  142. package/dist/server.d.ts +4 -0
  143. package/dist/server.js +224 -0
  144. package/dist/server.js.map +1 -1
  145. package/dist/streamableHttp.js +2 -0
  146. package/dist/streamableHttp.js.map +1 -1
  147. package/dist/tools/github/actions.js +4 -2
  148. package/dist/tools/github/actions.js.map +1 -1
  149. package/dist/tools/github/composite.d.ts +339 -0
  150. package/dist/tools/github/composite.js +343 -0
  151. package/dist/tools/github/composite.js.map +1 -0
  152. package/dist/tools/github/index.d.ts +1 -0
  153. package/dist/tools/github/index.js +1 -0
  154. package/dist/tools/github/index.js.map +1 -1
  155. package/dist/tools/github/issues.js +8 -4
  156. package/dist/tools/github/issues.js.map +1 -1
  157. package/dist/tools/github/pr.js +14 -7
  158. package/dist/tools/github/pr.js.map +1 -1
  159. package/dist/tools/index.js +10 -1
  160. package/dist/tools/index.js.map +1 -1
  161. package/dist/tools/searchTools.js +1 -1
  162. package/dist/tools/searchTools.js.map +1 -1
  163. package/dist/transport.d.ts +7 -1
  164. package/dist/transport.js +85 -11
  165. package/dist/transport.js.map +1 -1
  166. package/package.json +1 -1
  167. package/templates/automation-policies/recipe-authoring.json +25 -0
  168. package/templates/automation-policy.example.json +6 -0
  169. package/templates/recipes/lint-on-save.yaml +1 -2
@@ -25,6 +25,70 @@ 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 { normalizeRecipeForRuntime } from "./legacyRecipeCompat.js";
30
+ // Import tool registry and trigger tool self-registration
31
+ import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
32
+ import "./tools/index.js";
33
+ export function evaluateExpect(result, expect) {
34
+ const failures = [];
35
+ if (expect.stepsRun !== undefined && result.stepsRun !== expect.stepsRun) {
36
+ failures.push({
37
+ assertion: "stepsRun",
38
+ expected: expect.stepsRun,
39
+ actual: result.stepsRun,
40
+ message: `Expected stepsRun=${expect.stepsRun}, got ${result.stepsRun}`,
41
+ });
42
+ }
43
+ if (expect.errorMessage !== undefined) {
44
+ const expected = expect.errorMessage ?? null;
45
+ const actual = result.errorMessage ?? null;
46
+ if (expected !== actual) {
47
+ failures.push({
48
+ assertion: "errorMessage",
49
+ expected,
50
+ actual,
51
+ message: expected === null
52
+ ? `Expected clean run (no error), got: ${actual}`
53
+ : `Expected error "${expected}", got: ${actual === null ? "(none)" : actual}`,
54
+ });
55
+ }
56
+ }
57
+ if (expect.outputs !== undefined) {
58
+ for (const key of expect.outputs) {
59
+ if (!result.outputs.includes(key)) {
60
+ failures.push({
61
+ assertion: "outputs",
62
+ expected: key,
63
+ actual: result.outputs,
64
+ message: `Expected output key "${key}" not found in [${result.outputs.join(", ")}]`,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ if (expect.context !== undefined) {
70
+ for (const [key, expectedVal] of Object.entries(expect.context)) {
71
+ const actual = result.context[key];
72
+ if (actual === undefined) {
73
+ failures.push({
74
+ assertion: `context.${key}`,
75
+ expected: expectedVal,
76
+ actual: undefined,
77
+ message: `Expected context key "${key}" to equal "${expectedVal}", but key is missing`,
78
+ });
79
+ }
80
+ else if (!actual.includes(expectedVal)) {
81
+ failures.push({
82
+ assertion: `context.${key}`,
83
+ expected: expectedVal,
84
+ actual,
85
+ message: `Expected context["${key}"] to contain "${expectedVal}", got "${actual}"`,
86
+ });
87
+ }
88
+ }
89
+ }
90
+ return failures;
91
+ }
28
92
  // Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
29
93
  function stripLeadingNarration(text) {
30
94
  const lines = text.split("\n");
@@ -37,10 +101,11 @@ export function loadYamlRecipe(filePath) {
37
101
  return validateYamlRecipe(raw);
38
102
  }
39
103
  export function validateYamlRecipe(raw) {
40
- if (typeof raw !== "object" || raw === null) {
104
+ const normalized = normalizeRecipeForRuntime(raw);
105
+ if (typeof normalized !== "object" || normalized === null) {
41
106
  throw new Error("recipe must be an object");
42
107
  }
43
- const r = raw;
108
+ const r = normalized;
44
109
  if (typeof r.name !== "string" || !r.name) {
45
110
  throw new Error("recipe.name required");
46
111
  }
@@ -50,54 +115,80 @@ export function validateYamlRecipe(raw) {
50
115
  if (!Array.isArray(r.steps) || r.steps.length === 0) {
51
116
  throw new Error("recipe.steps must be a non-empty array");
52
117
  }
118
+ if (r.servers !== undefined &&
119
+ (!Array.isArray(r.servers) ||
120
+ r.servers.some((s) => typeof s !== "string"))) {
121
+ throw new Error("recipe.servers must be an array of strings if present");
122
+ }
53
123
  return r;
54
124
  }
125
+ /** Track already-loaded plugin specs to avoid double-loading within a process. */
126
+ const loadedPluginSpecs = new Set();
127
+ /**
128
+ * Load plugin specs declared in `recipe.servers` and register their tools into
129
+ * the recipe tool registry. Errors per-spec are logged as warnings — never fatal.
130
+ */
131
+ export async function loadRecipeServers(specs) {
132
+ const toLoad = specs.filter((s) => !loadedPluginSpecs.has(s));
133
+ if (toLoad.length === 0)
134
+ return;
135
+ let loadPluginsFull;
136
+ try {
137
+ ({ loadPluginsFull } = await import("../pluginLoader.js"));
138
+ }
139
+ catch (err) {
140
+ console.warn(`[recipe servers] failed to import pluginLoader: ${err instanceof Error ? err.message : String(err)}`);
141
+ return;
142
+ }
143
+ const minimalConfig = {
144
+ workspace: process.cwd(),
145
+ workspaceFolders: [process.cwd()],
146
+ commandTimeout: 30_000,
147
+ maxResultSize: 1_048_576,
148
+ };
149
+ const minimalLogger = {
150
+ info: (msg) => console.info(`[recipe servers] ${msg}`),
151
+ warn: (msg) => console.warn(`[recipe servers] ${msg}`),
152
+ error: (msg) => console.error(`[recipe servers] ${msg}`),
153
+ debug: (_msg) => { },
154
+ };
155
+ for (const spec of toLoad) {
156
+ try {
157
+ const loaded = await loadPluginsFull([spec], minimalConfig, minimalLogger);
158
+ let toolCount = 0;
159
+ for (const plugin of loaded) {
160
+ const pluginTools = plugin.tools.map((t) => ({
161
+ name: t.schema.name,
162
+ handler: t.handler,
163
+ schema: t.schema,
164
+ }));
165
+ toolCount += registerPluginTools(pluginTools);
166
+ }
167
+ loadedPluginSpecs.add(spec);
168
+ if (toolCount > 0) {
169
+ console.info(`[recipe servers] loaded "${spec}" — ${toolCount} tool(s) registered`);
170
+ }
171
+ }
172
+ catch (err) {
173
+ console.warn(`[recipe servers] failed to load "${spec}": ${err instanceof Error ? err.message : String(err)}`);
174
+ }
175
+ }
176
+ }
55
177
  export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
178
+ if (recipe.servers?.length) {
179
+ await loadRecipeServers(recipe.servers);
180
+ }
56
181
  const now = deps.now ? deps.now() : new Date();
57
182
  const ctx = {
58
183
  date: now.toISOString().slice(0, 10),
59
184
  time: now.toTimeString().slice(0, 5),
60
185
  ...seedContext,
61
186
  };
62
- const readFile = deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8"));
63
- const writeFile = deps.writeFile ??
64
- ((p, content) => {
65
- const abs = expandHome(p);
66
- mkdirSync(path.dirname(abs), { recursive: true });
67
- writeFileSync(abs, content);
68
- });
69
- const appendFile = deps.appendFile ??
70
- ((p, content) => {
71
- const abs = expandHome(p);
72
- mkdirSync(path.dirname(abs), { recursive: true });
73
- appendFileSync(abs, content);
74
- });
75
- const mkdir = deps.mkdir ??
76
- ((p) => mkdirSync(expandHome(p), { recursive: true }));
187
+ const stepDeps = resolveStepDeps(deps);
77
188
  const outputs = [];
78
189
  const stepResults = [];
79
190
  let stepsRun = 0;
80
191
  let runError;
81
- const workdir = deps.workdir ?? process.cwd();
82
- const stepDeps = {
83
- readFile,
84
- writeFile,
85
- appendFile,
86
- mkdir,
87
- workdir,
88
- gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
89
- gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
90
- getDiagnostics: deps.getDiagnostics ?? (() => ""),
91
- fetchFn: deps.fetchFn ?? globalThis.fetch,
92
- claudeFn: deps.claudeFn ?? defaultClaudeFn,
93
- claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
94
- providerDriverFn: deps.providerDriverFn ?? defaultProviderDriverFn,
95
- getGmailToken: deps.getGmailToken ??
96
- (async () => {
97
- const { getValidAccessToken } = await import("../connectors/gmail.js");
98
- return getValidAccessToken();
99
- }),
100
- };
101
192
  for (const step of recipe.steps) {
102
193
  // Handle agent steps separately
103
194
  if (step.agent) {
@@ -232,28 +323,20 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
232
323
  }
233
324
  stepsRun++;
234
325
  if (result !== null) {
326
+ // Apply transform if present — render template with $result injected
327
+ if (step.transform) {
328
+ try {
329
+ result = render(step.transform, { ...ctx, $result: result });
330
+ }
331
+ catch (err) {
332
+ // warn but fall through with original result
333
+ console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
334
+ }
335
+ }
235
336
  if (step.into) {
236
337
  ctx[step.into] = result;
237
- // For Gmail steps, also expose flat dot-notation keys for render()
238
- const isGmailStep = step.tool === "gmail.fetch_unread" ||
239
- step.tool === "gmail.search" ||
240
- step.tool === "gmail.fetch_thread";
241
- if (isGmailStep) {
242
- try {
243
- const parsed = JSON.parse(result);
244
- for (const [k, v] of Object.entries(parsed)) {
245
- if (typeof v === "string" || typeof v === "number") {
246
- ctx[`${step.into}.${k}`] = String(v);
247
- }
248
- }
249
- // Also expose messages array as JSON string for agent prompts
250
- if (Array.isArray(parsed.messages)) {
251
- ctx[`${step.into}.json`] = JSON.stringify(parsed.messages);
252
- }
253
- }
254
- catch {
255
- // non-JSON result, skip
256
- }
338
+ if (step.tool) {
339
+ applyToolOutputContext(step.tool, step.into, result, ctx);
257
340
  }
258
341
  }
259
342
  if (step.tool === "file.write" || step.tool === "file.append") {
@@ -261,39 +344,53 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
261
344
  }
262
345
  }
263
346
  }
347
+ // Evaluate expect block before persisting so failures are stored in the run log
348
+ const assertionFailures = recipe.expect
349
+ ? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
350
+ : [];
264
351
  // Write to RecipeRunLog so the dashboard Runs page shows this execution
265
- try {
266
- const { RecipeRunLog } = await import("../runLog.js");
267
- const { homedir } = await import("node:os");
268
- const logDir = path.join(homedir(), ".patchwork");
269
- const log = new RecipeRunLog({ dir: logDir });
270
- const trigger = recipe.trigger?.type ?? "manual";
271
- const createdAt = now.getTime();
272
- const doneAt = Date.now();
273
- const outputTail = stepResults
274
- .map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
275
- .join("\n")
276
- .slice(0, 2000);
277
- log.appendDirect({
278
- taskId: `yaml:${recipe.name}:${createdAt}`,
279
- recipeName: recipe.name,
280
- trigger: (["cron", "webhook", "recipe"].includes(trigger)
281
- ? trigger
282
- : "recipe"),
283
- status: runError ? "error" : "done",
284
- createdAt,
285
- startedAt: createdAt,
286
- doneAt,
287
- durationMs: doneAt - createdAt,
288
- outputTail,
289
- errorMessage: runError,
290
- });
291
- }
292
- catch {
293
- // Non-fatal — run log write failure should never break recipe execution
352
+ if (!stepDeps.testMode) {
353
+ try {
354
+ const { RecipeRunLog } = await import("../runLog.js");
355
+ const { homedir } = await import("node:os");
356
+ const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
357
+ const log = new RecipeRunLog({ dir: resolvedLogDir });
358
+ const trigger = recipe.trigger?.type ?? "manual";
359
+ const createdAt = now.getTime();
360
+ const doneAt = Date.now();
361
+ const outputTail = stepResults
362
+ .map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
363
+ .join("\n")
364
+ .slice(0, 2000);
365
+ log.appendDirect({
366
+ taskId: `yaml:${recipe.name}:${createdAt}`,
367
+ recipeName: recipe.name,
368
+ trigger: (["cron", "webhook", "recipe"].includes(trigger)
369
+ ? trigger
370
+ : "recipe"),
371
+ status: runError ? "error" : "done",
372
+ createdAt,
373
+ startedAt: createdAt,
374
+ doneAt,
375
+ durationMs: doneAt - createdAt,
376
+ outputTail,
377
+ errorMessage: runError,
378
+ stepResults: stepResults.map((s) => ({
379
+ id: s.id,
380
+ tool: s.tool,
381
+ status: s.status,
382
+ error: s.error,
383
+ durationMs: s.durationMs,
384
+ })),
385
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
386
+ });
387
+ }
388
+ catch {
389
+ // Non-fatal — run log write failure should never break recipe execution
390
+ }
294
391
  }
295
392
  // Notify via Slack if any step failed
296
- if (runError) {
393
+ if (runError && !stepDeps.testMode) {
297
394
  try {
298
395
  const { isConnected, postMessage } = await import("../connectors/slack.js");
299
396
  if (isConnected()) {
@@ -328,6 +425,140 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
328
425
  context: ctx,
329
426
  stepResults,
330
427
  errorMessage: runError,
428
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
429
+ };
430
+ }
431
+ export async function executeStep(step, ctx, deps) {
432
+ const toolId = step.tool;
433
+ if (!toolId) {
434
+ return null;
435
+ }
436
+ // Check if tool is registered in the new registry
437
+ if (hasTool(toolId)) {
438
+ const tool = getTool(toolId);
439
+ // Build params with template rendering for string values
440
+ const params = {};
441
+ for (const [key, value] of Object.entries(step)) {
442
+ if (key === "tool" || key === "agent" || key === "into")
443
+ continue;
444
+ if (typeof value === "string") {
445
+ params[key] = render(value, ctx);
446
+ }
447
+ else {
448
+ params[key] = value;
449
+ }
450
+ }
451
+ // Check if mock connector is available for this tool
452
+ if (deps.mockConnectors?.[toolId]) {
453
+ return deps.mockConnectors[toolId].invoke("execute", params);
454
+ }
455
+ if (tool &&
456
+ deps.recordFixturesDir &&
457
+ tool.namespace !== "file" &&
458
+ tool.namespace !== "git" &&
459
+ tool.namespace !== "diagnostics") {
460
+ return captureFixture(path.join(deps.recordFixturesDir, `${tool.namespace}.json`), tool.namespace, toolId.split(".")[1] ?? toolId, params, async () => executeTool(toolId, { params, step, ctx, deps }));
461
+ }
462
+ return executeTool(toolId, { params, step, ctx, deps });
463
+ }
464
+ // Unknown tool — skip, don't throw (forward compat)
465
+ return null;
466
+ }
467
+ /** Minimal `{{ expr }}` renderer — replaces against flat context map. */
468
+ export function render(template, ctx) {
469
+ return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
470
+ const key = expr.trim();
471
+ return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
472
+ });
473
+ }
474
+ function expandHome(p) {
475
+ if (p.startsWith("~/"))
476
+ return path.join(os.homedir(), p.slice(2));
477
+ return p;
478
+ }
479
+ function parseSinceToGitArg(since) {
480
+ const m = /^(\d+)(h|d)$/i.exec(since.trim());
481
+ if (!m)
482
+ return since;
483
+ const [, num, unit = "h"] = m;
484
+ return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
485
+ }
486
+ function defaultGitLogSince(since, workdir) {
487
+ try {
488
+ const sinceArg = parseSinceToGitArg(since);
489
+ const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
490
+ cwd: workdir ?? process.cwd(),
491
+ encoding: "utf-8",
492
+ timeout: 5000,
493
+ });
494
+ if (result.error || result.status !== 0)
495
+ return "(git log unavailable)";
496
+ return (result.stdout ?? "").trim();
497
+ }
498
+ catch {
499
+ return "(git log unavailable)";
500
+ }
501
+ }
502
+ function defaultGitStaleBranches(days, workdir) {
503
+ try {
504
+ const cutoff = new Date(Date.now() - days * 86_400_000)
505
+ .toISOString()
506
+ .slice(0, 10);
507
+ const r = spawnSync("git", [
508
+ "branch",
509
+ "--no-column",
510
+ "--sort=-committerdate",
511
+ "--format=%(refname:short)",
512
+ `--since=${cutoff}`,
513
+ ], {
514
+ cwd: workdir ?? process.cwd(),
515
+ encoding: "utf-8",
516
+ timeout: 5000,
517
+ });
518
+ if (r.error || r.status !== 0)
519
+ return "(git branches unavailable)";
520
+ return (r.stdout ?? "").trim();
521
+ }
522
+ catch {
523
+ return "(git branches unavailable)";
524
+ }
525
+ }
526
+ /** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
527
+ function resolveStepDeps(deps) {
528
+ const workdir = deps.workdir ?? process.cwd();
529
+ return {
530
+ readFile: deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8")),
531
+ writeFile: deps.writeFile ??
532
+ ((p, content) => {
533
+ const abs = expandHome(p);
534
+ mkdirSync(path.dirname(abs), { recursive: true });
535
+ writeFileSync(abs, content);
536
+ }),
537
+ appendFile: deps.appendFile ??
538
+ ((p, content) => {
539
+ const abs = expandHome(p);
540
+ mkdirSync(path.dirname(abs), { recursive: true });
541
+ appendFileSync(abs, content);
542
+ }),
543
+ mkdir: deps.mkdir ??
544
+ ((p) => mkdirSync(expandHome(p), { recursive: true })),
545
+ workdir,
546
+ gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
547
+ gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
548
+ getDiagnostics: deps.getDiagnostics ?? (() => ""),
549
+ fetchFn: deps.fetchFn ?? globalThis.fetch,
550
+ claudeFn: deps.claudeFn ?? defaultClaudeFn,
551
+ claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
552
+ providerDriverFn: deps.providerDriverFn ?? defaultProviderDriverFn,
553
+ mockConnectors: deps.mockConnectors ?? {},
554
+ recordFixturesDir: deps.recordFixturesDir,
555
+ getGmailToken: deps.getGmailToken ??
556
+ (async () => {
557
+ const { getValidAccessToken } = await import("../connectors/gmail.js");
558
+ return getValidAccessToken();
559
+ }),
560
+ logDir: deps.logDir,
561
+ testMode: deps.testMode ?? false,
331
562
  };
332
563
  }
333
564
  function defaultClaudeCodeFn(prompt) {
@@ -432,372 +663,108 @@ async function defaultClaudeFn(prompt, model) {
432
663
  return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
433
664
  }
434
665
  }
435
- async function executeStep(step, ctx, deps) {
436
- switch (step.tool) {
437
- case "file.read": {
438
- const p = render(step.path, ctx);
439
- try {
440
- return deps.readFile(p);
441
- }
442
- catch {
443
- if (step.optional)
444
- return "";
445
- throw new Error(`file.read: could not read ${p}`);
446
- }
447
- }
448
- case "file.write": {
449
- const p = render(step.path, ctx);
450
- const content = render(step.content, ctx);
451
- deps.writeFile(p, content);
452
- return content;
453
- }
454
- case "file.append": {
455
- const p = render(step.path, ctx);
456
- const content = render(step.content, ctx);
457
- const when = step.when;
458
- if (when && !evalWhen(when, ctx))
459
- return null;
460
- deps.appendFile(p, content);
461
- return content;
462
- }
463
- case "git.log_since": {
464
- const since = render(String(step.since ?? "24h"), ctx);
465
- return deps.gitLogSince(since, deps.workdir);
466
- }
467
- case "git.stale_branches": {
468
- const days = typeof step.days === "number" ? step.days : 30;
469
- return deps.gitStaleBranches(days, deps.workdir);
470
- }
471
- case "diagnostics.get": {
472
- const uri = render(String(step.uri ?? ""), ctx);
473
- return deps.getDiagnostics(uri);
474
- }
475
- case "gmail.fetch_unread": {
476
- const since = render(String(step.since ?? "24h"), ctx);
477
- const MAX_GMAIL_RESULTS = 50;
478
- const max = Math.min(typeof step.max === "number" ? step.max : 20, MAX_GMAIL_RESULTS);
479
- const query = `is:unread newer_than:${sinceToGmailQuery(since)}`;
480
- return gmailSearch(query, max, deps);
481
- }
482
- case "gmail.search": {
483
- const query = render(String(step.query ?? ""), ctx);
484
- const MAX_GMAIL_RESULTS = 50;
485
- const max = Math.min(typeof step.max === "number" ? step.max : 10, MAX_GMAIL_RESULTS);
486
- return gmailSearch(query, max, deps);
487
- }
488
- case "gmail.fetch_thread": {
489
- const id = render(String(step.id ?? ""), ctx);
490
- return gmailFetchThread(id, deps);
666
+ /**
667
+ * Build ExecutionDeps for ChainedRecipeRunner backed by the yamlRunner step
668
+ * handlers. This lets chained recipes use the same tool set (file.*, git.*,
669
+ * gmail.*, github.*, linear.*, diagnostics.*) as simple YAML recipes.
670
+ *
671
+ * Pass the result as `chainedDeps` when calling `dispatchRecipe` or
672
+ * `runChainedRecipe` so that `executeTool` is properly wired.
673
+ */
674
+ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
675
+ const stepDeps = resolveStepDeps(runnerDeps);
676
+ const executeTool = async (tool, params) => {
677
+ // Construct a YamlStep-compatible object so we can reuse executeStep.
678
+ const step = { tool, ...params };
679
+ // executeStep uses a RunContext for {{}} rendering — by the time executeTool
680
+ // is called the chained runner has already resolved templates, so we pass
681
+ // an empty context (no double-rendering).
682
+ const result = await executeStep(step, {}, stepDeps);
683
+ return result ?? "";
684
+ };
685
+ const executeAgent = async (prompt, model, driver) => {
686
+ const claudeCodeFn = claudeCodeFnOverride ?? stepDeps.claudeCodeFn;
687
+ if (driver === "claude-code") {
688
+ return claudeCodeFn(prompt);
491
689
  }
492
- case "github.list_issues": {
493
- const { listIssues } = await import("../connectors/github.js");
494
- const repo = step.repo ? render(String(step.repo), ctx) : undefined;
495
- const assignee = step.assignee
496
- ? render(String(step.assignee), ctx)
497
- : "@me";
498
- const limit = typeof step.max === "number" ? step.max : 20;
499
- const issues = await listIssues({ repo, assignee, limit });
500
- return JSON.stringify({ count: issues.length, issues });
690
+ if (driver === "claude" || driver === "anthropic") {
691
+ return stepDeps.claudeFn(prompt, model ?? "claude-haiku-4-5-20251001");
501
692
  }
502
- case "github.list_prs": {
503
- const { listPRs } = await import("../connectors/github.js");
504
- const repo = step.repo ? render(String(step.repo), ctx) : undefined;
505
- const author = step.author ? render(String(step.author), ctx) : "@me";
506
- const limit = typeof step.max === "number" ? step.max : 20;
507
- const prs = await listPRs({ repo, author, limit });
508
- return JSON.stringify({ count: prs.length, prs });
693
+ if (driver === "openai" || driver === "grok" || driver === "gemini") {
694
+ return stepDeps.providerDriverFn(driver, prompt, model);
509
695
  }
510
- case "linear.list_issues": {
511
- const { loadTokens, listIssues: listLinearIssues } = await import("../connectors/linear.js");
512
- if (!loadTokens()) {
513
- return JSON.stringify({
514
- count: 0,
515
- issues: [],
516
- error: "Linear not connected",
517
- });
518
- }
519
- const teamKey = step.team ? render(String(step.team), ctx) : undefined;
520
- const assigneeMe = step.assignee === "@me" || step.assignee === undefined;
521
- const stateFilter = step.state
522
- ? render(String(step.state), ctx)
523
- : "started,unstarted";
524
- const limit = typeof step.max === "number" ? step.max : 20;
525
- const states = stateFilter
526
- .split(",")
527
- .map((s) => s.trim())
528
- .filter(Boolean);
529
- try {
530
- const issues = await listLinearIssues({
531
- team: teamKey,
532
- assigneeMe,
533
- states,
534
- limit,
535
- });
536
- return JSON.stringify({ count: issues.length, issues });
537
- }
538
- catch (err) {
539
- return JSON.stringify({
540
- count: 0,
541
- issues: [],
542
- error: err instanceof Error ? err.message : String(err),
543
- });
544
- }
545
- }
546
- case "calendar.list_events": {
547
- const { listEvents } = await import("../connectors/googleCalendar.js");
548
- const daysAhead = typeof step.days_ahead === "number" ? step.days_ahead : 7;
549
- const maxResults = typeof step.max === "number" ? step.max : 20;
550
- const calendarId = step.calendar_id
551
- ? render(String(step.calendar_id), ctx)
552
- : undefined;
553
- try {
554
- const events = await listEvents({ daysAhead, maxResults, calendarId });
555
- return JSON.stringify({ count: events.length, events });
556
- }
557
- catch (err) {
558
- return JSON.stringify({
559
- count: 0,
560
- events: [],
561
- error: err instanceof Error ? err.message : String(err),
562
- });
696
+ // No driver specified — mirror runYamlRecipe fallback logic:
697
+ // prefer API if key is set, otherwise probe for claude CLI.
698
+ const usingDefaultClaudeFn = runnerDeps.claudeFn === undefined;
699
+ if (!process.env.ANTHROPIC_API_KEY && usingDefaultClaudeFn) {
700
+ const probe = spawnSync("claude", ["--version"], {
701
+ encoding: "utf-8",
702
+ timeout: 5000,
703
+ });
704
+ if (!probe.error) {
705
+ return claudeCodeFn(prompt);
563
706
  }
564
707
  }
565
- case "slack.post_message": {
566
- const { postMessage, loadTokens: loadSlackTokens } = await import("../connectors/slack.js");
567
- if (!loadSlackTokens()) {
568
- return JSON.stringify({ ok: false, error: "Slack not connected" });
569
- }
570
- const channel = step.channel
571
- ? render(String(step.channel), ctx)
572
- : "general";
573
- const text = step.text ? render(String(step.text), ctx) : "";
574
- const threadTs = step.thread_ts
575
- ? render(String(step.thread_ts), ctx)
576
- : undefined;
708
+ return stepDeps.claudeFn(prompt, model ?? "claude-haiku-4-5-20251001");
709
+ };
710
+ const loadNestedRecipe = async (name) => {
711
+ const { homedir } = await import("node:os");
712
+ const recipesDir = path.join(homedir(), ".patchwork", "recipes");
713
+ const candidates = [
714
+ path.join(recipesDir, `${name}.yaml`),
715
+ path.join(recipesDir, `${name}.yml`),
716
+ ];
717
+ for (const p of candidates) {
577
718
  try {
578
- const result = await postMessage(channel, text, threadTs ?? undefined);
579
- return JSON.stringify({
580
- ok: true,
581
- ts: result.ts,
582
- channel: result.channel,
583
- });
719
+ const raw = stepDeps.readFile(p);
720
+ const { parse } = await import("yaml");
721
+ const parsed = parse(raw);
722
+ if (parsed?.steps)
723
+ return parsed;
584
724
  }
585
- catch (err) {
586
- return JSON.stringify({
587
- ok: false,
588
- error: err instanceof Error ? err.message : String(err),
589
- });
725
+ catch {
726
+ // try next candidate
590
727
  }
591
728
  }
592
- default:
593
- // Unknown tool — skip, don't throw (forward compat)
594
- return null;
595
- }
596
- }
597
- /** Minimal `{{ expr }}` renderer — replaces against flat context map. */
598
- export function render(template, ctx) {
599
- return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
600
- const key = expr.trim();
601
- return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
602
- });
729
+ return null;
730
+ };
731
+ return { executeTool, executeAgent, loadNestedRecipe };
603
732
  }
604
733
  /**
605
- * Evaluate simple `N > 0 || M > 0` guards after template rendering.
606
- * Supports: numeric literals, >, <, >=, <=, ==, !=, ||, &&, !.
607
- * Returns true (run step) for anything it can't parse.
734
+ * Dispatch a loaded recipe to the appropriate runner.
735
+ *
736
+ * Recipes with `trigger.type: "chained"` are routed to the ChainedRecipeRunner
737
+ * (parallel execution, template variables, nested recipes, dry-run).
738
+ * All other recipes use the existing synchronous yamlRunner path.
739
+ *
740
+ * `chainedDeps` is only required when the recipe is chained; omit for simple recipes.
608
741
  */
609
- function evalWhen(when, ctx) {
610
- try {
611
- const expanded = render(when, ctx).trim();
612
- // Only handle the `N op M` and `expr || expr` / `expr && expr` patterns.
613
- const orParts = expanded.split("||");
614
- if (orParts.length > 1) {
615
- return orParts.some((p) => evalWhen(p.trim(), {}));
616
- }
617
- const andParts = expanded.split("&&");
618
- if (andParts.length > 1) {
619
- return andParts.every((p) => evalWhen(p.trim(), {}));
620
- }
621
- const m = /^(-?[\d.]+)\s*(>|<|>=|<=|==|!=)\s*(-?[\d.]+)$/.exec(expanded);
622
- if (!m)
623
- return true;
624
- const [, lhs, op, rhs] = m;
625
- const l = Number(lhs);
626
- const r = Number(rhs);
627
- switch (op) {
628
- case ">":
629
- return l > r;
630
- case "<":
631
- return l < r;
632
- case ">=":
633
- return l >= r;
634
- case "<=":
635
- return l <= r;
636
- case "==":
637
- return l === r;
638
- case "!=":
639
- return l !== r;
640
- default:
641
- return true;
742
+ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
743
+ const triggerType = recipe.trigger
744
+ ?.type;
745
+ if (triggerType === "chained") {
746
+ const { runChainedRecipe } = await import("./chainedRunner.js");
747
+ const chainedRecipe = recipe;
748
+ const now = deps.now ? deps.now() : new Date();
749
+ const options = {
750
+ env: {
751
+ ...process.env,
752
+ DATE: now.toISOString().slice(0, 10),
753
+ TIME: now.toTimeString().slice(0, 5),
754
+ ...seedContext,
755
+ },
756
+ maxConcurrency: chainedRecipe.maxConcurrency ?? 4,
757
+ maxDepth: chainedRecipe.maxDepth ?? 3,
758
+ dryRun: deps.chainedOptions?.dryRun ?? false,
759
+ onStepStart: deps.chainedOptions?.onStepStart,
760
+ onStepComplete: deps.chainedOptions?.onStepComplete,
761
+ };
762
+ if (!deps.chainedDeps) {
763
+ throw new Error("chainedDeps required for chained recipes (provide executeTool, executeAgent, loadNestedRecipe)");
642
764
  }
765
+ return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
643
766
  }
644
- catch {
645
- return true;
646
- }
647
- }
648
- function cleanSnippet(raw) {
649
- return raw
650
- .replace(/­|​|‌|‍|‎|‏|‪|‫|‬|‭|‮|⁠||͏/g, "")
651
- .replace(/(\s)\s+/g, "$1")
652
- .trim()
653
- .slice(0, 200);
654
- }
655
- function sinceToGmailQuery(since) {
656
- // "24h" → "1d", "7d" → "7d", "1h" → "1d" (round up)
657
- const m = /^(\d+)(h|d)$/.exec(since.trim().toLowerCase());
658
- if (!m)
659
- return "1d";
660
- const [, num, unit] = m;
661
- if (unit === "d")
662
- return `${num}d`;
663
- // hours → round up to days (min 1d)
664
- const days = Math.max(1, Math.ceil(Number(num) / 24));
665
- return `${days}d`;
666
- }
667
- function getHeader(headers, name) {
668
- return (headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ??
669
- "");
670
- }
671
- async function gmailSearch(query, max, deps) {
672
- const errorResult = (msg) => JSON.stringify({ count: 0, messages: [], error: msg });
673
- let token;
674
- try {
675
- token = await deps.getGmailToken();
676
- }
677
- catch {
678
- return errorResult("Gmail not connected");
679
- }
680
- try {
681
- const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${max}`;
682
- const listRes = await deps.fetchFn(listUrl, {
683
- headers: { Authorization: `Bearer ${token}` },
684
- });
685
- if (!listRes.ok)
686
- return errorResult("Gmail API error");
687
- const listJson = (await listRes.json());
688
- const ids = listJson.messages ?? [];
689
- const messages = await Promise.all(ids.slice(0, max).map(async (m) => {
690
- const detailUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=Subject,From,Date`;
691
- const detailRes = await deps.fetchFn(detailUrl, {
692
- headers: { Authorization: `Bearer ${token}` },
693
- });
694
- if (!detailRes.ok)
695
- return { id: m.id, subject: "", from: "", date: "", snippet: "" };
696
- const detail = (await detailRes.json());
697
- const hdrs = detail.payload?.headers ?? [];
698
- return {
699
- id: detail.id,
700
- subject: getHeader(hdrs, "Subject"),
701
- from: getHeader(hdrs, "From"),
702
- date: getHeader(hdrs, "Date"),
703
- snippet: cleanSnippet(detail.snippet ?? ""),
704
- };
705
- }));
706
- const result = { count: messages.length, messages };
707
- return JSON.stringify(result);
708
- }
709
- catch {
710
- return errorResult("Gmail fetch failed");
711
- }
712
- }
713
- async function gmailFetchThread(id, deps) {
714
- const errorResult = (msg) => JSON.stringify({ subject: "", messages: [], error: msg });
715
- let token;
716
- try {
717
- token = await deps.getGmailToken();
718
- }
719
- catch {
720
- return errorResult("Gmail not connected");
721
- }
722
- try {
723
- const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${id}?format=metadata&metadataHeaders=Subject,From,Date`;
724
- const res = await deps.fetchFn(url, {
725
- headers: { Authorization: `Bearer ${token}` },
726
- });
727
- if (!res.ok)
728
- return errorResult("Gmail API error");
729
- const thread = (await res.json());
730
- const msgs = thread.messages ?? [];
731
- const firstHdrs = msgs[0]?.payload?.headers ?? [];
732
- const subject = getHeader(firstHdrs, "Subject");
733
- const messages = msgs.map((m) => {
734
- const hdrs = m.payload?.headers ?? [];
735
- return {
736
- from: getHeader(hdrs, "From"),
737
- date: getHeader(hdrs, "Date"),
738
- body_snippet: m.snippet ?? "",
739
- };
740
- });
741
- const result = { subject, messages };
742
- return JSON.stringify(result);
743
- }
744
- catch {
745
- return errorResult("Gmail fetch failed");
746
- }
747
- }
748
- function expandHome(p) {
749
- if (p.startsWith("~/"))
750
- return path.join(os.homedir(), p.slice(2));
751
- return p;
752
- }
753
- function parseSinceToGitArg(since) {
754
- const m = /^(\d+)(h|d)$/i.exec(since.trim());
755
- if (!m)
756
- return since;
757
- const [, num, unit = "h"] = m;
758
- return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
759
- }
760
- function defaultGitLogSince(since, workdir) {
761
- try {
762
- const sinceArg = parseSinceToGitArg(since);
763
- const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
764
- cwd: workdir ?? process.cwd(),
765
- encoding: "utf-8",
766
- timeout: 5000,
767
- });
768
- if (result.error || result.status !== 0)
769
- return "(git log unavailable)";
770
- return (result.stdout ?? "").trim();
771
- }
772
- catch {
773
- return "(git log unavailable)";
774
- }
775
- }
776
- function defaultGitStaleBranches(days, workdir) {
777
- try {
778
- const cutoff = new Date(Date.now() - days * 86_400_000)
779
- .toISOString()
780
- .slice(0, 10);
781
- const r = spawnSync("git", ["branch", "--format=%(refname:short) %(committerdate:short)"], {
782
- cwd: workdir ?? process.cwd(),
783
- encoding: "utf-8",
784
- timeout: 5000,
785
- });
786
- const branches = r.error || r.status !== 0 ? "" : (r.stdout ?? "").trim();
787
- if (!branches)
788
- return "(no local branches)";
789
- return (branches
790
- .split("\n")
791
- .filter((line) => {
792
- const parts = line.trim().split(/\s+/);
793
- const dateStr = parts[1];
794
- return dateStr && dateStr < cutoff;
795
- })
796
- .join("\n") || "(none older than 30 days)");
797
- }
798
- catch {
799
- return "(git unavailable)";
800
- }
767
+ return runYamlRecipe(recipe, deps, seedContext);
801
768
  }
802
769
  /** List all YAML recipes in a directory. Returns names. */
803
770
  export function listYamlRecipes(recipesDir) {