taskflow-core 0.1.0

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 (115) hide show
  1. package/dist/agents/analyst.md +30 -0
  2. package/dist/agents/critic.md +31 -0
  3. package/dist/agents/doc-writer.md +43 -0
  4. package/dist/agents/executor-code.md +36 -0
  5. package/dist/agents/executor-fast.md +26 -0
  6. package/dist/agents/executor-ui.md +35 -0
  7. package/dist/agents/executor.md +29 -0
  8. package/dist/agents/final-arbiter.md +29 -0
  9. package/dist/agents/plan-arbiter.md +35 -0
  10. package/dist/agents/planner.md +30 -0
  11. package/dist/agents/recover.md +28 -0
  12. package/dist/agents/reviewer.md +37 -0
  13. package/dist/agents/risk-reviewer.md +37 -0
  14. package/dist/agents/scout.md +51 -0
  15. package/dist/agents/security-reviewer.md +39 -0
  16. package/dist/agents/test-engineer.md +31 -0
  17. package/dist/agents/verifier.md +29 -0
  18. package/dist/agents/visual-explorer.md +32 -0
  19. package/dist/agents.d.ts +55 -0
  20. package/dist/agents.d.ts.map +1 -0
  21. package/dist/agents.js +265 -0
  22. package/dist/agents.js.map +1 -0
  23. package/dist/cache.d.ts +57 -0
  24. package/dist/cache.d.ts.map +1 -0
  25. package/dist/cache.js +256 -0
  26. package/dist/cache.js.map +1 -0
  27. package/dist/compile.d.ts +37 -0
  28. package/dist/compile.d.ts.map +1 -0
  29. package/dist/compile.js +324 -0
  30. package/dist/compile.js.map +1 -0
  31. package/dist/context-store.d.ts +142 -0
  32. package/dist/context-store.d.ts.map +1 -0
  33. package/dist/context-store.js +383 -0
  34. package/dist/context-store.js.map +1 -0
  35. package/dist/detached-runner.d.ts +12 -0
  36. package/dist/detached-runner.d.ts.map +1 -0
  37. package/dist/detached-runner.js +68 -0
  38. package/dist/detached-runner.js.map +1 -0
  39. package/dist/flowir/hash.d.ts +51 -0
  40. package/dist/flowir/hash.d.ts.map +1 -0
  41. package/dist/flowir/hash.js +90 -0
  42. package/dist/flowir/hash.js.map +1 -0
  43. package/dist/flowir/index.d.ts +43 -0
  44. package/dist/flowir/index.d.ts.map +1 -0
  45. package/dist/flowir/index.js +62 -0
  46. package/dist/flowir/index.js.map +1 -0
  47. package/dist/flowir/meta.d.ts +103 -0
  48. package/dist/flowir/meta.d.ts.map +1 -0
  49. package/dist/flowir/meta.js +19 -0
  50. package/dist/flowir/meta.js.map +1 -0
  51. package/dist/flowir/phasefp.d.ts +55 -0
  52. package/dist/flowir/phasefp.d.ts.map +1 -0
  53. package/dist/flowir/phasefp.js +123 -0
  54. package/dist/flowir/phasefp.js.map +1 -0
  55. package/dist/flowir/translate.d.ts +37 -0
  56. package/dist/flowir/translate.d.ts.map +1 -0
  57. package/dist/flowir/translate.js +137 -0
  58. package/dist/flowir/translate.js.map +1 -0
  59. package/dist/frontmatter.d.ts +21 -0
  60. package/dist/frontmatter.d.ts.map +1 -0
  61. package/dist/frontmatter.js +107 -0
  62. package/dist/frontmatter.js.map +1 -0
  63. package/dist/host/runner-types.d.ts +98 -0
  64. package/dist/host/runner-types.d.ts.map +1 -0
  65. package/dist/host/runner-types.js +21 -0
  66. package/dist/host/runner-types.js.map +1 -0
  67. package/dist/index.d.ts +26 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +30 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/interpolate.d.ts +52 -0
  72. package/dist/interpolate.d.ts.map +1 -0
  73. package/dist/interpolate.js +418 -0
  74. package/dist/interpolate.js.map +1 -0
  75. package/dist/paths.d.ts +23 -0
  76. package/dist/paths.d.ts.map +1 -0
  77. package/dist/paths.js +43 -0
  78. package/dist/paths.js.map +1 -0
  79. package/dist/runner-core.d.ts +49 -0
  80. package/dist/runner-core.d.ts.map +1 -0
  81. package/dist/runner-core.js +208 -0
  82. package/dist/runner-core.js.map +1 -0
  83. package/dist/runtime.d.ts +234 -0
  84. package/dist/runtime.d.ts.map +1 -0
  85. package/dist/runtime.js +2284 -0
  86. package/dist/runtime.js.map +1 -0
  87. package/dist/schema.d.ts +257 -0
  88. package/dist/schema.d.ts.map +1 -0
  89. package/dist/schema.js +795 -0
  90. package/dist/schema.js.map +1 -0
  91. package/dist/stale.d.ts +72 -0
  92. package/dist/stale.d.ts.map +1 -0
  93. package/dist/stale.js +179 -0
  94. package/dist/stale.js.map +1 -0
  95. package/dist/store.d.ts +237 -0
  96. package/dist/store.d.ts.map +1 -0
  97. package/dist/store.js +761 -0
  98. package/dist/store.js.map +1 -0
  99. package/dist/typebox-helpers.d.ts +15 -0
  100. package/dist/typebox-helpers.d.ts.map +1 -0
  101. package/dist/typebox-helpers.js +19 -0
  102. package/dist/typebox-helpers.js.map +1 -0
  103. package/dist/usage.d.ts +21 -0
  104. package/dist/usage.d.ts.map +1 -0
  105. package/dist/usage.js +33 -0
  106. package/dist/usage.js.map +1 -0
  107. package/dist/verify.d.ts +38 -0
  108. package/dist/verify.d.ts.map +1 -0
  109. package/dist/verify.js +324 -0
  110. package/dist/verify.js.map +1 -0
  111. package/dist/workspace.d.ts +64 -0
  112. package/dist/workspace.d.ts.map +1 -0
  113. package/dist/workspace.js +178 -0
  114. package/dist/workspace.js.map +1 -0
  115. package/package.json +40 -0
