patchwork-os 0.2.0-alpha.34 → 0.2.0-alpha.36

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 (270) hide show
  1. package/README.md +202 -93
  2. package/deploy/bootstrap-new-vps.sh +12 -12
  3. package/deploy/bootstrap-vps.sh +6 -3
  4. package/deploy/deploy-landing.sh +59 -2
  5. package/dist/activityLog.d.ts +49 -0
  6. package/dist/activityLog.js +78 -0
  7. package/dist/activityLog.js.map +1 -1
  8. package/dist/approvalHttp.d.ts +25 -0
  9. package/dist/approvalHttp.js +74 -18
  10. package/dist/approvalHttp.js.map +1 -1
  11. package/dist/approvalInsights.d.ts +49 -0
  12. package/dist/approvalInsights.js +97 -0
  13. package/dist/approvalInsights.js.map +1 -0
  14. package/dist/approvalQueue.d.ts +11 -0
  15. package/dist/approvalQueue.js +80 -1
  16. package/dist/approvalQueue.js.map +1 -1
  17. package/dist/approvalSignals.d.ts +124 -0
  18. package/dist/approvalSignals.js +512 -0
  19. package/dist/approvalSignals.js.map +1 -0
  20. package/dist/automation.d.ts +37 -0
  21. package/dist/automation.js +105 -61
  22. package/dist/automation.js.map +1 -1
  23. package/dist/automationSuggestions.d.ts +79 -0
  24. package/dist/automationSuggestions.js +150 -0
  25. package/dist/automationSuggestions.js.map +1 -0
  26. package/dist/bridge.js +78 -1
  27. package/dist/bridge.js.map +1 -1
  28. package/dist/ccPermissions.d.ts +15 -0
  29. package/dist/ccPermissions.js +15 -0
  30. package/dist/ccPermissions.js.map +1 -1
  31. package/dist/claudeDriver.js +74 -16
  32. package/dist/claudeDriver.js.map +1 -1
  33. package/dist/commands/patchworkInit.d.ts +8 -0
  34. package/dist/commands/patchworkInit.js +41 -5
  35. package/dist/commands/patchworkInit.js.map +1 -1
  36. package/dist/commands/recipe.d.ts +20 -0
  37. package/dist/commands/recipe.js +212 -6
  38. package/dist/commands/recipe.js.map +1 -1
  39. package/dist/commands/recipeInstall.d.ts +79 -1
  40. package/dist/commands/recipeInstall.js +333 -16
  41. package/dist/commands/recipeInstall.js.map +1 -1
  42. package/dist/commands/tracesExport.d.ts +83 -0
  43. package/dist/commands/tracesExport.js +269 -0
  44. package/dist/commands/tracesExport.js.map +1 -0
  45. package/dist/commands/tracesImport.d.ts +56 -0
  46. package/dist/commands/tracesImport.js +161 -0
  47. package/dist/commands/tracesImport.js.map +1 -0
  48. package/dist/config.d.ts +8 -0
  49. package/dist/config.js +9 -1
  50. package/dist/config.js.map +1 -1
  51. package/dist/connectorRoutes.d.ts +43 -0
  52. package/dist/connectorRoutes.js +1023 -0
  53. package/dist/connectorRoutes.js.map +1 -0
  54. package/dist/connectors/asana.d.ts +198 -0
  55. package/dist/connectors/asana.js +679 -0
  56. package/dist/connectors/asana.js.map +1 -0
  57. package/dist/connectors/baseConnector.d.ts +36 -0
  58. package/dist/connectors/baseConnector.js +151 -28
  59. package/dist/connectors/baseConnector.js.map +1 -1
  60. package/dist/connectors/discord.d.ts +150 -0
  61. package/dist/connectors/discord.js +543 -0
  62. package/dist/connectors/discord.js.map +1 -0
  63. package/dist/connectors/github.js +11 -4
  64. package/dist/connectors/github.js.map +1 -1
  65. package/dist/connectors/gitlab.d.ts +180 -0
  66. package/dist/connectors/gitlab.js +582 -0
  67. package/dist/connectors/gitlab.js.map +1 -0
  68. package/dist/connectors/gmail.js +50 -10
  69. package/dist/connectors/gmail.js.map +1 -1
  70. package/dist/connectors/googleCalendar.js +36 -10
  71. package/dist/connectors/googleCalendar.js.map +1 -1
  72. package/dist/connectors/googleDrive.d.ts +34 -0
  73. package/dist/connectors/googleDrive.js +321 -0
  74. package/dist/connectors/googleDrive.js.map +1 -0
  75. package/dist/connectors/linear.js +23 -4
  76. package/dist/connectors/linear.js.map +1 -1
  77. package/dist/connectors/mcpOAuth.js +26 -2
  78. package/dist/connectors/mcpOAuth.js.map +1 -1
  79. package/dist/connectors/oauthStateStore.d.ts +31 -0
  80. package/dist/connectors/oauthStateStore.js +52 -0
  81. package/dist/connectors/oauthStateStore.js.map +1 -0
  82. package/dist/connectors/pagerduty.d.ts +160 -0
  83. package/dist/connectors/pagerduty.js +464 -0
  84. package/dist/connectors/pagerduty.js.map +1 -0
  85. package/dist/connectors/slack.d.ts +16 -1
  86. package/dist/connectors/slack.js +57 -5
  87. package/dist/connectors/slack.js.map +1 -1
  88. package/dist/connectors/tokenStorage.js +27 -2
  89. package/dist/connectors/tokenStorage.js.map +1 -1
  90. package/dist/connectors/zendesk.js +19 -1
  91. package/dist/connectors/zendesk.js.map +1 -1
  92. package/dist/cors.d.ts +10 -0
  93. package/dist/cors.js +29 -0
  94. package/dist/cors.js.map +1 -0
  95. package/dist/decisionReplay.d.ts +72 -0
  96. package/dist/decisionReplay.js +92 -0
  97. package/dist/decisionReplay.js.map +1 -0
  98. package/dist/decisionTraceLog.d.ts +6 -0
  99. package/dist/decisionTraceLog.js +54 -2
  100. package/dist/decisionTraceLog.js.map +1 -1
  101. package/dist/featureFlags.d.ts +17 -11
  102. package/dist/featureFlags.js +52 -47
  103. package/dist/featureFlags.js.map +1 -1
  104. package/dist/fp/automationInterpreter.js +25 -21
  105. package/dist/fp/automationInterpreter.js.map +1 -1
  106. package/dist/fp/automationState.js +4 -1
  107. package/dist/fp/automationState.js.map +1 -1
  108. package/dist/fp/policyParser.js +4 -1
  109. package/dist/fp/policyParser.js.map +1 -1
  110. package/dist/inboxRoutes.d.ts +22 -0
  111. package/dist/inboxRoutes.js +114 -0
  112. package/dist/inboxRoutes.js.map +1 -0
  113. package/dist/index.js +734 -144
  114. package/dist/index.js.map +1 -1
  115. package/dist/mcpRoutes.d.ts +37 -0
  116. package/dist/mcpRoutes.js +76 -0
  117. package/dist/mcpRoutes.js.map +1 -0
  118. package/dist/oauth.d.ts +3 -0
  119. package/dist/oauth.js +151 -26
  120. package/dist/oauth.js.map +1 -1
  121. package/dist/oauthRoutes.d.ts +32 -0
  122. package/dist/oauthRoutes.js +124 -0
  123. package/dist/oauthRoutes.js.map +1 -0
  124. package/dist/orchestrator/orchestratorBridge.js +2 -2
  125. package/dist/orchestrator/orchestratorBridge.js.map +1 -1
  126. package/dist/patchworkConfig.d.ts +7 -0
  127. package/dist/patchworkConfig.js.map +1 -1
  128. package/dist/pluginLoader.d.ts +12 -0
  129. package/dist/pluginLoader.js +43 -4
  130. package/dist/pluginLoader.js.map +1 -1
  131. package/dist/pluginWatcher.js +8 -3
  132. package/dist/pluginWatcher.js.map +1 -1
  133. package/dist/preToolUseHook.d.ts +12 -0
  134. package/dist/preToolUseHook.js +23 -0
  135. package/dist/preToolUseHook.js.map +1 -1
  136. package/dist/recipeOrchestration.d.ts +8 -0
  137. package/dist/recipeOrchestration.js +320 -39
  138. package/dist/recipeOrchestration.js.map +1 -1
  139. package/dist/recipeRoutes.d.ts +154 -0
  140. package/dist/recipeRoutes.js +1098 -0
  141. package/dist/recipeRoutes.js.map +1 -0
  142. package/dist/recipes/captureForRunlog.d.ts +27 -0
  143. package/dist/recipes/captureForRunlog.js +128 -0
  144. package/dist/recipes/captureForRunlog.js.map +1 -0
  145. package/dist/recipes/chainedRunner.d.ts +54 -3
  146. package/dist/recipes/chainedRunner.js +256 -36
  147. package/dist/recipes/chainedRunner.js.map +1 -1
  148. package/dist/recipes/compiler.js +3 -3
  149. package/dist/recipes/compiler.js.map +1 -1
  150. package/dist/recipes/detectSilentFail.d.ts +34 -0
  151. package/dist/recipes/detectSilentFail.js +105 -0
  152. package/dist/recipes/detectSilentFail.js.map +1 -0
  153. package/dist/recipes/installer.js +3 -3
  154. package/dist/recipes/installer.js.map +1 -1
  155. package/dist/recipes/manifest.js +21 -6
  156. package/dist/recipes/manifest.js.map +1 -1
  157. package/dist/recipes/migrationWarnings.d.ts +12 -0
  158. package/dist/recipes/migrationWarnings.js +44 -0
  159. package/dist/recipes/migrationWarnings.js.map +1 -0
  160. package/dist/recipes/replayRun.d.ts +62 -0
  161. package/dist/recipes/replayRun.js +97 -0
  162. package/dist/recipes/replayRun.js.map +1 -0
  163. package/dist/recipes/resolveRecipePath.d.ts +69 -0
  164. package/dist/recipes/resolveRecipePath.js +202 -0
  165. package/dist/recipes/resolveRecipePath.js.map +1 -0
  166. package/dist/recipes/scheduler.js +102 -11
  167. package/dist/recipes/scheduler.js.map +1 -1
  168. package/dist/recipes/schemaGenerator.js +3 -3
  169. package/dist/recipes/schemaGenerator.js.map +1 -1
  170. package/dist/recipes/toolRegistry.d.ts +5 -0
  171. package/dist/recipes/toolRegistry.js +9 -0
  172. package/dist/recipes/toolRegistry.js.map +1 -1
  173. package/dist/recipes/tools/asana.d.ts +16 -0
  174. package/dist/recipes/tools/asana.js +524 -0
  175. package/dist/recipes/tools/asana.js.map +1 -0
  176. package/dist/recipes/tools/discord.d.ts +18 -0
  177. package/dist/recipes/tools/discord.js +254 -0
  178. package/dist/recipes/tools/discord.js.map +1 -0
  179. package/dist/recipes/tools/file.d.ts +6 -0
  180. package/dist/recipes/tools/file.js +12 -8
  181. package/dist/recipes/tools/file.js.map +1 -1
  182. package/dist/recipes/tools/github.js +29 -4
  183. package/dist/recipes/tools/github.js.map +1 -1
  184. package/dist/recipes/tools/gitlab.d.ts +11 -0
  185. package/dist/recipes/tools/gitlab.js +285 -0
  186. package/dist/recipes/tools/gitlab.js.map +1 -0
  187. package/dist/recipes/tools/gmail.d.ts +1 -1
  188. package/dist/recipes/tools/gmail.js +230 -6
  189. package/dist/recipes/tools/gmail.js.map +1 -1
  190. package/dist/recipes/tools/googleDrive.d.ts +1 -0
  191. package/dist/recipes/tools/googleDrive.js +55 -0
  192. package/dist/recipes/tools/googleDrive.js.map +1 -0
  193. package/dist/recipes/tools/index.d.ts +8 -0
  194. package/dist/recipes/tools/index.js +8 -0
  195. package/dist/recipes/tools/index.js.map +1 -1
  196. package/dist/recipes/tools/jira.d.ts +14 -0
  197. package/dist/recipes/tools/jira.js +369 -0
  198. package/dist/recipes/tools/jira.js.map +1 -0
  199. package/dist/recipes/tools/linear.d.ts +2 -1
  200. package/dist/recipes/tools/linear.js +227 -3
  201. package/dist/recipes/tools/linear.js.map +1 -1
  202. package/dist/recipes/tools/meetingNotes.d.ts +21 -0
  203. package/dist/recipes/tools/meetingNotes.js +701 -0
  204. package/dist/recipes/tools/meetingNotes.js.map +1 -0
  205. package/dist/recipes/tools/pagerduty.d.ts +15 -0
  206. package/dist/recipes/tools/pagerduty.js +451 -0
  207. package/dist/recipes/tools/pagerduty.js.map +1 -0
  208. package/dist/recipes/tools/sentry.d.ts +12 -0
  209. package/dist/recipes/tools/sentry.js +73 -0
  210. package/dist/recipes/tools/sentry.js.map +1 -0
  211. package/dist/recipes/tools/slack.js +15 -5
  212. package/dist/recipes/tools/slack.js.map +1 -1
  213. package/dist/recipes/validation.js +83 -14
  214. package/dist/recipes/validation.js.map +1 -1
  215. package/dist/recipes/yamlRunner.d.ts +30 -2
  216. package/dist/recipes/yamlRunner.js +369 -70
  217. package/dist/recipes/yamlRunner.js.map +1 -1
  218. package/dist/recipesHttp.d.ts +76 -1
  219. package/dist/recipesHttp.js +474 -12
  220. package/dist/recipesHttp.js.map +1 -1
  221. package/dist/runLog.d.ts +78 -2
  222. package/dist/runLog.js +204 -6
  223. package/dist/runLog.js.map +1 -1
  224. package/dist/schemas/dry-run-plan.v1.json +139 -0
  225. package/dist/schemas/recipe.v1.json +684 -0
  226. package/dist/server.d.ts +79 -10
  227. package/dist/server.js +366 -1384
  228. package/dist/server.js.map +1 -1
  229. package/dist/ssrfGuard.d.ts +54 -0
  230. package/dist/ssrfGuard.js +122 -0
  231. package/dist/ssrfGuard.js.map +1 -0
  232. package/dist/streamableHttp.d.ts +39 -1
  233. package/dist/streamableHttp.js +126 -17
  234. package/dist/streamableHttp.js.map +1 -1
  235. package/dist/tools/getDocumentSymbols.d.ts +24 -0
  236. package/dist/tools/getDocumentSymbols.js +74 -8
  237. package/dist/tools/getDocumentSymbols.js.map +1 -1
  238. package/dist/tools/getSecurityAdvisories.js +10 -1
  239. package/dist/tools/getSecurityAdvisories.js.map +1 -1
  240. package/dist/tools/getSessionUsage.d.ts +3 -0
  241. package/dist/tools/getSessionUsage.js +3 -0
  242. package/dist/tools/getSessionUsage.js.map +1 -1
  243. package/dist/tools/index.d.ts +8 -0
  244. package/dist/tools/index.js +32 -2
  245. package/dist/tools/index.js.map +1 -1
  246. package/dist/tools/slackPostMessage.js +1 -1
  247. package/dist/tools/slackPostMessage.js.map +1 -1
  248. package/dist/tools/transaction.d.ts +19 -0
  249. package/dist/tools/transaction.js +29 -0
  250. package/dist/tools/transaction.js.map +1 -1
  251. package/dist/traceEncryption.d.ts +46 -0
  252. package/dist/traceEncryption.js +124 -0
  253. package/dist/traceEncryption.js.map +1 -0
  254. package/dist/transport.d.ts +39 -0
  255. package/dist/transport.js +88 -8
  256. package/dist/transport.js.map +1 -1
  257. package/package.json +22 -5
  258. package/templates/policies/README.md +72 -0
  259. package/templates/policies/conservative.json +14 -0
  260. package/templates/policies/developer.json +14 -0
  261. package/templates/policies/headless-ci.json +24 -0
  262. package/templates/policies/personal-assistant.json +15 -0
  263. package/templates/policies/regulated-industry.json +18 -0
  264. package/templates/recipes/project-health-check.yaml +1 -1
  265. package/templates/recipes/webhook/README.md +70 -0
  266. package/templates/recipes/webhook/capture-thought.yaml +26 -0
  267. package/templates/recipes/webhook/customer-escalation.yaml +49 -0
  268. package/templates/recipes/webhook/incident-intake.yaml +46 -0
  269. package/templates/recipes/webhook/meeting-prep.yaml +48 -0
  270. package/templates/recipes/webhook/morning-brief.yaml +57 -0
