taskflow-core 0.2.0 → 0.2.2

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 (157) hide show
  1. package/README.md +247 -862
  2. package/dist/agents.d.ts +12 -0
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +29 -1
  5. package/dist/agents.js.map +1 -1
  6. package/dist/build-info.d.ts +48 -0
  7. package/dist/build-info.d.ts.map +1 -0
  8. package/dist/build-info.js +112 -0
  9. package/dist/build-info.js.map +1 -0
  10. package/dist/build-info.json +4 -0
  11. package/dist/cwd-bridge.d.ts +61 -0
  12. package/dist/cwd-bridge.d.ts.map +1 -0
  13. package/dist/cwd-bridge.js +136 -0
  14. package/dist/cwd-bridge.js.map +1 -0
  15. package/dist/detached-runner.js +31 -5
  16. package/dist/detached-runner.js.map +1 -1
  17. package/dist/exec/driver.d.ts +6 -0
  18. package/dist/exec/driver.d.ts.map +1 -1
  19. package/dist/exec/driver.js +40 -18
  20. package/dist/exec/driver.js.map +1 -1
  21. package/dist/exec/kernel-policy.d.ts.map +1 -1
  22. package/dist/exec/kernel-policy.js +10 -0
  23. package/dist/exec/kernel-policy.js.map +1 -1
  24. package/dist/exec/step-kinds.d.ts +14 -1
  25. package/dist/exec/step-kinds.d.ts.map +1 -1
  26. package/dist/exec/step-kinds.js +126 -6
  27. package/dist/exec/step-kinds.js.map +1 -1
  28. package/dist/exec/step.d.ts +22 -0
  29. package/dist/exec/step.d.ts.map +1 -1
  30. package/dist/exec/step.js +40 -5
  31. package/dist/exec/step.js.map +1 -1
  32. package/dist/final-output.d.ts +53 -0
  33. package/dist/final-output.d.ts.map +1 -0
  34. package/dist/final-output.js +65 -0
  35. package/dist/final-output.js.map +1 -0
  36. package/dist/flowir/canonical-hash.d.ts.map +1 -1
  37. package/dist/flowir/canonical-hash.js +2 -0
  38. package/dist/flowir/canonical-hash.js.map +1 -1
  39. package/dist/flowir/compile.d.ts.map +1 -1
  40. package/dist/flowir/compile.js +17 -0
  41. package/dist/flowir/compile.js.map +1 -1
  42. package/dist/flowir/meta.d.ts +3 -0
  43. package/dist/flowir/meta.d.ts.map +1 -1
  44. package/dist/flowir/schema.d.ts +3 -0
  45. package/dist/flowir/schema.d.ts.map +1 -1
  46. package/dist/flowir/schema.js.map +1 -1
  47. package/dist/flowir/translate.d.ts.map +1 -1
  48. package/dist/flowir/translate.js +4 -0
  49. package/dist/flowir/translate.js.map +1 -1
  50. package/dist/host/runner-types.d.ts +16 -0
  51. package/dist/host/runner-types.d.ts.map +1 -1
  52. package/dist/index.d.ts +5 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +9 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/interpolate.d.ts +4 -0
  57. package/dist/interpolate.d.ts.map +1 -1
  58. package/dist/interpolate.js +14 -0
  59. package/dist/interpolate.js.map +1 -1
  60. package/dist/resources/authority.d.ts +35 -0
  61. package/dist/resources/authority.d.ts.map +1 -0
  62. package/dist/resources/authority.js +67 -0
  63. package/dist/resources/authority.js.map +1 -0
  64. package/dist/resources/backend.d.ts +302 -0
  65. package/dist/resources/backend.d.ts.map +1 -0
  66. package/dist/resources/backend.js +16 -0
  67. package/dist/resources/backend.js.map +1 -0
  68. package/dist/resources/baseline.d.ts +116 -0
  69. package/dist/resources/baseline.d.ts.map +1 -0
  70. package/dist/resources/baseline.js +447 -0
  71. package/dist/resources/baseline.js.map +1 -0
  72. package/dist/resources/canonical-json.d.ts +7 -0
  73. package/dist/resources/canonical-json.d.ts.map +1 -0
  74. package/dist/resources/canonical-json.js +75 -0
  75. package/dist/resources/canonical-json.js.map +1 -0
  76. package/dist/resources/errors.d.ts +23 -0
  77. package/dist/resources/errors.d.ts.map +1 -0
  78. package/dist/resources/errors.js +89 -0
  79. package/dist/resources/errors.js.map +1 -0
  80. package/dist/resources/execution.d.ts +90 -0
  81. package/dist/resources/execution.d.ts.map +1 -0
  82. package/dist/resources/execution.js +581 -0
  83. package/dist/resources/execution.js.map +1 -0
  84. package/dist/resources/index.d.ts +15 -0
  85. package/dist/resources/index.d.ts.map +1 -0
  86. package/dist/resources/index.js +15 -0
  87. package/dist/resources/index.js.map +1 -0
  88. package/dist/resources/journal.d.ts +138 -0
  89. package/dist/resources/journal.d.ts.map +1 -0
  90. package/dist/resources/journal.js +438 -0
  91. package/dist/resources/journal.js.map +1 -0
  92. package/dist/resources/leases.d.ts +51 -0
  93. package/dist/resources/leases.d.ts.map +1 -0
  94. package/dist/resources/leases.js +354 -0
  95. package/dist/resources/leases.js.map +1 -0
  96. package/dist/resources/permits.d.ts +52 -0
  97. package/dist/resources/permits.d.ts.map +1 -0
  98. package/dist/resources/permits.js +240 -0
  99. package/dist/resources/permits.js.map +1 -0
  100. package/dist/resources/persistence.d.ts +63 -0
  101. package/dist/resources/persistence.d.ts.map +1 -0
  102. package/dist/resources/persistence.js +522 -0
  103. package/dist/resources/persistence.js.map +1 -0
  104. package/dist/resources/registry.d.ts +56 -0
  105. package/dist/resources/registry.d.ts.map +1 -0
  106. package/dist/resources/registry.js +139 -0
  107. package/dist/resources/registry.js.map +1 -0
  108. package/dist/resources/resolve.d.ts +49 -0
  109. package/dist/resources/resolve.d.ts.map +1 -0
  110. package/dist/resources/resolve.js +309 -0
  111. package/dist/resources/resolve.js.map +1 -0
  112. package/dist/resources/sandbox.d.ts +72 -0
  113. package/dist/resources/sandbox.d.ts.map +1 -0
  114. package/dist/resources/sandbox.js +952 -0
  115. package/dist/resources/sandbox.js.map +1 -0
  116. package/dist/resources/schema.d.ts +195 -0
  117. package/dist/resources/schema.d.ts.map +1 -0
  118. package/dist/resources/schema.js +231 -0
  119. package/dist/resources/schema.js.map +1 -0
  120. package/dist/resources/types.d.ts +36 -0
  121. package/dist/resources/types.d.ts.map +1 -0
  122. package/dist/resources/types.js +75 -0
  123. package/dist/resources/types.js.map +1 -0
  124. package/dist/resume.d.ts +76 -0
  125. package/dist/resume.d.ts.map +1 -0
  126. package/dist/resume.js +190 -0
  127. package/dist/resume.js.map +1 -0
  128. package/dist/runner-core.d.ts +29 -0
  129. package/dist/runner-core.d.ts.map +1 -1
  130. package/dist/runner-core.js +311 -36
  131. package/dist/runner-core.js.map +1 -1
  132. package/dist/runtime/phases/parallel.d.ts +3 -0
  133. package/dist/runtime/phases/parallel.d.ts.map +1 -1
  134. package/dist/runtime/phases/parallel.js.map +1 -1
  135. package/dist/runtime/phases/reduce.d.ts +47 -0
  136. package/dist/runtime/phases/reduce.d.ts.map +1 -0
  137. package/dist/runtime/phases/reduce.js +195 -0
  138. package/dist/runtime/phases/reduce.js.map +1 -0
  139. package/dist/runtime/phases/script.d.ts.map +1 -1
  140. package/dist/runtime/phases/script.js +47 -9
  141. package/dist/runtime/phases/script.js.map +1 -1
  142. package/dist/runtime.d.ts +67 -1
  143. package/dist/runtime.d.ts.map +1 -1
  144. package/dist/runtime.js +904 -79
  145. package/dist/runtime.js.map +1 -1
  146. package/dist/schema.d.ts +135 -5
  147. package/dist/schema.d.ts.map +1 -1
  148. package/dist/schema.js +338 -21
  149. package/dist/schema.js.map +1 -1
  150. package/dist/store.d.ts +56 -0
  151. package/dist/store.d.ts.map +1 -1
  152. package/dist/store.js +5 -1
  153. package/dist/store.js.map +1 -1
  154. package/dist/trace.d.ts +3 -0
  155. package/dist/trace.d.ts.map +1 -1
  156. package/dist/trace.js.map +1 -1
  157. package/package.json +4 -3