package/dist/schema.js ADDED
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Taskflow DSL — schema, types, and validation.
3
+ *
4
+ * A taskflow is a declarative, multi-phase workflow. Each phase delegates work
5
+ * to a subagent (an isolated `pi` process). Phases form a DAG via `dependsOn`.
6
+ */
7
+ import * as path from "node:path";
8
+ import { StringEnum } from "./typebox-helpers.js";
9
+ import { Type } from "typebox";
10
+ import { WORKSPACE_KEYWORDS } from "./workspace.js";
11
+ // ---------------------------------------------------------------------------
12
+ // Phase types
13
+ // ---------------------------------------------------------------------------
14
+ const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament"];
15
+ /** Loop iteration bounds. Authors may lower the max; the hard cap is a runaway guard. */
16
+ export const LOOP_DEFAULT_MAX_ITERATIONS = 10;
17
+ export const LOOP_HARD_MAX_ITERATIONS = 100;
18
+ /** Max depth of runtime `flow { def }` sub-flow nesting (runaway guard for
19
+ * LLM-generated sub-flows that themselves spawn more sub-flows). The existing
20
+ * `_stack` recursion check guards saved-flow cycles; this bounds inline depth. */
21
+ export const MAX_DYNAMIC_NESTING = 5;
22
+ /** Breadth caps applied ONLY to runtime-generated (`flow { def }`) sub-flows,
23
+ * whose content is LLM-authored and therefore untrusted. Authored/saved flows
24
+ * are not subject to these (a human reviewed them). They bound DoS blast radius
25
+ * from a model emitting a graph with thousands of phases / a giant fan-out. */
26
+ export const MAX_DYNAMIC_PHASES = 100;
27
+ export const MAX_DYNAMIC_MAP_ITEMS = 200;
28
+ export const MAX_DYNAMIC_CONCURRENCY = 16;
29
+ /** Tournament competitor bounds. */
30
+ export const TOURNAMENT_DEFAULT_VARIANTS = 3;
31
+ export const TOURNAMENT_HARD_MAX_VARIANTS = 20;
32
+ const TOURNAMENT_MODES = ["best", "aggregate"];
33
+ const OUTPUT_FORMATS = ["text", "json"];
34
+ const JOIN_MODES = ["all", "any"];
35
+ const CACHE_SCOPES = ["run-only", "cross-run", "off"];
36
+ /** Allowed fingerprint entry prefixes. `glob!:` = content-hash variant of `glob:`. */
37
+ const CACHE_FINGERPRINT_PREFIXES = ["git:", "glob:", "glob!:", "file:", "env:"];
38
+ /** Phase types that must NOT be cached across runs (a fresh result is required each run). */
39
+ const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament"];
40
+ const ParallelTaskSchema = Type.Object({
41
+ task: Type.String({ description: "Task for this parallel branch (supports interpolation)" }),
42
+ agent: Type.Optional(Type.String({ description: "Override the phase agent for this branch" })),
43
+ }, { additionalProperties: false });
44
+ /** Declarative retry policy for a phase's subagent call(s). */
45
+ const RetrySchema = Type.Object({
46
+ max: Type.Number({ description: "Max retry attempts after the first try (>= 0)" }),
47
+ backoffMs: Type.Optional(Type.Number({ description: "Base delay between attempts, in ms", default: 0 })),
48
+ factor: Type.Optional(Type.Number({ description: "Backoff multiplier per attempt (1 = fixed, 2 = exponential)", default: 1 })),
49
+ }, { additionalProperties: false });
50
+ /**
51
+ * Per-phase cache policy. Defaults to `run-only` which is exactly the historical
52
+ * behavior (within-run resume only). `cross-run` opts a phase into the persistent
53
+ * cross-run memoization store; see docs/rfc-cross-run-memoization.md.
54
+ */
55
+ const CacheSchema = Type.Object({
56
+ scope: Type.Optional(StringEnum(CACHE_SCOPES, {
57
+ description: "Cache reuse scope. 'run-only' (default) = within-run resume only (historical behavior); 'cross-run' = reuse identical-input results from any prior run; 'off' = never reuse (even within-run).",
58
+ default: "run-only",
59
+ })),
60
+ ttl: Type.Optional(Type.String({
61
+ description: "Max cache age before a cross-run hit is treated as a miss, e.g. '30m', '6h', '7d'. Omit for no time bound.",
62
+ })),
63
+ fingerprint: Type.Optional(Type.Array(Type.String(), {
64
+ description: "Extra freshness inputs folded into the cache key so 'the world changed' becomes a cache miss. Each entry: 'git:HEAD' | 'glob:<pattern>' | 'glob!:<pattern>' (content-hash) | 'file:<path>' | 'env:<NAME>'.",
65
+ })),
66
+ }, { additionalProperties: false });
67
+ /** Run-wide cost / token ceiling. Exceeding it halts the run (remaining phases skipped). */
68
+ const BudgetSchema = Type.Object({
69
+ maxUSD: Type.Optional(Type.Number({ description: "Halt the run once accumulated cost exceeds this many USD" })),
70
+ maxTokens: Type.Optional(Type.Number({ description: "Halt the run once accumulated input+output tokens exceed this" })),
71
+ }, { additionalProperties: false });
72
+ const PhaseSchema = Type.Object({
73
+ id: Type.String({ description: "Unique phase identifier (referenced via {steps.<id>.output})" }),
74
+ type: Type.Optional(StringEnum(PHASE_TYPES, { description: "Phase kind", default: "agent" })),
75
+ agent: Type.Optional(Type.String({ description: "Agent name to run this phase" })),
76
+ task: Type.Optional(Type.String({ description: "Task prompt (supports interpolation placeholders)" })),
77
+ // map fan-out
78
+ over: Type.Optional(Type.String({ description: "[map] Interpolation ref resolving to an array to fan out over" })),
79
+ as: Type.Optional(Type.String({ description: "[map] Loop variable name (default: item)", default: "item" })),
80
+ // parallel static branches
81
+ branches: Type.Optional(Type.Array(ParallelTaskSchema, { description: "[parallel] Static task branches" })),
82
+ // reduce
83
+ from: Type.Optional(Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" })),
84
+ // sub-workflow (flow)
85
+ use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })),
86
+ def: Type.Optional(Type.Unknown({
87
+ description: "[flow] Inline sub-flow definition, resolved at runtime. Mutually exclusive with 'use'. A string is interpolated (e.g. '{steps.plan.json}') then JSON-parsed; an object is used directly. The result must be a Taskflow ({name,phases}) or a bare phases array / {phases:[...]} (auto-wrapped). Validated + verified before execution; on any failure the phase fails-open (defError) without aborting the run.",
88
+ })),
89
+ with: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
90
+ description: "[flow] Args passed to the sub-flow (string values support interpolation)",
91
+ })),
92
+ // loop-until-done
93
+ until: Type.Optional(Type.String({
94
+ 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).",
95
+ })),
96
+ maxIterations: Type.Optional(Type.Number({
97
+ description: `[loop] Hard cap on iterations (default ${LOOP_DEFAULT_MAX_ITERATIONS}, max ${LOOP_HARD_MAX_ITERATIONS}). The loop always terminates within this bound even if 'until' never becomes truthy.`,
98
+ default: LOOP_DEFAULT_MAX_ITERATIONS,
99
+ })),
100
+ convergence: Type.Optional(Type.Boolean({
101
+ description: "[loop] When true (default), stop early if an iteration's output is identical to the previous one (a fixed point — further iterations would not change anything).",
102
+ default: true,
103
+ })),
104
+ // tournament: N variants compete, a judge picks the best (or aggregates)
105
+ variants: Type.Optional(Type.Number({
106
+ description: `[tournament] Number of competing variants to spawn from 'task' (default ${TOURNAMENT_DEFAULT_VARIANTS}, max ${TOURNAMENT_HARD_MAX_VARIANTS}). Ignored when 'branches' is provided (those become the variants instead).`,
107
+ default: TOURNAMENT_DEFAULT_VARIANTS,
108
+ })),
109
+ judge: Type.Optional(Type.String({
110
+ description: "[tournament] Judge prompt. The numbered variant outputs are injected before it. To pick a winner, end with a line like 'WINNER: <n>' or return JSON {\"winner\": <n>}. Defaults to a sensible built-in rubric.",
111
+ })),
112
+ judgeAgent: Type.Optional(Type.String({ description: "[tournament] Agent that runs the judge step (defaults to the phase 'agent')." })),
113
+ mode: Type.Optional(StringEnum(TOURNAMENT_MODES, {
114
+ description: "[tournament] 'best' (default): output is the winning variant verbatim. 'aggregate': output is the judge's synthesized answer combining the variants.",
115
+ default: "best",
116
+ })),
117
+ dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
118
+ join: Type.Optional(StringEnum(JOIN_MODES, {
119
+ description: "Dependency join: 'all' (default) waits for every dep; 'any' runs as soon as one dep completes",
120
+ default: "all",
121
+ })),
122
+ when: Type.Optional(Type.String({
123
+ description: "Conditional guard: skip this phase unless the expression is truthy. Supports {refs} and == != < > <= >= && || ! ()",
124
+ })),
125
+ retry: Type.Optional(RetrySchema),
126
+ output: Type.Optional(StringEnum(OUTPUT_FORMATS, { description: "Parse output as text or json", default: "text" })),
127
+ model: Type.Optional(Type.String({ description: "Model override for this phase" })),
128
+ thinking: Type.Optional(Type.String({ description: "Thinking level override for this phase" })),
129
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Restrict tools for this phase's agent" })),
130
+ 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)." })),
131
+ final: Type.Optional(Type.Boolean({ description: "Mark this phase's output as the workflow result" })),
132
+ optional: Type.Optional(Type.Boolean({ description: "If true, a failure does not abort the run", default: false })),
133
+ concurrency: Type.Optional(Type.Number({ description: "Override max concurrency for map/parallel" })),
134
+ context: Type.Optional(Type.Array(Type.String(), {
135
+ description: "File paths or {steps.X} refs to pre-read and inject before the task. Resolves interpolated refs first, then reads each file (capped per-file). Eliminates O(N²) turn-cost exploration.",
136
+ })),
137
+ contextLimit: Type.Optional(Type.Number({
138
+ description: "Max characters to read per file referenced in context (default 8000).",
139
+ default: 8000,
140
+ })),
141
+ onBlock: Type.Optional(StringEnum(["halt", "retry"], {
142
+ description: "[gate] What to do when the gate blocks: 'halt' (default, stop the flow) or 'retry' (re-run upstream phases then re-evaluate the gate). Limited by 'retry.max'.",
143
+ default: "halt",
144
+ })),
145
+ eval: Type.Optional(Type.Array(Type.String(), {
146
+ description: "[gate] Zero-token machine checks that run BEFORE the LLM gate. If ALL pass, the gate is skipped (PASS). If ANY fail, the LLM gate runs as normal. Each entry is a condition expression like '{steps.x.output} contains PASS' or '{steps.x.json.score} >= 0.8'. Supports same operators as 'when' plus 'contains' for substring checks.",
147
+ })),
148
+ cache: Type.Optional(CacheSchema),
149
+ shareContext: Type.Optional(Type.Boolean({
150
+ 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.",
151
+ })),
152
+ }, { additionalProperties: false });
153
+ const ArgSpecSchema = Type.Object({
154
+ default: Type.Optional(Type.Unknown()),
155
+ description: Type.Optional(Type.String()),
156
+ required: Type.Optional(Type.Boolean()),
157
+ }, { additionalProperties: false });
158
+ export const TaskflowSchema = Type.Object({
159
+ name: Type.String({ minLength: 1, description: "Workflow name (becomes /tf:<name> command when saved)" }),
160
+ description: Type.Optional(Type.String()),
161
+ version: Type.Optional(Type.Number({ default: 1 })),
162
+ args: Type.Optional(Type.Record(Type.String(), ArgSpecSchema, { description: "Declared invocation arguments" })),
163
+ concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents", default: 8 })),
164
+ budget: Type.Optional(BudgetSchema),
165
+ agentScope: Type.Optional(StringEnum(["user", "project", "both"], { description: "Agent discovery scope", default: "user" })),
166
+ strictInterpolation: Type.Optional(Type.Boolean({
167
+ description: "When true, unresolved interpolation placeholders and validation warnings about missing deps/args become hard errors",
168
+ default: false,
169
+ })),
170
+ contextSharing: Type.Optional(Type.Boolean({
171
+ description: "Enable the Shared Context Tree for ALL phases in this flow (shorthand for setting shareContext on every phase). Default false.",
172
+ })),
173
+ incremental: Type.Optional(Type.Boolean({
174
+ 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.",
175
+ })),
176
+ phases: Type.Array(PhaseSchema, { minItems: 1, description: "Ordered phase definitions (DAG via dependsOn)" }),
177
+ }, { additionalProperties: false });
178
+ /** True when `def` is a shorthand spec (no `phases`, but a task/tasks/chain field). */
179
+ export function isShorthand(def) {
180
+ if (typeof def !== "object" || def === null)
181
+ return false;
182
+ const d = def;
183
+ if (Array.isArray(d.phases))
184
+ return false;
185
+ return ((Array.isArray(d.chain) && d.chain.length > 0) ||
186
+ (Array.isArray(d.tasks) && d.tasks.length > 0) ||
187
+ typeof d.task === "string");
188
+ }
189
+ /** Coerce an unknown value into a non-empty list of non-empty strings (or undefined). */
190
+ function readContextList(v) {
191
+ if (!Array.isArray(v))
192
+ return undefined;
193
+ const list = v.filter((x) => typeof x === "string" && x.trim().length > 0);
194
+ return list.length ? list : undefined;
195
+ }
196
+ function readStep(s) {
197
+ if (typeof s === "string")
198
+ return { task: s };
199
+ if (s && typeof s === "object") {
200
+ const o = s;
201
+ const step = { agent: typeof o.agent === "string" ? o.agent : undefined, task: String(o.task ?? "") };
202
+ const ctx = readContextList(o.context);
203
+ if (ctx)
204
+ step.context = ctx;
205
+ if (typeof o.contextLimit === "number")
206
+ step.contextLimit = o.contextLimit;
207
+ return step;
208
+ }
209
+ return { task: "" };
210
+ }
211
+ /**
212
+ * Desugar a shorthand spec into a full Taskflow DAG. Throws if no recognizable
213
+ * shorthand field is present. Carries through optional name/description/
214
+ * concurrency/agentScope/args.
215
+ */
216
+ export function desugar(def) {
217
+ if (typeof def !== "object" || def === null)
218
+ throw new Error("Shorthand spec must be an object");
219
+ const d = def;
220
+ const meta = {};
221
+ if (typeof d.description === "string")
222
+ meta.description = d.description;
223
+ if (typeof d.concurrency === "number")
224
+ meta.concurrency = d.concurrency;
225
+ if (d.agentScope === "user" || d.agentScope === "project" || d.agentScope === "both")
226
+ meta.agentScope = d.agentScope;
227
+ if (d.args && typeof d.args === "object")
228
+ meta.args = d.args;
229
+ if (d.budget)
230
+ meta.budget = d.budget;
231
+ if (typeof d.strictInterpolation === "boolean")
232
+ meta.strictInterpolation = d.strictInterpolation;
233
+ const nameOf = (fallback) => (typeof d.name === "string" && d.name.trim() ? d.name.trim() : fallback);
234
+ // chain → sequential agent phases
235
+ if (Array.isArray(d.chain) && d.chain.length > 0) {
236
+ // Spec-level context in chain mode would be a flow-level default (every
237
+ // step), which is deliberately NOT supported — declare it per step instead.
238
+ if (d.context !== undefined || d.contextLimit !== undefined) {
239
+ console.warn("[taskflow] Shorthand chain ignores top-level 'context'/'contextLimit' — put them on individual steps instead.");
240
+ }
241
+ const steps = d.chain.map(readStep);
242
+ const phases = steps.map((s, i) => {
243
+ const phase = { id: `step${i + 1}`, type: "agent", task: s.task };
244
+ if (s.agent)
245
+ phase.agent = s.agent;
246
+ if (s.context)
247
+ phase.context = s.context;
248
+ if (s.contextLimit !== undefined)
249
+ phase.contextLimit = s.contextLimit;
250
+ if (i > 0)
251
+ phase.dependsOn = [`step${i}`];
252
+ if (i === steps.length - 1)
253
+ phase.final = true;
254
+ return phase;
255
+ });
256
+ return { name: nameOf("chain"), ...meta, phases };
257
+ }
258
+ // tasks → one parallel phase (fan-out + merge), no extra aggregation agent.
259
+ // Context is SHARED across all branches (the runtime pre-reads per phase, not
260
+ // per branch): spec-level context plus the union of step-level contexts.
261
+ if (Array.isArray(d.tasks) && d.tasks.length > 0) {
262
+ const steps = d.tasks.map(readStep);
263
+ const branches = steps.map((s) => (s.agent ? { task: s.task, agent: s.agent } : { task: s.task }));
264
+ const phase = { id: "parallel", type: "parallel", branches, final: true };
265
+ const shared = [...(readContextList(d.context) ?? []), ...steps.flatMap((s) => s.context ?? [])];
266
+ if (shared.length)
267
+ phase.context = Array.from(new Set(shared));
268
+ const limits = [
269
+ typeof d.contextLimit === "number" ? d.contextLimit : undefined,
270
+ ...steps.map((s) => s.contextLimit),
271
+ ].filter((n) => typeof n === "number");
272
+ if (limits.length)
273
+ phase.contextLimit = Math.max(...limits);
274
+ return { name: nameOf("parallel"), ...meta, phases: [phase] };
275
+ }
276
+ // single task → one agent phase (the spec itself is the step)
277
+ if (typeof d.task === "string") {
278
+ const phase = { id: "main", type: "agent", task: d.task, final: true };
279
+ if (typeof d.agent === "string")
280
+ phase.agent = d.agent;
281
+ const ctx = readContextList(d.context);
282
+ if (ctx)
283
+ phase.context = ctx;
284
+ if (typeof d.contextLimit === "number")
285
+ phase.contextLimit = d.contextLimit;
286
+ return { name: nameOf("task"), ...meta, phases: [phase] };
287
+ }
288
+ throw new Error("Shorthand spec needs one of: 'task' (single), 'tasks' (parallel), or 'chain' (sequential)");
289
+ }
290
+ /**
291
+ * Parse a TTL string like '30m', '6h', '7d', '500ms', '90s' into milliseconds.
292
+ * Returns null for malformed or non-positive values. Plain integers = ms.
293
+ */
294
+ export function parseTtlMs(ttl) {
295
+ if (typeof ttl !== "string")
296
+ return null;
297
+ const m = ttl.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
298
+ if (!m)
299
+ return null;
300
+ const n = Number(m[1]);
301
+ if (!Number.isFinite(n) || n <= 0)
302
+ return null;
303
+ const unit = (m[2] ?? "ms").toLowerCase();
304
+ const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
305
+ return n * mult[unit];
306
+ }
307
+ export function validateTaskflow(def, opts = {}) {
308
+ const errors = [];
309
+ const warnings = [];
310
+ if (typeof def !== "object" || def === null) {
311
+ return { ok: false, errors: ["Taskflow must be an object"], warnings };
312
+ }
313
+ const flow = def;
314
+ const strict = opts.strict ?? flow.strictInterpolation === true;
315
+ if (!flow.name || typeof flow.name !== "string")
316
+ errors.push("Missing or invalid 'name'");
317
+ if (!Array.isArray(flow.phases) || flow.phases.length === 0) {
318
+ errors.push("Taskflow must have at least one phase");
319
+ return { ok: false, errors, warnings };
320
+ }
321
+ // Hardening for runtime-generated (untrusted) sub-flows: bound breadth and
322
+ // contain filesystem access. These do NOT apply to authored/saved flows.
323
+ if (opts.dynamic) {
324
+ if (flow.phases.length > MAX_DYNAMIC_PHASES) {
325
+ errors.push(`Dynamic sub-flow has too many phases (${flow.phases.length}, max ${MAX_DYNAMIC_PHASES})`);
326
+ }
327
+ if (typeof flow.concurrency === "number" && flow.concurrency > MAX_DYNAMIC_CONCURRENCY) {
328
+ errors.push(`Dynamic sub-flow concurrency too high (${flow.concurrency}, max ${MAX_DYNAMIC_CONCURRENCY})`);
329
+ }
330
+ const root = opts.cwd ? path.resolve(opts.cwd) : undefined;
331
+ for (const p of flow.phases) {
332
+ if (!p || typeof p !== "object")
333
+ continue;
334
+ // Per-phase concurrency override is also capped.
335
+ if (typeof p.concurrency === "number" && p.concurrency > MAX_DYNAMIC_CONCURRENCY) {
336
+ errors.push(`Dynamic sub-flow phase '${p.id}': concurrency too high (${p.concurrency}, max ${MAX_DYNAMIC_CONCURRENCY})`);
337
+ }
338
+ // cwd containment: a generated phase may not escape the run's cwd, and
339
+ // may not request a reserved workspace keyword (temp/dedicated/worktree)
340
+ // — LLM-authored sub-flows must not allocate isolated dirs or git
341
+ // worktrees that mutate the repo. Only author-written flows may.
342
+ if (typeof p.cwd === "string") {
343
+ if (WORKSPACE_KEYWORDS.includes(p.cwd)) {
344
+ errors.push(`Dynamic sub-flow phase '${p.id}': cwd '${p.cwd}' is a reserved workspace keyword not allowed in generated flows`);
345
+ }
346
+ else if (root) {
347
+ const resolved = path.resolve(root, p.cwd);
348
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
349
+ errors.push(`Dynamic sub-flow phase '${p.id}': cwd '${p.cwd}' escapes the run directory`);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ const ids = new Set();
356
+ for (const p of flow.phases) {
357
+ if (!p || typeof p !== "object") {
358
+ errors.push("Each phase must be an object");
359
+ continue;
360
+ }
361
+ if (!p.id) {
362
+ errors.push("Each phase requires an 'id'");
363
+ continue;
364
+ }
365
+ if (ids.has(p.id))
366
+ errors.push(`Duplicate phase id: ${p.id}`);
367
+ ids.add(p.id);
368
+ // When a phase opts into the Shared Context Tree, its id becomes a filesystem
369
+ // node id; restrict the charset so two ids can't sanitize to the same node
370
+ // (which would silently merge their blackboards). Non-sharing phases are
371
+ // unaffected (full backward compat).
372
+ if ((p.shareContext === true || flow.contextSharing === true) && !/^[A-Za-z0-9._-]+$/.test(p.id)) {
373
+ errors.push(`Phase '${p.id}': ids used with context sharing must match [A-Za-z0-9._-]+`);
374
+ }
375
+ const type = (p.type ?? "agent");
376
+ if (!PHASE_TYPES.includes(type))
377
+ errors.push(`Phase '${p.id}': unknown type '${type}'`);
378
+ // Per-type requirements
379
+ if (type === "agent" || type === "gate") {
380
+ if (!p.task)
381
+ errors.push(`Phase '${p.id}' (${type}) requires 'task'`);
382
+ }
383
+ if (type === "map") {
384
+ if (!p.over)
385
+ errors.push(`Phase '${p.id}' (map) requires 'over'`);
386
+ if (!p.task)
387
+ errors.push(`Phase '${p.id}' (map) requires 'task'`);
388
+ }
389
+ if (type === "parallel") {
390
+ if (!p.branches || p.branches.length === 0)
391
+ errors.push(`Phase '${p.id}' (parallel) requires non-empty 'branches'`);
392
+ }
393
+ if (type === "reduce") {
394
+ if (!p.from || p.from.length === 0)
395
+ errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
396
+ if (!p.task)
397
+ errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
398
+ }
399
+ if (type === "flow") {
400
+ const hasUse = typeof p.use === "string" && p.use.length > 0;
401
+ const hasDef = p.def !== undefined;
402
+ if (!hasUse && !hasDef) {
403
+ errors.push(`Phase '${p.id}' (flow) requires 'use' (a saved flow name) or 'def' (an inline definition)`);
404
+ }
405
+ else if (hasUse && hasDef) {
406
+ errors.push(`Phase '${p.id}' (flow): 'use' and 'def' are mutually exclusive — provide exactly one`);
407
+ }
408
+ }
409
+ if (type === "loop") {
410
+ if (!p.task)
411
+ errors.push(`Phase '${p.id}' (loop) requires 'task' (the iteration body)`);
412
+ if (!p.until)
413
+ errors.push(`Phase '${p.id}' (loop) requires 'until' (the stop condition)`);
414
+ if (p.maxIterations !== undefined) {
415
+ if (typeof p.maxIterations !== "number" || !Number.isFinite(p.maxIterations) || p.maxIterations < 1) {
416
+ errors.push(`Phase '${p.id}' (loop): maxIterations must be a number >= 1`);
417
+ }
418
+ else if (p.maxIterations > LOOP_HARD_MAX_ITERATIONS) {
419
+ errors.push(`Phase '${p.id}' (loop): maxIterations must be <= ${LOOP_HARD_MAX_ITERATIONS}`);
420
+ }
421
+ }
422
+ }
423
+ if (type === "tournament") {
424
+ const hasBranches = Array.isArray(p.branches) && p.branches.length > 0;
425
+ if (!hasBranches && !p.task) {
426
+ errors.push(`Phase '${p.id}' (tournament) requires 'task' (the competitor prompt) or non-empty 'branches'`);
427
+ }
428
+ if (p.variants !== undefined) {
429
+ if (typeof p.variants !== "number" || !Number.isFinite(p.variants) || p.variants < 2) {
430
+ errors.push(`Phase '${p.id}' (tournament): variants must be a number >= 2`);
431
+ }
432
+ else if (p.variants > TOURNAMENT_HARD_MAX_VARIANTS) {
433
+ errors.push(`Phase '${p.id}' (tournament): variants must be <= ${TOURNAMENT_HARD_MAX_VARIANTS}`);
434
+ }
435
+ }
436
+ if (hasBranches && p.branches.length < 2) {
437
+ errors.push(`Phase '${p.id}' (tournament): 'branches' needs at least 2 competitors`);
438
+ }
439
+ if (p.mode && !TOURNAMENT_MODES.includes(p.mode)) {
440
+ errors.push(`Phase '${p.id}' (tournament): unknown mode '${p.mode}'`);
441
+ }
442
+ }
443
+ if (p.retry) {
444
+ if (typeof p.retry.max !== "number" || p.retry.max < 0) {
445
+ errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
446
+ }
447
+ else if (p.retry.max > 20) {
448
+ errors.push(`Phase '${p.id}': retry.max must be <= 20`);
449
+ }
450
+ if (p.retry.backoffMs !== undefined && (p.retry.backoffMs < 0 || p.retry.backoffMs > 60000)) {
451
+ errors.push(`Phase '${p.id}': retry.backoffMs must be between 0 and 60000`);
452
+ }
453
+ if (p.retry.factor !== undefined && (p.retry.factor < 1 || p.retry.factor > 10)) {
454
+ errors.push(`Phase '${p.id}': retry.factor must be between 1 and 10`);
455
+ }
456
+ }
457
+ if (p.join && !JOIN_MODES.includes(p.join)) {
458
+ errors.push(`Phase '${p.id}': unknown join mode '${p.join}'`);
459
+ }
460
+ // Cache policy validation (cross-run memoization).
461
+ if (p.cache) {
462
+ const scope = p.cache.scope ?? "run-only";
463
+ if (!CACHE_SCOPES.includes(scope)) {
464
+ errors.push(`Phase '${p.id}': unknown cache.scope '${scope}' (expected one of ${CACHE_SCOPES.join(", ")})`);
465
+ }
466
+ // Gate B: gate/approval phases must produce a fresh result every run.
467
+ if (scope === "cross-run" && CACHE_CROSS_RUN_BLOCKED_TYPES.includes(type)) {
468
+ errors.push(`Phase '${p.id}' (${type}): cache.scope 'cross-run' is not allowed for ${CACHE_CROSS_RUN_BLOCKED_TYPES.join("/")} phases — they must produce a fresh result each run. Use 'run-only'.`);
469
+ }
470
+ // Gate C: every fingerprint entry must use a known prefix (fail closed).
471
+ for (const fp of p.cache.fingerprint ?? []) {
472
+ const ok = CACHE_FINGERPRINT_PREFIXES.some((pre) => fp.startsWith(pre) && fp.length > pre.length);
473
+ if (!ok) {
474
+ errors.push(`Phase '${p.id}': invalid cache.fingerprint entry '${fp}' (expected '<prefix><value>' with prefix one of ${CACHE_FINGERPRINT_PREFIXES.join(", ")})`);
475
+ }
476
+ }
477
+ // Gate D: TTL must parse to a positive duration when present.
478
+ if (p.cache.ttl !== undefined && parseTtlMs(p.cache.ttl) === null) {
479
+ errors.push(`Phase '${p.id}': invalid cache.ttl '${p.cache.ttl}' (expected e.g. '30m', '6h', '7d')`);
480
+ }
481
+ }
482
+ // Agent name convention: hyphens only (per AGENTS.md naming convention)
483
+ if (p.agent && typeof p.agent === "string" && p.agent.includes("_")) {
484
+ errors.push(`Phase '${p.id}': agent name '${p.agent}' uses underscores — use hyphens (e.g. 'executor-code' not 'executor_code')`);
485
+ }
486
+ // Phase id convention: hyphens only (consistent with interpolation placeholders like {steps.audit-each.output})
487
+ if (p.id && p.id.includes("_")) {
488
+ errors.push(`Phase '${p.id}': id uses underscores — use hyphens for consistency with interpolation placeholders (e.g. {steps.audit-each.output})`);
489
+ }
490
+ }
491
+ // dependsOn / from references must exist
492
+ for (const p of flow.phases) {
493
+ if (!p?.id)
494
+ continue;
495
+ for (const dep of p.dependsOn ?? []) {
496
+ if (!ids.has(dep))
497
+ errors.push(`Phase '${p.id}': dependsOn unknown phase '${dep}'`);
498
+ }
499
+ for (const f of p.from ?? []) {
500
+ if (!ids.has(f))
501
+ errors.push(`Phase '${p.id}': from unknown phase '${f}'`);
502
+ }
503
+ }
504
+ // Agent name format validation (AGENTS.md naming convention: hyphens only, no underscores)
505
+ const VALID_AGENT_RE = /^[a-z][a-z0-9-]*$/;
506
+ for (const p of flow.phases) {
507
+ if (!p?.id)
508
+ continue;
509
+ if (p.agent && !p.agent.includes("_") && !VALID_AGENT_RE.test(p.agent)) {
510
+ errors.push(`Phase '${p.id}': agent '${p.agent}' has invalid name format (expected lowercase alphanumeric with hyphens)`);
511
+ }
512
+ }
513
+ // Cycle detection (Kahn)
514
+ if (errors.length === 0) {
515
+ const cycle = detectCycle(flow.phases);
516
+ if (cycle)
517
+ errors.push(`Dependency cycle detected: ${cycle.join(" -> ")}`);
518
+ }
519
+ // Exactly handle final-phase resolution lazily (0 finals => last phase is final)
520
+ const finals = flow.phases.filter((p) => p?.final);
521
+ if (finals.length > 1)
522
+ errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
523
+ // --- Hard errors: {steps.X.*} references that aren't declared deps ------
524
+ // Catches the most common authoring mistake: the task talks about
525
+ // `{steps.review.output}` but `dependsOn: ["review"]` is missing, so the
526
+ // phase runs in parallel with `review` and the model sees the literal
527
+ // placeholder string. The runtime can't infer the intent — fail fast at
528
+ // validation time so the mistake is caught before the run starts.
529
+ //
530
+ // The check uses TRANSITIVE ancestors: if phase B depends on A, and C depends
531
+ // on B, then C may reference {steps.A.*} transitively. Only truly unreachable
532
+ // refs are errors.
533
+ //
534
+ // Phases with `join: "any"` are exempt: by design they only need ONE of
535
+ // their declared deps to complete, and may reference other phases as
536
+ // informational context (not as true dependencies).
537
+ if (errors.length === 0) {
538
+ const idToPhase = new Map(flow.phases.map((p) => [p.id, p]));
539
+ // Precompute transitive ancestors for every phase via BFS over dependsOn.
540
+ const transitiveCache = new Map();
541
+ const transitiveAncestors = (phaseId) => {
542
+ const cached = transitiveCache.get(phaseId);
543
+ if (cached)
544
+ return cached;
545
+ const result = new Set();
546
+ const queue = [...(idToPhase.get(phaseId)?.dependsOn ?? []), ...(idToPhase.get(phaseId)?.from ?? [])];
547
+ while (queue.length) {
548
+ const id = queue.shift();
549
+ if (result.has(id))
550
+ continue;
551
+ result.add(id);
552
+ const dep = idToPhase.get(id);
553
+ if (dep) {
554
+ for (const d of [...(dep.dependsOn ?? []), ...(dep.from ?? [])]) {
555
+ if (!result.has(d))
556
+ queue.push(d);
557
+ }
558
+ }
559
+ }
560
+ transitiveCache.set(phaseId, result);
561
+ return result;
562
+ };
563
+ for (const p of flow.phases) {
564
+ if (!p?.id)
565
+ continue;
566
+ const isJoinAny = p.join === "any";
567
+ if (isJoinAny)
568
+ continue;
569
+ const transitive = transitiveAncestors(p.id);
570
+ const refs = collectRefs(p);
571
+ for (const ref of refs.steps) {
572
+ if (ref === p.id) {
573
+ errors.push(`Phase '${p.id}': references its own output via {steps.${ref}.*}; this is almost always a bug.`);
574
+ continue;
575
+ }
576
+ if (!idToPhase.has(ref)) {
577
+ // Unknown ref is already an error from the dependsOn check, but
578
+ // {steps.X.*} can appear in a task without dependsOn. Don't
579
+ // double-warn — the dependsOn loop above already flags it.
580
+ continue;
581
+ }
582
+ if (!transitive.has(ref)) {
583
+ errors.push(`Phase '${p.id}': task references {steps.${ref}.*} but '${ref}' is not reachable via dependsOn. ` +
584
+ `The phase will run in parallel with '${ref}' and see the literal placeholder. ` +
585
+ `Add "dependsOn": ["${ref}"] (or include '${ref}' transitively).`);
586
+ }
587
+ }
588
+ }
589
+ }
590
+ // --- Runtime/invocation warnings: missing args + cwd/codebase mismatch -----
591
+ if (errors.length === 0 && opts.args) {
592
+ const argRefs = new Set();
593
+ for (const p of flow.phases) {
594
+ if (!p?.id)
595
+ continue;
596
+ for (const ref of collectRefs(p).args)
597
+ argRefs.add(ref);
598
+ }
599
+ for (const ref of argRefs) {
600
+ if (!(ref in opts.args)) {
601
+ warnings.push(`Taskflow references {args.${ref}} but the invocation did not provide '${ref}'. ` +
602
+ `The placeholder will remain literal unless a default or runtime arg is supplied.`);
603
+ }
604
+ }
605
+ if (opts.cwd && typeof opts.args.codebase === "string" && opts.args.codebase.trim()) {
606
+ const cwd = path.resolve(opts.cwd);
607
+ const codebase = path.resolve(cwd, opts.args.codebase);
608
+ // Safe case: cwd is the codebase root or a subdirectory within it.
609
+ // Warn when cwd is a sibling, unrelated path, or a parent of the
610
+ // codebase (agents that rely on cwd would inspect too broad a tree).
611
+ if (!pathContains(codebase, cwd)) {
612
+ warnings.push(`Invocation cwd '${cwd}' does not match args.codebase '${codebase}'. ` +
613
+ `Some agents may inspect the wrong repo if they rely on cwd. Prefer running from the codebase root or set phase.cwd explicitly.`);
614
+ }
615
+ }
616
+ }
617
+ if (strict && warnings.length) {
618
+ errors.push(...warnings.map((w) => `Strict interpolation: ${w}`));
619
+ }
620
+ return { ok: errors.length === 0, errors, warnings };
621
+ }
622
+ export function collectRefs(phase) {
623
+ const steps = new Set();
624
+ const args = new Set();
625
+ const scan = (s) => {
626
+ if (!s)
627
+ return;
628
+ let m;
629
+ const stepRe = /\{steps\.([a-zA-Z0-9_-]+)/g;
630
+ while ((m = stepRe.exec(s)) !== null)
631
+ steps.add(m[1]);
632
+ const argRe = /\{args\.([a-zA-Z0-9_-]+)/g;
633
+ while ((m = argRe.exec(s)) !== null)
634
+ args.add(m[1]);
635
+ };
636
+ scan(phase.task);
637
+ scan(phase.over);
638
+ scan(phase.when);
639
+ scan(phase.until);
640
+ for (const e of phase.eval ?? [])
641
+ scan(e);
642
+ for (const b of phase.branches ?? [])
643
+ scan(b.task);
644
+ for (const v of Object.values(phase.with ?? {}))
645
+ if (typeof v === "string")
646
+ scan(v);
647
+ for (const c of phase.context ?? [])
648
+ scan(c);
649
+ return { steps: Array.from(steps), args: Array.from(args) };
650
+ }
651
+ function pathContains(parent, child) {
652
+ const rel = path.relative(parent, child);
653
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
654
+ }
655
+ /** Returns a cycle path if the DAG has one, else null. */
656
+ function detectCycle(phases) {
657
+ const deps = new Map();
658
+ for (const p of phases)
659
+ deps.set(p.id, dependenciesOf(p));
660
+ const WHITE = 0;
661
+ const GRAY = 1;
662
+ const BLACK = 2;
663
+ const color = new Map();
664
+ for (const p of phases)
665
+ color.set(p.id, WHITE);
666
+ const stack = [];
667
+ const visit = (id) => {
668
+ color.set(id, GRAY);
669
+ stack.push(id);
670
+ for (const d of deps.get(id) ?? []) {
671
+ if (!deps.has(d))
672
+ continue;
673
+ const c = color.get(d);
674
+ if (c === GRAY) {
675
+ const start = stack.indexOf(d);
676
+ return [...stack.slice(start), d];
677
+ }
678
+ if (c === WHITE) {
679
+ const found = visit(d);
680
+ if (found)
681
+ return found;
682
+ }
683
+ }
684
+ color.set(id, BLACK);
685
+ stack.pop();
686
+ return null;
687
+ };
688
+ for (const p of phases) {
689
+ if (color.get(p.id) === WHITE) {
690
+ const found = visit(p.id);
691
+ if (found)
692
+ return found;
693
+ }
694
+ }
695
+ return null;
696
+ }
697
+ /** Effective dependency ids of a phase (dependsOn ∪ from). */
698
+ export function dependenciesOf(phase) {
699
+ const set = new Set([...(phase.dependsOn ?? []), ...(phase.from ?? [])]);
700
+ return Array.from(set);
701
+ }
702
+ /**
703
+ * Transitive upstream dependency closure of a phase: every id reachable via
704
+ * `dependsOn ∪ from`, including indirect ancestors. Cycle-safe (visited set).
705
+ * Returns the closure EXCLUDING `phaseId` itself. Sorted for deterministic
706
+ * hashing. Shares the exact edge semantics with `topoLayers`/`detectCycle` so
707
+ * the closure is complete for every valid flow (validation already rejects
708
+ * `{steps.X}` refs that aren't reachable via these edges, except for
709
+ * `join: "any"` phases — handled by callers as needed).
710
+ *
711
+ * Hoisted out of `validateTaskflow` so `phaseFingerprint` (M6) and validation
712
+ * share one source of truth for "what does this phase structurally depend on".
713
+ */
714
+ export function transitiveDependencies(phases, phaseId) {
715
+ const byId = new Map(phases.map((p) => [p.id, p]));
716
+ const seen = new Set();
717
+ const queue = [];
718
+ const seed = byId.get(phaseId);
719
+ if (seed)
720
+ for (const d of dependenciesOf(seed))
721
+ queue.push(d);
722
+ while (queue.length) {
723
+ const id = queue.shift();
724
+ if (seen.has(id))
725
+ continue;
726
+ if (!byId.has(id))
727
+ continue; // unknown dep — validation reports elsewhere
728
+ seen.add(id);
729
+ const dep = byId.get(id);
730
+ for (const d of dependenciesOf(dep)) {
731
+ if (!seen.has(d))
732
+ queue.push(d);
733
+ }
734
+ }
735
+ return Array.from(seen).sort();
736
+ }
737
+ /** Topologically ordered layers; phases in the same layer can run concurrently. */
738
+ export function topoLayers(phases) {
739
+ const byId = new Map(phases.map((p) => [p.id, p]));
740
+ const indeg = new Map();
741
+ const dependents = new Map();
742
+ for (const p of phases) {
743
+ indeg.set(p.id, 0);
744
+ dependents.set(p.id, []);
745
+ }
746
+ for (const p of phases) {
747
+ for (const d of dependenciesOf(p)) {
748
+ if (!byId.has(d))
749
+ continue;
750
+ indeg.set(p.id, (indeg.get(p.id) ?? 0) + 1);
751
+ dependents.get(d).push(p.id);
752
+ }
753
+ }
754
+ const layers = [];
755
+ let frontier = phases.filter((p) => (indeg.get(p.id) ?? 0) === 0);
756
+ const seen = new Set();
757
+ while (frontier.length > 0) {
758
+ layers.push(frontier);
759
+ const next = [];
760
+ for (const p of frontier) {
761
+ seen.add(p.id);
762
+ for (const dep of dependents.get(p.id) ?? []) {
763
+ indeg.set(dep, (indeg.get(dep) ?? 0) - 1);
764
+ if ((indeg.get(dep) ?? 0) === 0 && !seen.has(dep))
765
+ next.push(byId.get(dep));
766
+ }
767
+ }
768
+ frontier = next;
769
+ }
770
+ return layers;
771
+ }
772
+ /** Resolve which phase is the result-bearing phase. */
773
+ export function finalPhase(phases) {
774
+ return phases.find((p) => p.final) ?? phases[phases.length - 1];
775
+ }
776
+ /**
777
+ * Apply a flow's declared arg defaults over the provided values, then pass
778
+ * through any extra provided keys. Shared by the tool entrypoint (index) and the
779
+ * sub-flow (`flow`) phase (runtime).
780
+ */
781
+ export function resolveArgs(def, provided) {
782
+ const args = {};
783
+ for (const [key, spec] of Object.entries(def.args ?? {})) {
784
+ if (provided && key in provided)
785
+ args[key] = provided[key];
786
+ else if (spec.default !== undefined)
787
+ args[key] = spec.default;
788
+ }
789
+ if (provided)
790
+ for (const [k, v] of Object.entries(provided))
791
+ if (!(k in args))
792
+ args[k] = v;
793
+ return args;
794
+ }
795
+ //# sourceMappingURL=schema.js.map