@@ -24,14 +24,33 @@ import { spawnSync } from "node:child_process";
24
24
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
27
28
  import { parse as parseYaml } from "yaml";
28
29
  import { captureFixture } from "../connectors/fixtureRecorder.js";
29
30
  import { findYamlRecipePath } from "../recipesHttp.js";
30
31
  import { executeAgent as _executeAgent, } from "./agentExecutor.js";
32
+ import { detectSilentFail } from "./detectSilentFail.js";
31
33
  import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./legacyRecipeCompat.js";
34
+ import { resolveRecipePath } from "./resolveRecipePath.js";
32
35
  // Import tool registry and trigger tool self-registration
33
36
  import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
34
37
  import "./tools/index.js";
38
+ /**
39
+ * Bundled-templates directory used as a third allowed root for nested-recipe
40
+ * lookups (`recipe:` references with explicit paths). Resolved once at module
41
+ * load — `__dirname` equivalent points at `dist/recipes/` in the npm tarball
42
+ * (or `src/recipes/` in dev) so the relative `../../templates/recipes` lifts
43
+ * out of the source tree to the package root regardless of build layout.
44
+ *
45
+ * See dogfood A-PR2 / R2 M-5 — the third jail root is captured here, not at
46
+ * call time, so a runtime CWD change cannot relocate it.
47
+ */
48
+ const BUNDLED_TEMPLATES_DIR = (() => {
49
+ const here = path.dirname(fileURLToPath(import.meta.url));
50
+ // dist/recipes/yamlRunner.js → ../../templates/recipes
51
+ // src/recipes/yamlRunner.ts → ../../templates/recipes
52
+ return path.resolve(here, "..", "..", "templates", "recipes");
53
+ })();
35
54
  export function evaluateExpect(result, expect) {
36
55
  const failures = [];
37
56
  if (expect.stepsRun !== undefined && result.stepsRun !== expect.stepsRun) {
@@ -181,12 +200,52 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
181
200
  await loadRecipeServers(recipe.servers);
182
201
  }
183
202
  const now = deps.now ? deps.now() : new Date();
203
+ // Resolve recipe-level context blocks (type: env) into seed context
204
+ const envCtx = {};
205
+ if (Array.isArray(recipe.context)) {
206
+ for (const block of recipe
207
+ .context ?? []) {
208
+ const b = block;
209
+ if (b.type === "env" && Array.isArray(b.keys)) {
210
+ for (const key of b.keys) {
211
+ const v = process.env[key];
212
+ if (v !== undefined)
213
+ envCtx[key] = v;
214
+ }
215
+ }
216
+ }
217
+ }
184
218
  const ctx = {
185
219
  date: now.toISOString().slice(0, 10),
186
220
  time: now.toTimeString().slice(0, 5),
221
+ ...envCtx,
187
222
  ...seedContext,
188
223
  };
189
224
  const stepDeps = resolveStepDeps(deps);
225
+ // Open a `running`-state run-log entry so the dashboard sees the recipe
226
+ // as in flight. Only when a long-lived `runLog` is provided (bridge path);
227
+ // CLI runs fall back to `appendDirect` at end via the existing logDir
228
+ // path. Skip in test mode.
229
+ const recipeStartedAt = now.getTime();
230
+ const recipeTriggerKind = recipe.trigger?.type ?? "manual";
231
+ const yamlTriggerKind = (["cron", "webhook", "recipe"].includes(recipeTriggerKind)
232
+ ? recipeTriggerKind
233
+ : "recipe");
234
+ let runSeq;
235
+ if (deps.runLog && !stepDeps.testMode) {
236
+ try {
237
+ runSeq = deps.runLog.startRun({
238
+ taskId: `yaml:${recipe.name}:${recipeStartedAt}`,
239
+ recipeName: recipe.name,
240
+ trigger: yamlTriggerKind,
241
+ createdAt: recipeStartedAt,
242
+ startedAt: recipeStartedAt,
243
+ });
244
+ }
245
+ catch {
246
+ // Non-fatal — run-log failures must never break recipe execution.
247
+ }
248
+ }
190
249
  const outputs = [];
191
250
  const stepResults = [];
192
251
  let stepsRun = 0;
@@ -206,13 +265,22 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
206
265
  driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
207
266
  model: agentCfg.model,
208
267
  }, buildAgentExecutorDeps(stepDeps, deps));