package/dist/schema.js CHANGED
@@ -10,10 +10,14 @@ import { contractShapeErrors } from "./contract.js";
10
10
  import { scorerShapeErrors } from "./scorers.js";
11
11
  import { Type } from "typebox";
12
12
  import { Errors as SchemaErrors } from "typebox/value";
13
+ import { cwdArgName, hasCwdPlaceholder, normalizeRelativePath } from "./cwd-bridge.js";
13
14
  import { WORKSPACE_KEYWORDS } from "./workspace.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Phase types
16
17
  // ---------------------------------------------------------------------------
18
+ /** Hard cap on subagent calls made by one tree-reduce phase. This bounds
19
+ * author mistakes even when no run budget was declared. */
20
+ export const TREE_REDUCE_HARD_MAX_CALLS = 256;
17
21
  /** Closed set of native phase kinds — single source of truth for DSL + FlowIR. */
18
22
  export const PHASE_TYPES = [
19
23
  "agent",
@@ -31,6 +35,18 @@ export const PHASE_TYPES = [
31
35
  /** Dynamic sub-DAG: nested sub-flow or graft-promote into parent (Horizon B). */
32
36
  "expand",
33
37
  ];
38
+ /** Phase kinds that execute one or more subagents and therefore require an
39
+ * effective idle watchdog (or a finite wall timeout when the watchdog is off). */
40
+ export const AGENT_RUNNING_PHASE_TYPES = [
41
+ "agent",
42
+ "gate",
43
+ "reduce",
44
+ "map",
45
+ "parallel",
46
+ "loop",
47
+ "tournament",
48
+ "race",
49
+ ];
34
50
  /** Loop iteration bounds. Authors may lower the max; the hard cap is a runaway guard. */
35
51
  export const LOOP_DEFAULT_MAX_ITERATIONS = 10;
36
52
  export const LOOP_HARD_MAX_ITERATIONS = 100;
@@ -58,11 +74,12 @@ const CACHE_SCOPES = ["run-only", "cross-run", "off"];
58
74
  const CACHE_FINGERPRINT_PREFIXES = ["git:", "glob:", "glob!:", "file:", "env:"];
59
75
  /** Phase types that must NOT be cached across runs (a fresh result is required each run). */
60
76
  const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament", "script", "race", "expand"];
61
- /** `cwd` is a literal path / workspace keyword, not an interpolated field. */
62
- const CWD_PLACEHOLDER_RE = /\{[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\}/;
63
77
  const ParallelTaskSchema = Type.Object({
64
78
  task: Type.String({ description: "Task for this parallel branch (supports interpolation)" }),
65
79
  agent: Type.Optional(Type.String({ description: "Override the phase agent for this branch" })),
80
+ cwd: Type.Optional(Type.String({
81
+ description: "Working directory for this parallel branch's subagent (a literal path). Overrides the phase-level cwd for this branch only. Reserved workspace keywords ('temp'/'dedicated'/'worktree') are NOT supported per-branch (the workspace lifecycle is per-phase) — use the phase-level cwd for those.",
82
+ })),
66
83
  }, { additionalProperties: false });
67
84
  /** Declarative retry policy for a phase's subagent call(s). */
68
85
  const RetrySchema = Type.Object({
@@ -112,6 +129,24 @@ const PhaseSchema = Type.Object({
112
129
  cancelLosers: Type.Optional(Type.Boolean({ default: true })),
113
130
  // reduce
114
131
  from: Type.Optional(Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" })),
132
+ /**
133
+ * [reduce] How the aggregated `from[]` inputs are reduced. `'one-shot'`
134
+ * (default) feeds all inputs to a single reducer call as `{previous.output}`.
135
+ * `'tree'` batches the inputs (see `batchSize`) and runs intermediate
136
+ * reducer calls over each batch, then reduces the round outputs until one
137
+ * remains — useful when the aggregated input would exceed a single prompt.
138
+ * Tree reduction uses the SAME agent/model/options/timeout/idleTimeout as
139
+ * the phase and forces the imperative runtime (the event kernel falls back).
140
+ * The corrected `{previous.output}` aggregation (all `from[]` sources) always
141
+ * applies regardless of strategy.
142
+ */
143
+ reduceStrategy: Type.Optional(StringEnum(["one-shot", "tree"], {
144
+ description: "[reduce] 'one-shot' (default) = single reducer call; 'tree' = batched intermediate rounds (see batchSize). Forces imperative runtime.",
145
+ default: "one-shot",
146
+ })),
147
+ /** [reduce] With `reduceStrategy:'tree'`, the max number of aggregated inputs
148
+ * fed to each intermediate reducer call (>= 2). Ignored for one-shot. */
149
+ batchSize: Type.Optional(Type.Integer({ minimum: 2, description: "[reduce] Batch size for reduceStrategy:'tree' (integer >= 2). Ignored for one-shot." })),
115
150
  // sub-workflow (flow) + expand fragment
116
151
  use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })),
117
152
  /** [expand] Fragment source — string interpolation or inline Taskflow / phases (same as flow.def). */
@@ -139,6 +174,16 @@ const PhaseSchema = Type.Object({
139
174
  timeout: Type.Optional(Type.Number({
140
175
  description: "Max execution time in milliseconds. For script phases: caps the shell command (default 60000, max 300000). For agent-running phases (agent/gate/reduce/map/parallel/loop/tournament): caps EACH subagent call — on expiry the subagent is aborted and the phase fails with a 'timedOut' marker (never retried). Not supported for approval/flow phases. Must be >= 1000.",
141
176
  })),
177
+ /**
178
+ * Idle watchdog override (ms) for agent-running phases. A positive value (>= 1000)
179
+ * replaces the host default (300000ms): if a subagent produces no output for this
180
+ * long it is killed as stalled. `0` DISABLES the watchdog — but then a finite
181
+ * wall `timeout` (>= 1000) is REQUIRED on this phase so it can never hang forever.
182
+ * Overrides the flow-level `idleTimeout`. Absent → flow-level or host default.
183
+ */
184
+ idleTimeout: Type.Optional(Type.Number({
185
+ description: "[agent-running] Idle watchdog in ms (>= 1000, or 0 to disable). 0 requires a finite wall 'timeout' >= 1000. Overrides the flow-level idleTimeout.",
186
+ })),
142
187
  // loop-until-done
143
188
  until: Type.Optional(Type.String({
144
189
  description: "[loop] Stop condition evaluated after each iteration. The iteration's output is exposed as {steps.<thisId>.output}/.json. Supports the same operators as `when`. The loop stops when this is truthy, on convergence, or at maxIterations. A parse error stops the loop (fail-safe).",
@@ -186,7 +231,7 @@ const PhaseSchema = Type.Object({
186
231
  description: "Thinking level override for this phase. Unknown values are rejected instead of silently inheriting the host default.",
187
232
  })),
188
233
  tools: Type.Optional(Type.Array(Type.String(), { description: "Restrict tools for this phase's agent" })),
189
- cwd: Type.Optional(Type.String({ description: "Working directory for this phase's subagent. A literal path, or a reserved keyword: 'temp' (ephemeral dir, removed after the phase), 'dedicated' (persistent dir under the run state, kept), or 'worktree' (a git worktree on a throwaway branch, removed after the phase)." })),
234
+ cwd: Type.Optional(Type.String({ description: "Working directory for this phase. Accepts a literal path; a reserved keyword ('temp', 'dedicated', 'worktree'); or the exact whole placeholder {args.X} when X is declared type:'relative-path'. The 0.2.1 argument bridge requires an explicit host resolve-only opt-in." })),
190
235
  final: Type.Optional(Type.Boolean({ description: "Mark this phase's output as the workflow result" })),
191
236
  optional: Type.Optional(Type.Boolean({ description: "If true, a failure does not abort the run", default: false })),
192
237
  idempotent: Type.Optional(Type.Boolean({
@@ -216,11 +261,53 @@ const PhaseSchema = Type.Object({
216
261
  description: "Opt into the Shared Context Tree for this phase: the subagent gets ctx_read/ctx_write (a blackboard shared with siblings/ancestors, to avoid re-reading files) and ctx_report/ctx_spawn (report upward + queue child tasks the runtime picks up). Default false — existing flows are unaffected.",
217
262
  })),
218
263
  }, { additionalProperties: false });
219
- const ArgSpecSchema = Type.Object({
264
+ const LegacyArgSpecSchema = Type.Object({
265
+ type: Type.Optional(Type.Never()),
220
266
  default: Type.Optional(Type.Unknown()),
221
267
  description: Type.Optional(Type.String()),
222
268
  required: Type.Optional(Type.Boolean()),
223
269
  }, { additionalProperties: false });
270
+ const StringArgSpecSchema = Type.Object({
271
+ type: Type.Literal("string"),
272
+ default: Type.Optional(Type.String()),
273
+ description: Type.Optional(Type.String()),
274
+ required: Type.Optional(Type.Boolean()),
275
+ }, { additionalProperties: false });
276
+ const RelativePathArgSpecSchema = Type.Object({
277
+ type: Type.Literal("relative-path"),
278
+ default: Type.Optional(Type.String()),
279
+ description: Type.Optional(Type.String()),
280
+ required: Type.Optional(Type.Boolean()),
281
+ }, { additionalProperties: false });
282
+ const NumberArgSpecSchema = Type.Object({
283
+ type: Type.Literal("number"),
284
+ default: Type.Optional(Type.Number()),
285
+ minimum: Type.Optional(Type.Number()),
286
+ maximum: Type.Optional(Type.Number()),
287
+ description: Type.Optional(Type.String()),
288
+ required: Type.Optional(Type.Boolean()),
289
+ }, { additionalProperties: false });
290
+ const BooleanArgSpecSchema = Type.Object({
291
+ type: Type.Literal("boolean"),
292
+ default: Type.Optional(Type.Boolean()),
293
+ description: Type.Optional(Type.String()),
294
+ required: Type.Optional(Type.Boolean()),
295
+ }, { additionalProperties: false });
296
+ const EnumArgSpecSchema = Type.Object({
297
+ type: Type.Literal("enum"),
298
+ values: Type.Array(Type.Union([Type.String(), Type.Number()]), { minItems: 1 }),
299
+ default: Type.Optional(Type.Union([Type.String(), Type.Number()])),
300
+ description: Type.Optional(Type.String()),
301
+ required: Type.Optional(Type.Boolean()),
302
+ }, { additionalProperties: false });
303
+ const ArgSpecSchema = Type.Union([
304
+ LegacyArgSpecSchema,
305
+ StringArgSpecSchema,
306
+ RelativePathArgSpecSchema,
307
+ NumberArgSpecSchema,
308
+ BooleanArgSpecSchema,
309
+ EnumArgSpecSchema,
310
+ ]);
224
311
  export const TaskflowSchema = Type.Object({
225
312
  name: Type.String({ minLength: 1, description: "Workflow name (becomes /tf:<name> command when saved)" }),
226
313
  description: Type.Optional(Type.String()),
@@ -239,6 +326,16 @@ export const TaskflowSchema = Type.Object({
239
326
  incremental: Type.Optional(Type.Boolean({
240
327
  description: "Default every phase to cross-run caching (scope:'cross-run') so re-running this flow reuses unchanged phases across runs/sessions. Equivalent to setting cache:{scope:'cross-run'} on every phase; per-phase cache settings and the cross-run-blocked types (gate/approval/loop/tournament) still take precedence. Default false (run-only — each run starts fresh unless a phase opts in). A run-time `incremental` argument overrides this.",
241
328
  })),
329
+ /**
330
+ * Flow-level idle watchdog (ms) for all agent-running phases that don't set
331
+ * their own `idleTimeout`. Positive (>= 1000) overrides the host default
332
+ * (300000ms); `0` disables the watchdog for those phases — but then every
333
+ * agent-running phase MUST declare a finite wall `timeout` (>= 1000) so the
334
+ * flow can never hang forever. A per-phase `idleTimeout` overrides this.
335
+ */
336
+ idleTimeout: Type.Optional(Type.Number({
337
+ description: "Flow-level idle watchdog in ms (>= 1000, or 0 to disable). Per-phase idleTimeout overrides. 0 requires every agent-running phase to declare a finite wall 'timeout' >= 1000.",
338
+ })),
242
339
  phases: Type.Array(PhaseSchema, { minItems: 1, description: "Ordered phase definitions (DAG via dependsOn)" }),
243
340
  }, { additionalProperties: false });
244
341
  /** True when `def` is a shorthand spec (no `phases`, but a task/tasks/chain field). */
@@ -270,6 +367,8 @@ function readStep(s) {
270
367
  step.context = ctx;
271
368
  if (typeof o.contextLimit === "number")
272
369
  step.contextLimit = o.contextLimit;
370
+ if (typeof o.cwd === "string")
371
+ step.cwd = o.cwd;
273
372
  return step;
274
373
  }
275
374
  return { task: "" };
@@ -296,6 +395,13 @@ export function desugar(def) {
296
395
  meta.budget = d.budget;
297
396
  if (typeof d.strictInterpolation === "boolean")
298
397
  meta.strictInterpolation = d.strictInterpolation;
398
+ /** Top-level shorthand cwd (literal path or workspace keyword). Becomes the
399
+ * default for every step; a per-step cwd overrides it. For single/chain it
400
+ * lands on each Phase.cwd (full workspace lifecycle). For parallel it lands
401
+ * on the phase cwd (shared), with per-branch cwds overriding per-branch.
402
+ * Note: `cwd` is a Phase field, NOT a Taskflow field, so it is distributed
403
+ * to each phase below rather than placed on the Taskflow object. */
404
+ const topCwd = typeof d.cwd === "string" && d.cwd.trim() ? d.cwd : undefined;
299
405
  const nameOf = (fallback) => (typeof d.name === "string" && d.name.trim() ? d.name.trim() : fallback);
300
406
  // chain → sequential agent phases
301
407
  if (Array.isArray(d.chain) && d.chain.length > 0) {
@@ -313,6 +419,9 @@ export function desugar(def) {
313
419
  phase.context = s.context;
314
420
  if (s.contextLimit !== undefined)
315
421
  phase.contextLimit = s.contextLimit;
422
+ const stepCwd = s.cwd ?? topCwd;
423
+ if (stepCwd)
424
+ phase.cwd = stepCwd;
316
425
  if (i > 0)
317
426
  phase.dependsOn = [`step${i}`];
318
427
  if (i === steps.length - 1)
@@ -326,8 +435,18 @@ export function desugar(def) {
326
435
  // per branch): spec-level context plus the union of step-level contexts.
327
436
  if (Array.isArray(d.tasks) && d.tasks.length > 0) {
328
437
  const steps = d.tasks.map(readStep);
329
- const branches = steps.map((s) => (s.agent ? { task: s.task, agent: s.agent } : { task: s.task }));
438
+ const branches = steps.map((s) => {
439
+ const b = { task: s.task };
440
+ if (s.agent)
441
+ b.agent = s.agent;
442
+ // Per-branch literal cwd (workspace keywords are rejected by validation).
443
+ if (s.cwd)
444
+ b.cwd = s.cwd;
445
+ return b;
446
+ });
330
447
  const phase = { id: "parallel", type: "parallel", branches, final: true };
448
+ if (topCwd)
449
+ phase.cwd = topCwd;
331
450
  const shared = [...(readContextList(d.context) ?? []), ...steps.flatMap((s) => s.context ?? [])];
332
451
  if (shared.length)
333
452
  phase.context = Array.from(new Set(shared));
@@ -349,6 +468,8 @@ export function desugar(def) {
349
468
  phase.context = ctx;
350
469
  if (typeof d.contextLimit === "number")
351
470
  phase.contextLimit = d.contextLimit;
471
+ if (topCwd)
472
+ phase.cwd = topCwd;
352
473
  return { name: nameOf("task"), ...meta, phases: [phase] };
353
474
  }
354
475
  throw new Error("Shorthand spec needs one of: 'task' (single), 'tasks' (parallel), or 'chain' (sequential)");
@@ -370,6 +491,58 @@ export function parseTtlMs(ttl) {
370
491
  const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
371
492
  return n * mult[unit];
372
493
  }
494
+ function typedArgValueErrors(name, spec, value, source) {
495
+ const prefix = `Argument '${name}' ${source}`;
496
+ if (spec.type === "relative-path") {
497
+ const r = normalizeRelativePath(value);
498
+ return r.ok ? [] : [`${prefix} ${r.message}`];
499
+ }
500
+ if (spec.type === "string") {
501
+ if (typeof value !== "string")
502
+ return [`${prefix} must be a string, got ${value === null ? "null" : typeof value}`];
503
+ return [];
504
+ }
505
+ if (spec.type === "number") {
506
+ if (typeof value !== "number" || !Number.isFinite(value))
507
+ return [`${prefix} must be a finite number`];
508
+ if (spec.minimum !== undefined && value < spec.minimum)
509
+ return [`${prefix} must be >= ${spec.minimum}`];
510
+ if (spec.maximum !== undefined && value > spec.maximum)
511
+ return [`${prefix} must be <= ${spec.maximum}`];
512
+ return [];
513
+ }
514
+ if (spec.type === "boolean") {
515
+ return typeof value === "boolean" ? [] : [`${prefix} must be a boolean, got ${value === null ? "null" : typeof value}`];
516
+ }
517
+ if (spec.type === "enum") {
518
+ const values = Array.isArray(spec.values) ? spec.values : [];
519
+ return values.some((v) => Object.is(v, value)) ? [] : [`${prefix} must be one of ${JSON.stringify(values)}`];
520
+ }
521
+ return [];
522
+ }
523
+ /** Validate resolved invocation values independently of full flow structure.
524
+ * Runtime calls this at the Core boundary so direct, resume, and detached
525
+ * execution cannot bypass adapter-level typed-arg checks. */
526
+ export function validateInvocationArgs(def, args) {
527
+ const errors = [];
528
+ const specs = (def.args ?? {});
529
+ for (const [name, spec] of Object.entries(specs)) {
530
+ // Full structural validation owns malformed specs. Keep this boundary
531
+ // total so validateTaskflow() reports SchemaErrors instead of throwing
532
+ // while it performs the optional invocation-value pass.
533
+ if (typeof spec !== "object" || spec === null || Array.isArray(spec))
534
+ continue;
535
+ const typedSpec = spec;
536
+ if (!(name in args)) {
537
+ if (typedSpec.type !== undefined && typedSpec.required === true && typedSpec.default === undefined)
538
+ errors.push(`Missing required argument '${name}'`);
539
+ continue;
540
+ }
541
+ if (typedSpec.type !== undefined)
542
+ errors.push(...typedArgValueErrors(name, typedSpec, args[name], "invocation"));
543
+ }
544
+ return errors;
545
+ }
373
546
  export function validateTaskflow(def, opts = {}) {
374
547
  const errors = [];
375
548
  const warnings = [];
@@ -397,6 +570,52 @@ export function validateTaskflow(def, opts = {}) {
397
570
  errors.push("Taskflow must have at least one phase");
398
571
  return { ok: false, errors, warnings };
399
572
  }
573
+ // Typed invocation args are the trust boundary for resource selectors. Legacy
574
+ // specs remain valid for every flow version, but can never feed a cwd bridge.
575
+ // `version` is informational metadata, not a schema-version selector.
576
+ const argSpecs = (typeof flow.args === "object" && flow.args !== null && !Array.isArray(flow.args)
577
+ ? flow.args
578
+ : {});
579
+ for (const [name, spec] of Object.entries(argSpecs)) {
580
+ if (!spec || typeof spec !== "object" || Array.isArray(spec))
581
+ continue;
582
+ if (spec.type === "number" && spec.minimum !== undefined && spec.maximum !== undefined && spec.minimum > spec.maximum) {
583
+ errors.push(`Argument '${name}': minimum must be <= maximum`);
584
+ }
585
+ if (spec.type === "enum" && Array.isArray(spec.values) && new Set(spec.values.map((v) => `${typeof v}:${String(v)}`)).size !== spec.values.length) {
586
+ errors.push(`Argument '${name}': enum values must be unique`);
587
+ }
588
+ if (spec.default !== undefined && spec.type !== undefined) {
589
+ errors.push(...typedArgValueErrors(name, spec, spec.default, "default"));
590
+ }
591
+ }
592
+ if (opts.args !== undefined) {
593
+ errors.push(...validateInvocationArgs(flow, opts.args));
594
+ for (const [name, spec] of Object.entries(argSpecs)) {
595
+ if (!spec || typeof spec !== "object" || Array.isArray(spec))
596
+ continue;
597
+ if (spec.type === undefined && spec.required === true && spec.default === undefined && !(name in opts.args)) {
598
+ warnings.push(`Legacy untyped argument '${name}' is marked required but remains advisory; add an explicit type to enforce it`);
599
+ }
600
+ }
601
+ for (const name of Object.keys(opts.args)) {
602
+ if (name in argSpecs)
603
+ continue;
604
+ if (strict)
605
+ warnings.push(`Invocation argument '${name}' is undeclared`);
606
+ }
607
+ }
608
+ // Flow-level idleTimeout: positive must be >= 1000; 0 is allowed (disables the
609
+ // watchdog) but every agent-running phase must then declare a finite wall
610
+ // `timeout` so the run can never hang forever (critical invariant #5).
611
+ if (flow.idleTimeout !== undefined) {
612
+ if (typeof flow.idleTimeout !== "number" || !Number.isFinite(flow.idleTimeout) || flow.idleTimeout < 0) {
613
+ errors.push(`Flow 'idleTimeout' must be a non-negative finite number (ms), got ${typeof flow.idleTimeout === "number" ? flow.idleTimeout : typeof flow.idleTimeout}`);
614
+ }
615
+ else if (flow.idleTimeout > 0 && flow.idleTimeout < 1000) {
616
+ errors.push(`Flow 'idleTimeout' must be 0 (disable) or >= 1000 ms, got ${flow.idleTimeout}`);
617
+ }
618
+ }
400
619
  // Hardening for runtime-generated (untrusted) sub-flows: bound breadth and
401
620
  // contain filesystem access. These do NOT apply to authored/saved flows.
402
621
  if (opts.dynamic) {
@@ -406,10 +625,12 @@ export function validateTaskflow(def, opts = {}) {
406
625
  if (typeof flow.concurrency === "number" && flow.concurrency > MAX_DYNAMIC_CONCURRENCY) {
407
626
  errors.push(`Dynamic sub-flow concurrency too high (${flow.concurrency}, max ${MAX_DYNAMIC_CONCURRENCY})`);
408
627
  }
409
- const root = opts.cwd ? path.resolve(opts.cwd) : undefined;
410
628
  for (const p of flow.phases) {
411
629
  if (!p || typeof p !== "object")
412
630
  continue;
631
+ if (Array.isArray(p.context) && p.context.length > 0) {
632
+ errors.push(`Dynamic sub-flow phase '${p.id}': context file pre-reads are not allowed in generated flows`);
633
+ }
413
634
  // A generated phase may not execute shell commands. `script` runs an
414
635
  // arbitrary command — a strictly larger capability than the reserved
415
636
  // cwd keywords blocked below — so an LLM-authored plan (flow{def} /
@@ -444,20 +665,18 @@ export function validateTaskflow(def, opts = {}) {
444
665
  if (typeof p.concurrency === "number" && p.concurrency > MAX_DYNAMIC_CONCURRENCY) {
445
666
  errors.push(`Dynamic sub-flow phase '${p.id}': concurrency too high (${p.concurrency}, max ${MAX_DYNAMIC_CONCURRENCY})`);
446
667
  }
447
- // cwd containment: a generated phase may not escape the run's cwd, and
448
- // may not request a reserved workspace keyword (temp/dedicated/worktree)
449
- // — LLM-authored sub-flows must not allocate isolated dirs or git
450
- // worktrees that mutate the repo. Only author-written flows may.
668
+ // W1a/FileBroker is not available yet, so a generated phase may not
669
+ // choose any cwd. Lexical containment is insufficient because symlinks
670
+ // and time-of-check/time-of-use races can cross the invocation boundary.
451
671
  if (typeof p.cwd === "string") {
452
- if (WORKSPACE_KEYWORDS.includes(p.cwd)) {
453
- errors.push(`Dynamic sub-flow phase '${p.id}': cwd '${p.cwd}' is a reserved workspace keyword not allowed in generated flows`);
454
- }
455
- else if (root) {
456
- const resolved = path.resolve(root, p.cwd);
457
- if (resolved !== root && !resolved.startsWith(root + path.sep)) {
458
- errors.push(`Dynamic sub-flow phase '${p.id}': cwd '${p.cwd}' escapes the run directory`);
672
+ errors.push(`Dynamic sub-flow phase '${p.id}': cwd selection is not allowed in generated flows`);
673
+ }
674
+ if (Array.isArray(p.branches)) {
675
+ p.branches.forEach((branch, i) => {
676
+ if (branch && typeof branch === "object" && typeof branch.cwd === "string") {
677
+ errors.push(`Dynamic sub-flow phase '${p.id}': branches[${i}].cwd selection is not allowed in generated flows`);
459
678
  }
460
- }
679
+ });
461
680
  }
462
681
  }
463
682
  }
@@ -516,8 +735,26 @@ export function validateTaskflow(def, opts = {}) {
516
735
  !THINKING_LEVELS.includes(p.thinking)) {
517
736
  errors.push(`Phase '${p.id}': 'thinking' must be one of ${THINKING_LEVELS.join(", ")}; got '${p.thinking}'`);
518
737
  }
519
- if (typeof p.cwd === "string" && CWD_PLACEHOLDER_RE.test(p.cwd)) {
520
- errors.push(`Phase '${p.id}': 'cwd' does not support interpolation placeholders (${p.cwd}). Use a literal path, or a reserved workspace keyword ('temp', 'dedicated', 'worktree').`);
738
+ if (typeof p.cwd === "string" && hasCwdPlaceholder(p.cwd)) {
739
+ const argName = cwdArgName(p.cwd);
740
+ if (!argName) {
741
+ errors.push(`Phase '${p.id}': dynamic 'cwd' must be exactly one whole {args.X} placeholder; concatenation, multiple placeholders, and {steps.*} are forbidden.`);
742
+ }
743
+ else {
744
+ const spec = argSpecs[argName];
745
+ if (!spec) {
746
+ errors.push(`Phase '${p.id}': cwd references undeclared argument '${argName}'`);
747
+ }
748
+ else if (spec.type !== "relative-path") {
749
+ errors.push(`Phase '${p.id}': cwd argument '${argName}' must be declared with type 'relative-path'`);
750
+ }
751
+ if (p.cache?.scope === "cross-run") {
752
+ errors.push(`Phase '${p.id}': cwd selected by an argument cannot use cache.scope 'cross-run' until workspace state restoration is available.`);
753
+ }
754
+ if (p.retry && typeof p.retry.max === "number" && p.retry.max > 0) {
755
+ errors.push(`Phase '${p.id}': cwd selected by an argument cannot use retry.max > 0 because a failed resolve-only write has an unknown filesystem outcome and must be reconciled before another attempt.`);
756
+ }
757
+ }
521
758
  }
522
759
  // dependsOn / from entries are string phase-id refs that flow into the graph
523
760
  // helpers and nodeId(); a non-string entry would crash the renderer.
@@ -532,11 +769,35 @@ export function validateTaskflow(def, opts = {}) {
532
769
  // Branch entries become competitors at runtime (b.task is interpolated); a
533
770
  // non-object / non-string-task entry would crash the runtime, so reject it.
534
771
  if (Array.isArray(p.branches)) {
772
+ const branchPhaseType = (p.type ?? "agent");
535
773
  p.branches.forEach((b, i) => {
536
774
  if (!b || typeof b !== "object" || Array.isArray(b))
537
775
  errors.push(`Phase '${p.id}': branches[${i}] must be an object with a 'task', got ${b === null ? "null" : typeof b}`);
538
776
  else if (typeof b.task !== "string")
539
777
  errors.push(`Phase '${p.id}': branches[${i}].task must be a string`);
778
+ else {
779
+ // Per-branch cwd: a literal path is honored per-branch; a reserved
780
+ // workspace keyword ('temp'/'dedicated'/'worktree') is NOT supported
781
+ // per-branch (the workspace lifecycle is per-phase — a branch cannot
782
+ // safely allocate/teardown its own workspace). Reject that shape
783
+ // precisely rather than silently using the wrong cwd. Use the
784
+ // phase-level `cwd` for workspace isolation.
785
+ const bcwd = b.cwd;
786
+ if (typeof bcwd === "string") {
787
+ if (branchPhaseType !== "parallel") {
788
+ errors.push(`Phase '${p.id}' (${branchPhaseType}): branches[${i}].cwd is only supported for parallel phases`);
789
+ }
790
+ else if (hasCwdPlaceholder(bcwd)) {
791
+ errors.push(`Phase '${p.id}': branches[${i}].cwd does not support interpolation placeholders (${bcwd}). Use a literal path.`);
792
+ }
793
+ else if (WORKSPACE_KEYWORDS.includes(bcwd)) {
794
+ errors.push(`Phase '${p.id}': branches[${i}].cwd '${bcwd}' is a reserved workspace keyword not supported per-branch (the workspace lifecycle is per-phase). Use the phase-level 'cwd' for workspace isolation.`);
795
+ }
796
+ else if (typeof p.cwd === "string" && (WORKSPACE_KEYWORDS.includes(p.cwd) || hasCwdPlaceholder(p.cwd))) {
797
+ errors.push(`Phase '${p.id}': branches[${i}].cwd cannot override phase cwd '${p.cwd}' because that phase uses a managed workspace/cwd binding. Put the literal cwd on the phase or remove the branch override.`);
798
+ }
799
+ }
800
+ }
540
801
  });
541
802
  }
542
803
  // tools entries are matched against a Set by the adapters (t => set.has(t));
@@ -590,7 +851,9 @@ export function validateTaskflow(def, opts = {}) {
590
851
  warnings.push(`Phase '${p.id}' (gate): both 'eval' and 'score' are set — eval runs first (all-pass skips the gate entirely), then score. This is valid but usually one of the two suffices.`);
591
852
  }
592
853
  // Judge agent naming convention (mirrors the phase-agent check below).
593
- const judgeAgent = scoreVal.judge?.agent;
854
+ const judgeAgent = scoreVal !== null && typeof scoreVal === "object"
855
+ ? scoreVal.judge?.agent
856
+ : undefined;
594
857
  if (typeof judgeAgent === "string" && judgeAgent.includes("_")) {
595
858
  errors.push(`Phase '${p.id}': score.judge.agent '${judgeAgent}' uses underscores — use hyphens`);
596
859
  }
@@ -672,6 +935,33 @@ export function validateTaskflow(def, opts = {}) {
672
935
  errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
673
936
  if (!p.task)
674
937
  errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
938
+ // reduceStrategy / batchSize (reduce-only).
939
+ const strat = p.reduceStrategy;
940
+ if (strat !== undefined && strat !== "tree" && strat !== "one-shot") {
941
+ errors.push(`Phase '${p.id}' (reduce): reduceStrategy must be 'tree' or 'one-shot', got '${String(strat)}'`);
942
+ }
943
+ const bs = p.batchSize;
944
+ if (bs !== undefined) {
945
+ if (typeof bs !== "number" || !Number.isFinite(bs) || bs < 2 || !Number.isInteger(bs)) {
946
+ errors.push(`Phase '${p.id}' (reduce): batchSize must be an integer >= 2`);
947
+ }
948
+ else if (strat !== "tree") {
949
+ warnings.push(`Phase '${p.id}' (reduce): batchSize is only used with reduceStrategy 'tree' — ignored for one-shot`);
950
+ }
951
+ }
952
+ if (strat === "tree") {
953
+ const batchSize = typeof bs === "number" && Number.isInteger(bs) && bs >= 2 ? bs : 2;
954
+ let remaining = asArray(p.from).length;
955
+ let maxCalls = 0;
956
+ while (remaining > 1 && maxCalls <= TREE_REDUCE_HARD_MAX_CALLS) {
957
+ const callsThisRound = Math.ceil(remaining / batchSize);
958
+ maxCalls += callsThisRound;
959
+ remaining = callsThisRound;
960
+ }
961
+ if (maxCalls > TREE_REDUCE_HARD_MAX_CALLS) {
962
+ errors.push(`Phase '${p.id}' (reduce): tree strategy exceeds hard cap ${TREE_REDUCE_HARD_MAX_CALLS} subagent calls; increase batchSize or split the reduction`);
963
+ }
964
+ }
675
965
  }
676
966
  if (type === "flow") {
677
967
  const hasUse = typeof p.use === "string" && p.use.length > 0;
@@ -743,6 +1033,32 @@ export function validateTaskflow(def, opts = {}) {
743
1033
  errors.push(`Phase '${p.id}' (${type}): 'timeout' must be a number >= 1000 ms`);
744
1034
  }
745
1035
  }
1036
+ // idleTimeout validation (agent-running phases that can use the watchdog).
1037
+ // The host default (300000ms) applies when neither phase nor flow sets it.
1038
+ const AGENT_RUNNING_FOR_IDLE = new Set(AGENT_RUNNING_PHASE_TYPES);
1039
+ if (AGENT_RUNNING_FOR_IDLE.has(type)) {
1040
+ const phaseIdle = p.idleTimeout;
1041
+ if (phaseIdle !== undefined) {
1042
+ if (typeof phaseIdle !== "number" || !Number.isFinite(phaseIdle) || phaseIdle < 0) {
1043
+ errors.push(`Phase '${p.id}': 'idleTimeout' must be a non-negative finite number (ms), got ${typeof phaseIdle === "number" ? phaseIdle : typeof phaseIdle}`);
1044
+ }
1045
+ else if (phaseIdle > 0 && phaseIdle < 1000) {
1046
+ errors.push(`Phase '${p.id}': 'idleTimeout' must be 0 (disable) or >= 1000 ms, got ${phaseIdle}`);
1047
+ }
1048
+ }
1049
+ // Effective idle watchdog = phase (wins) → flow → host default (undefined).
1050
+ const effectiveIdle = (typeof phaseIdle === "number" ? phaseIdle : flow.idleTimeout);
1051
+ if (effectiveIdle === 0) {
1052
+ // Disabling the watchdog requires a finite wall timeout so the phase can
1053
+ // never hang forever (critical invariant #5: never hang forever).
1054
+ if (typeof p.timeout !== "number" || !Number.isFinite(p.timeout) || p.timeout < 1000) {
1055
+ errors.push(`Phase '${p.id}': idleTimeout:0 disables the watchdog — a finite wall 'timeout' (>= 1000 ms) is required so this phase cannot hang forever. Add a 'timeout' or use a positive 'idleTimeout'.`);
1056
+ }
1057
+ }
1058
+ }
1059
+ else if (p.idleTimeout !== undefined) {
1060
+ warnings.push(`Phase '${p.id}' (${type}): 'idleTimeout' only applies to agent-running phases (${AGENT_RUNNING_PHASE_TYPES.join("/")}) — ignored here`);
1061
+ }
746
1062
  if (p.retry) {
747
1063
  if (typeof p.retry.max !== "number" || p.retry.max < 0) {
748
1064
  errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
@@ -972,6 +1288,7 @@ export function collectRefs(phase) {
972
1288
  scan(phase.over);
973
1289
  scan(phase.when);
974
1290
  scan(phase.until);
1291
+ scan(phase.cwd);
975
1292
  // Script phases: the array form of `run` supports {steps.X}/{args.X}
976
1293
  // interpolation (the string form does NOT — it's a raw shell command,
977
1294
  // validation rejects placeholders in it), and `input` (stdin) does too.