209
- if (agentResult.startsWith("[agent step failed:")) {
210
- runError = runError ?? agentResult;
268
+ // Catch both `[agent step failed: ...]` (existing) and the
269
+ // silent-fail patterns `[agent step skipped: ...]` etc. via the
270
+ // shared detector. Per-step opt-out via `silentFailDetection: false`.
271
+ const agentSilentFail = step.silentFailDetection !== false
272
+ ? detectSilentFail(agentResult)
273
+ : null;
274
+ if (agentResult.startsWith("[agent step failed:") || agentSilentFail) {
275
+ const reason = agentSilentFail
276
+ ? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
277
+ : agentResult;
278
+ runError = runError ?? reason;
211
279
  stepResults.push({
212
280
  id: stepId,
213
281
  tool: "agent",
214
282
  status: "error",
215
- error: agentResult,
283
+ error: reason,
216
284
  durationMs: Date.now() - stepStart,
217
285
  });
218
286
  }
@@ -230,7 +298,15 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
230
298
  });
231
299
  }
232
300
  else {
233
- ctx[intoKey] = stripped;
301
+ // Try to parse as JSON so dot-notation ({{meeting.field}}) works
302
+ try {
303
+ const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
304
+ const parsed = JSON.parse((jsonMatch[1] ?? "").trim());
305
+ ctx[intoKey] = parsed;
306
+ }
307
+ catch {
308
+ ctx[intoKey] = stripped;
309
+ }
234
310
  outputs.push(intoKey);
235
311
  stepResults.push({
236
312
  id: stepId,
@@ -256,19 +332,21 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
256
332
  continue;
257
333
  }
258
334
  const stepStart = Date.now();
259
- const stepId = step.into ?? step.tool ?? `step_${stepsRun}`;
335
+ const stepId = step.into ?? `step_${stepsRun}`;
260
336
  // Resolve retry policy: step-level overrides recipe-level.
261
337
  const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
262
338
  const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
263
339
  let result = null;
264
340
  let stepError;
265
341
  let thrownError;
342
+ let thrownErrorCode;
266
343
  for (let attempt = 0; attempt <= retryCount; attempt++) {
267
344
  if (attempt > 0) {
268
345
  await new Promise((r) => setTimeout(r, retryDelayMs));
269
346
  }
270
347
  stepError = undefined;
271
348
  thrownError = undefined;
349
+ thrownErrorCode = undefined;
272
350
  try {
273
351
  result = await executeStep(step, ctx, stepDeps);
274
352
  // Detect tool-level errors reported as JSON {ok: false, error: ...}
@@ -283,9 +361,29 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
283
361
  /* non-JSON result is fine */
284
362
  }
285
363
  }
364
+ // Silent-fail detection: tools that return string placeholders
365
+ // (`(git branches unavailable)`, `[agent step skipped: ...]`)
366
+ // or empty list-tool error shapes (`{count:0,error:"..."}`)
367
+ // succeed with bad data — flag them as `error` so the runner
368
+ // doesn't quietly hand garbage to a downstream agent. Per-step
369
+ // opt-out via `silentFailDetection: false`.
370
+ if (!stepError &&
371
+ result !== null &&
372
+ step.silentFailDetection !== false) {
373
+ const detected = detectSilentFail(result);
374
+ if (detected) {
375
+ stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
376
+ }
377
+ }
286
378
  }
287
379
  catch (err) {
288
380
  thrownError = err instanceof Error ? err.message : String(err);
381
+ // Preserve structured error codes (e.g. recipe_path_jail_escape)
382
+ // so callers and tests can branch on `err.code` per R2 M-4
383
+ // without scraping the message string.
384
+ const code = err?.code;
385
+ if (typeof code === "string")
386
+ thrownErrorCode = code;
289
387
  result = null;
290
388
  }
291
389
  if (!stepError && !thrownError)
@@ -302,6 +400,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
302
400
  tool: step.tool,
303
401
  status: "error",
304
402
  error: thrownError,
403
+ ...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
305
404
  durationMs: Date.now() - stepStart,
306
405
  });
307
406
  if (!failOpen) {
@@ -347,7 +446,15 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
347
446
  }
348
447
  }
349
448
  if (step.tool === "file.write" || step.tool === "file.append") {
350
- outputs.push(render(step.path, ctx));
449
+ // R2 C-1 / F-02: re-validate the rendered path against the jail so a
450
+ // template substitution that survived earlier checks (e.g. via a
451
+ // chained sub-recipe deps override) cannot smuggle an out-of-jail
452
+ // path into the run log / dashboard outputs list.
453
+ const renderedPath = render(step.path, ctx);
454
+ outputs.push(resolveRecipePath(renderedPath, {
455
+ workspace: stepDeps.workdir,
456
+ write: true,
457
+ }));
351
458
  }
352
459
  }
353
460
  }
@@ -355,42 +462,54 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
355
462
  const assertionFailures = recipe.expect
356
463
  ? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
357
464
  : [];
358
- // Write to RecipeRunLog so the dashboard Runs page shows this execution
465
+ // Write to RecipeRunLog so the dashboard Runs page shows this execution.
466
+ // Bridge path: completeRun on the running entry opened above (live-tail).
467
+ // CLI path: construct a local log + appendDirect (no live-tail).
359
468
  if (!stepDeps.testMode) {
360
469
  try {
361
- const { RecipeRunLog } = await import("../runLog.js");
362
- const { homedir } = await import("node:os");
363
- const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
364
- const log = new RecipeRunLog({ dir: resolvedLogDir });
365
- const trigger = recipe.trigger?.type ?? "manual";
366
- const createdAt = now.getTime();
367
470
  const doneAt = Date.now();
368
471
  const outputTail = stepResults
369
472
  .map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
370
473
  .join("\n")
371
474
  .slice(0, 2000);
372
- log.appendDirect({
373
- taskId: `yaml:${recipe.name}:${createdAt}`,
374
- recipeName: recipe.name,
375
- trigger: (["cron", "webhook", "recipe"].includes(trigger)
376
- ? trigger
377
- : "recipe"),
378
- status: runError ? "error" : "done",
379
- createdAt,
380
- startedAt: createdAt,
381
- doneAt,
382
- durationMs: doneAt - createdAt,
383
- outputTail,
384
- errorMessage: runError,
385
- stepResults: stepResults.map((s) => ({
386
- id: s.id,
387
- tool: s.tool,
388
- status: s.status,
389
- error: s.error,
390
- durationMs: s.durationMs,
391
- })),
392
- ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
393
- });
475
+ const finalStepResults = stepResults.map((s) => ({
476
+ id: s.id,
477
+ tool: s.tool,
478
+ status: s.status,
479
+ error: s.error,
480
+ durationMs: s.durationMs,
481
+ }));
482
+ if (deps.runLog && runSeq !== undefined) {
483
+ deps.runLog.completeRun(runSeq, {
484
+ status: runError ? "error" : "done",
485
+ doneAt,
486
+ durationMs: doneAt - recipeStartedAt,
487
+ stepResults: finalStepResults,
488
+ outputTail,
489
+ ...(runError !== undefined && { errorMessage: runError }),
490
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
491
+ });
492
+ }
493
+ else {
494
+ const { RecipeRunLog } = await import("../runLog.js");
495
+ const { homedir } = await import("node:os");
496
+ const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
497
+ const log = new RecipeRunLog({ dir: resolvedLogDir });
498
+ log.appendDirect({
499
+ taskId: `yaml:${recipe.name}:${recipeStartedAt}`,
500
+ recipeName: recipe.name,
501
+ trigger: yamlTriggerKind,
502
+ status: runError ? "error" : "done",
503
+ createdAt: recipeStartedAt,
504
+ startedAt: recipeStartedAt,
505
+ doneAt,
506
+ durationMs: doneAt - recipeStartedAt,
507
+ outputTail,
508
+ errorMessage: runError,
509
+ stepResults: finalStepResults,
510
+ ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
511
+ });
512
+ }
394
513
  }
395
514
  catch {
396
515
  // Non-fatal — run log write failure should never break recipe execution
@@ -448,12 +567,7 @@ export async function executeStep(step, ctx, deps) {
448
567
  for (const [key, value] of Object.entries(step)) {
449
568
  if (key === "tool" || key === "agent" || key === "into")
450
569
  continue;
451
- if (typeof value === "string") {
452
- params[key] = render(value, ctx);
453
- }
454
- else {
455
- params[key] = value;
456
- }
570
+ params[key] = deepRender(value, ctx);
457
571
  }
458
572
  // Check if mock connector is available for this tool
459
573
  if (deps.mockConnectors?.[toolId]) {
@@ -471,17 +585,60 @@ export async function executeStep(step, ctx, deps) {
471
585
  // Unknown tool — skip, don't throw (forward compat)
472
586
  return null;
473
587
  }
474
- /** Minimal `{{ expr }}` renderer — replaces against flat context map. */
588
+ /** Minimal `{{ expr }}` renderer — flat keys and dot-notation paths. */
475
589
  export function render(template, ctx) {
476
590
  return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
477
591
  const key = expr.trim();
478
- return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
592
+ const coerce = (v) => {
593
+ if (v == null)
594
+ return "";
595
+ if (typeof v === "object")
596
+ return JSON.stringify(v);
597
+ return String(v);
598
+ };
599
+ // Fast path: flat key exists
600
+ if (Object.hasOwn(ctx, key))
601
+ return coerce(ctx[key]);
602
+ // Dot-notation: resolve nested path into ctx values (JSON-parse string intermediates)
603
+ const parts = key.split(".");
604
+ // biome-ignore lint/suspicious/noExplicitAny: resolved values are dynamic JSON shapes
605
+ let val = ctx;
606
+ for (const part of parts) {
607
+ if (val == null)
608
+ return "";
609
+ if (typeof val === "string") {
610
+ try {
611
+ val = JSON.parse(val);
612
+ }
613
+ catch {
614
+ return "";
615
+ }
616
+ }
617
+ if (typeof val !== "object")
618
+ return "";
619
+ val = val[part];
620
+ }
621
+ return val == null
622
+ ? ""
623
+ : typeof val === "object"
624
+ ? JSON.stringify(val)
625
+ : String(val);
479
626
  });
480
627
  }
481
- function expandHome(p) {
482
- if (p.startsWith("~/"))
483
- return path.join(os.homedir(), p.slice(2));
484
- return p;
628
+ /** Recursively render all string leaves in a value (for nested params like blocks). */
629
+ function deepRender(value, ctx) {
630
+ if (typeof value === "string")
631
+ return render(value, ctx);
632
+ if (Array.isArray(value))
633
+ return value.map((v) => deepRender(v, ctx));
634
+ if (value !== null && typeof value === "object") {
635
+ const out = {};
636
+ for (const [k, v] of Object.entries(value)) {
637
+ out[k] = deepRender(v, ctx);
638
+ }
639
+ return out;
640
+ }
641
+ return value;
485
642
  }
486
643
  function parseSinceToGitArg(since) {
487
644
  const m = /^(\d+)(h|d)$/i.exec(since.trim());
@@ -490,7 +647,19 @@ function parseSinceToGitArg(since) {
490
647
  const [, num, unit = "h"] = m;
491
648
  return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
492
649
  }
493
- function defaultGitLogSince(since, workdir) {
650
+ // Exported for test coverage of the regression fix (was returning the
651
+ // `(git log unavailable)` placeholder string on any failure, which
652
+ // silently looked like success to pre-#72 runners).
653
+ export function defaultGitLogSince(since, workdir) {
654
+ // Same antipattern that broke `defaultGitStaleBranches` (PR #70): on
655
+ // any error this used to return `(git log unavailable)`. The runner
656
+ // saw that as success-with-empty-data and downstream agents
657
+ // summarized "no recent commits" — false signal.
658
+ //
659
+ // Fix: return a JSON `{ok: false, error}` shape on failure so the
660
+ // runner's existing JSON-error detection (yamlRunner step-error
661
+ // block) flags the step as `error`. Successful runs still return
662
+ // bare git output text.
494
663
  try {
495
664
  const sinceArg = parseSinceToGitArg(since);
496
665
  const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
@@ -498,25 +667,50 @@ function defaultGitLogSince(since, workdir) {
498
667
  encoding: "utf-8",
499
668
  timeout: 5000,
500
669
  });
501
- if (result.error || result.status !== 0)
502
- return "(git log unavailable)";
670
+ if (result.error) {
671
+ return JSON.stringify({
672
+ ok: false,
673
+ error: `git log failed: ${result.error.message}`,
674
+ });
675
+ }
676
+ if (result.status !== 0) {
677
+ const stderr = (result.stderr ?? "").toString().trim().slice(0, 200);
678
+ return JSON.stringify({
679
+ ok: false,
680
+ error: `git log exited ${result.status}${stderr ? `: ${stderr}` : ""}`,
681
+ });
682
+ }
503
683
  return (result.stdout ?? "").trim();
504
684
  }
505
- catch {
506
- return "(git log unavailable)";
685
+ catch (err) {
686
+ return JSON.stringify({
687
+ ok: false,
688
+ error: `git log threw: ${err instanceof Error ? err.message : String(err)}`,
689
+ });
507
690
  }
508
691
  }
509
- function defaultGitStaleBranches(days, workdir) {
692
+ // Exported for test coverage of the regression fix (was using `git branch
693
+ // --since=<date>` which isn't a real flag).
694
+ export function defaultGitStaleBranches(days, workdir) {
695
+ // Two bugs were caught dogfooding the `branch-health` recipe:
696
+ // 1) `git branch --since=<date>` is NOT a valid flag — git exits 129
697
+ // with "unknown option `since=...`". The function used to ALWAYS
698
+ // fall through to the "(git branches unavailable)" placeholder.
699
+ // 2) Even if `--since` had been a real flag, its semantics ("commits
700
+ // since") would have produced the OPPOSITE list of what
701
+ // "stale_branches" implies — branches with recent activity, not
702
+ // ones that have gone quiet.
703
+ //
704
+ // Fix: use `git for-each-ref` with a `committerdate` format, parse the
705
+ // ISO date in JS, and emit branches whose last commit is OLDER than
706
+ // the cutoff. Output is one per line: `<short-name> <YYYY-MM-DD>`.
510
707
  try {
511
- const cutoff = new Date(Date.now() - days * 86_400_000)
512
- .toISOString()
513
- .slice(0, 10);
708
+ const cutoffMs = Date.now() - days * 86_400_000;
514
709
  const r = spawnSync("git", [
515
- "branch",
516
- "--no-column",
517
- "--sort=-committerdate",
518
- "--format=%(refname:short)",
519
- `--since=${cutoff}`,
710
+ "for-each-ref",
711
+ "--sort=committerdate",
712
+ "--format=%(refname:short)\t%(committerdate:iso-strict)",
713
+ "refs/heads/",
520
714
  ], {
521
715
  cwd: workdir ?? process.cwd(),
522
716
  encoding: "utf-8",
@@ -524,7 +718,25 @@ function defaultGitStaleBranches(days, workdir) {
524
718
  });
525
719
  if (r.error || r.status !== 0)
526
720
  return "(git branches unavailable)";
527
- return (r.stdout ?? "").trim();
721
+ const lines = (r.stdout ?? "").split("\n").filter(Boolean);
722
+ const stale = [];
723
+ for (const line of lines) {
724
+ const tab = line.indexOf("\t");
725
+ if (tab < 0)
726
+ continue;
727
+ const name = line.slice(0, tab);
728
+ const dateStr = line.slice(tab + 1);
729
+ const ts = Date.parse(dateStr);
730
+ if (Number.isNaN(ts))
731
+ continue;
732
+ if (ts < cutoffMs) {
733
+ stale.push(`${name}\t${dateStr.slice(0, 10)}`);
734
+ }
735
+ }
736
+ if (stale.length === 0) {
737
+ return `(no branches inactive >${days}d)`;
738
+ }
739
+ return stale.join("\n");
528
740
  }
529
741
  catch {
530
742
  return "(git branches unavailable)";
@@ -533,26 +745,43 @@ function defaultGitStaleBranches(days, workdir) {
533
745
  /** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
534
746
  function resolveStepDeps(deps) {
535
747
  const workdir = deps.workdir ?? process.cwd();
748
+ // Defense-in-depth: even if a file.* tool somehow forgets to call
749
+ // resolveRecipePath in its execute(), the default StepDeps file ops will
750
+ // jail the path before touching the filesystem (G-security F-01 / R2 C-1
751
+ // chained-runner third-substitution-site coverage).
536
752
  return {
537
- readFile: deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8")),
753
+ readFile: deps.readFile ??
754
+ ((p) => readFileSync(resolveRecipePath(p, { workspace: workdir }), "utf-8")),
538
755
  writeFile: deps.writeFile ??
539
756
  ((p, content) => {
540
- const abs = expandHome(p);
757
+ const abs = resolveRecipePath(p, { workspace: workdir, write: true });
541
758
  mkdirSync(path.dirname(abs), { recursive: true });
542
759
  writeFileSync(abs, content);
543
760
  }),
544
761
  appendFile: deps.appendFile ??
545
762
  ((p, content) => {
546
- const abs = expandHome(p);
763
+ const abs = resolveRecipePath(p, { workspace: workdir, write: true });
547
764
  mkdirSync(path.dirname(abs), { recursive: true });
548
765
  appendFileSync(abs, content);
549
766
  }),
550
767
  mkdir: deps.mkdir ??
551
- ((p) => mkdirSync(expandHome(p), { recursive: true })),
768
+ ((p) => mkdirSync(resolveRecipePath(p, { workspace: workdir, write: true }), {
769
+ recursive: true,
770
+ })),
552
771
  workdir,
553
772
  gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
554
773
  gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
555
- getDiagnostics: deps.getDiagnostics ?? (() => ""),
774
+ // The `diagnostics.get` recipe tool is registered (src/recipes/tools/
775
+ // diagnostics.ts) but only meaningful when the bridge wires a real
776
+ // `getDiagnostics` impl backed by the LSP / extension client. CLI runs
777
+ // and tests have no bridge to ask, so the default returns a JSON error
778
+ // shape that the step-error detector flags as `error` instead of the
779
+ // pre-fix empty string that silently passed as success.
780
+ getDiagnostics: deps.getDiagnostics ??
781
+ (() => JSON.stringify({
782
+ ok: false,
783
+ error: "diagnostics.get unavailable (no bridge / no `deps.getDiagnostics` injected)",
784
+ })),
556
785
  fetchFn: deps.fetchFn ?? globalThis.fetch,
557
786
  claudeFn: deps.claudeFn ?? defaultClaudeFn,
558
787
  claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
@@ -565,6 +794,11 @@ function resolveStepDeps(deps) {
565
794
  const { getValidAccessToken } = await import("../connectors/gmail.js");
566
795
  return getValidAccessToken();
567
796
  }),
797
+ getDriveToken: deps.getDriveToken ??
798
+ (async () => {
799
+ const { getValidAccessToken } = await import("../connectors/googleDrive.js");
800
+ return getValidAccessToken();
801
+ }),
568
802
  logDir: deps.logDir,
569
803
  testMode: deps.testMode ?? false,
570
804
  };
@@ -746,6 +980,26 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
746
980
  }
747
981
  }
748
982
  const executeTool = async (tool, params) => {
983
+ // R2 C-1 third-substitution-site coverage: the chained runner has its
984
+ // own template-resolution path (`chainedRunner.ts:194-205`). By the
985
+ // time we reach this dispatch point the params have been rendered
986
+ // *and* JSON-parsed, so a `path` field that survived the chained
987
+ // substitution may have just been promoted from inside-jail to
988
+ // outside-jail. Re-jail any `path` field on file.* tools here so that
989
+ // chained sub-recipes can't bypass the per-tool jail in `tools/file.ts`
990
+ // by injecting `..` segments via outer-recipe vars.
991
+ if ((tool === "file.read" ||
992
+ tool === "file.write" ||
993
+ tool === "file.append") &&
994
+ typeof params.path === "string") {
995
+ params = {
996
+ ...params,
997
+ path: resolveRecipePath(params.path, {
998
+ workspace: stepDeps.workdir,
999
+ write: tool !== "file.read",
1000
+ }),
1001
+ };
1002
+ }
749
1003
  // Construct a YamlStep-compatible object so we can reuse executeStep.
750
1004
  const step = { tool, ...params };
751
1005
  // executeStep uses a RunContext for {{}} rendering — by the time executeTool
@@ -755,8 +1009,36 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
755
1009
  return result ?? "";
756
1010
  };
757
1011
  const executeAgent = async (prompt, model, driver) => _executeAgent({ prompt, model, driver }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
1012
+ // ---------------------------------------------------------------------
1013
+ // BEGIN A-PR2 EDIT BLOCK — `loadNestedRecipe` jail (dogfood F-04).
1014
+ //
1015
+ // Path-shaped recipe references (`recipe: ./inner.yaml`, `recipe: /abs.yaml`)
1016
+ // are restricted to three allowed roots:
1017
+ // 1. parent recipe's directory (`path.dirname(parentSourcePath)`)
1018
+ // 2. user recipes dir (`~/.patchwork/recipes/`)
1019
+ // 3. bundled templates dir (`BUNDLED_TEMPLATES_DIR`, captured at boot)
1020
+ //
1021
+ // Resolved candidates that escape all three (e.g. `/etc/passwd.yaml`) are
1022
+ // rejected with `null` — same shape as a not-found lookup so the chained
1023
+ // runner reports its existing "nested_recipe_not_found" error rather than
1024
+ // surfacing a security-implementation detail to the recipe author.
1025
+ //
1026
+ // Coordination note (A-PR1 may also touch this file): the helper
1027
+ // `pathIsWithin` below is local to this module — A-PR1 is changing
1028
+ // unrelated `vars` validation paths and should not collide here. If a merge
1029
+ // conflict surfaces, keep BOTH the jail AND the A-PR1 vars validation.
1030
+ // ---------------------------------------------------------------------
1031
+ const pathIsWithin = (candidate, base) => {
1032
+ const resolvedCandidate = path.resolve(candidate);
1033
+ const resolvedBase = path.resolve(base);
1034
+ if (resolvedCandidate === resolvedBase)
1035
+ return true;
1036
+ return resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
1037
+ };
758
1038
  const loadNestedRecipe = async (name, parentSourcePath) => {
759
1039
  const lookupName = normalizeNestedRecipeLookupName(name);
1040
+ const { homedir: nestedHomedir } = await import("node:os");
1041
+ const userRecipesDir = path.join(nestedHomedir(), ".patchwork", "recipes");
760
1042
  if (parentSourcePath) {
761
1043
  const parentDir = path.dirname(parentSourcePath);
762
1044
  const pathLike = path.isAbsolute(name) ||
@@ -771,15 +1053,24 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
771
1053
  const candidates = /\.ya?ml$/i.test(resolvedBase)
772
1054
  ? [resolvedBase]
773
1055
  : [`${resolvedBase}.yaml`, `${resolvedBase}.yml`, resolvedBase];
1056
+ // Jail: every candidate must live inside one of the three allowed
1057
+ // roots (parent dir, user recipes, bundled templates). Reject silently
1058
+ // — null mirrors the existing not-found path so error messages stay
1059
+ // generic and don't leak the jail boundaries.
1060
+ const allowedRoots = [parentDir, userRecipesDir, BUNDLED_TEMPLATES_DIR];
774
1061
  for (const candidate of candidates) {
1062
+ const inJail = allowedRoots.some((root) => pathIsWithin(candidate, root));
1063
+ if (!inJail)
1064
+ continue;
775
1065
  const loaded = tryLoadRecipeFile(candidate);
776
1066
  if (loaded)
777
1067
  return loaded;
778
1068
  }
779
1069
  }
780
1070
  }
781
- const { homedir } = await import("node:os");
782
- const recipesDir = path.join(homedir(), ".patchwork", "recipes");
1071
+ // END A-PR2 EDIT BLOCK
1072
+ // Reuses `userRecipesDir` already resolved above for the jail check.
1073
+ const recipesDir = userRecipesDir;
783
1074
  // Check for manifest-based package directory first.
784
1075
  // Supports both plain names ("morning-brief") and scoped names ("@acme/morning-brief").
785
1076
  const pkgDirCandidates = [
@@ -841,13 +1132,21 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
841
1132
  onStepStart: deps.chainedOptions?.onStepStart,
842
1133
  onStepComplete: deps.chainedOptions?.onStepComplete,
843
1134
  runLogDir: deps.chainedOptions?.runLogDir,
1135
+ runLog: deps.chainedOptions?.runLog,
1136
+ activityLog: deps.chainedOptions?.activityLog,
1137
+ mockedOutputs: deps.chainedOptions?.mockedOutputs,
1138
+ taskIdPrefix: deps.chainedOptions?.taskIdPrefix,
844
1139
  };
845
1140
  if (!deps.chainedDeps) {
846
1141
  throw new Error("chainedDeps required for chained recipes (provide executeTool, executeAgent, loadNestedRecipe)");
847
1142
  }
848
1143
  return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
849
1144
  }
850
- return runYamlRecipe(recipe, deps, seedContext);
1145
+ // For non-chained recipes, lift `runLog` from chainedOptions onto the
1146
+ // RunnerDeps so runYamlRecipe gets the bridge's singleton too.
1147
+ return runYamlRecipe(recipe, deps.chainedOptions?.runLog
1148
+ ? { ...deps, runLog: deps.chainedOptions.runLog }
1149
+ : deps, seedContext);
851
1150
  }
852
1151
  /** List all YAML recipes in a directory. Returns names. */
853
1152
  export function listYamlRecipes(recipesDir) {