akm-cli 0.9.0 → 0.9.1-beta.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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -10,14 +10,16 @@ import { resolveLlmEngineUse, } from "../../integrations/agent/engine-resolution
10
10
  import { resolveLlmModel, resolveModel } from "../../integrations/agent/model-aliases.js";
11
11
  import { getBuiltinAgentProfile } from "../../integrations/agent/profiles.js";
12
12
  import { HARNESS_BY_ID } from "../../integrations/harnesses/index.js";
13
- import { workflowMaxConcurrency } from "../concurrency-policy.js";
13
+ import { defaultLlmEngineConcurrency, defaultMapConcurrency, workflowMaxConcurrency } from "../concurrency-policy.js";
14
+ import { projectExecCore } from "../program/schema.js";
15
+ import { DEFAULT_EXEC_TIMEOUT_MS } from "../resource-limits.js";
14
16
  import { compileWorkflowPlan } from "./compile.js";
15
17
  import { decodeWorkflowPlanV3, WORKFLOW_IR_VERSION } from "./schema.js";
16
18
  /**
17
19
  * The only source-to-runtime boundary. Source compilation remains pure; engine
18
20
  * selection and every dispatch-significant setting are resolved here once.
19
21
  */
20
- export function compileResolveFreezeWorkflow(asset, inputConfig) {
22
+ export function compileResolveFreezeWorkflow(asset, inputConfig, options = {}) {
21
23
  // Applied ONCE, before any resolution: every engine lookup below (selection,
22
24
  // snapshots, the gate judge) then sees one config and needs no fallback
23
25
  // awareness of its own.
@@ -26,11 +28,19 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
26
28
  // `defaults.engine` is the lowest-precedence selector, so a document- or
27
29
  // unit-level `engine:` still wins and must not be reported as opencode's.
28
30
  let usedFallbackEngine = false;
29
- const preliminary = compilePlan(asset);
31
+ const preliminary = (options.compile ?? compilePlan)(asset);
30
32
  const engines = {};
31
33
  const maxConcurrency = frozenConcurrency(config);
34
+ const mapDefaultConcurrency = frozenMapDefaultConcurrency(config);
32
35
  const documentDefaults = asset.document.defaults;
33
- const freezeInvocation = (unit) => {
36
+ // Keyed by stepId, NOT by array position. Compile is 1:1 and order-preserving
37
+ // today, so `asset.document.steps[index]` happened to line up with
38
+ // `preliminary.steps[index]` — but nothing enforces that, and a compile pass
39
+ // that ever filtered or reordered steps would silently attribute one step's
40
+ // engine/model/timeout overrides to a different step. Every draft step
41
+ // already carries its `stepId`, so look the source up by it.
42
+ const sourceStepsById = new Map(asset.document.steps.map((step) => [step.id, step]));
43
+ const freezeInvocation = (unit, stepId) => {
34
44
  const layers = [...(documentDefaults ? [documentDefaults] : []), ...(unit ? [unit] : [])];
35
45
  const name = selectedEngine(config, layers);
36
46
  if (!name)
@@ -44,19 +54,51 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
44
54
  addSnapshot(config, name, engines);
45
55
  const model = exactModel(config, name, engine, layers);
46
56
  const timeoutMs = effectiveTimeout(config, engine, layers);
47
- const llm = engine.kind === "llm" ? mergedLlmOverrides(layers) : undefined;
57
+ // Merge llm overrides REGARDLESS of engine kind so a non-llm engine with
58
+ // overrides anywhere in its layer stack (unit `llm:` or document
59
+ // `defaults.llm`) is detected instead of silently dropped. SDK engines'
60
+ // legitimate LLM *fallback* (`llmEngine`) is a separate mechanism — it
61
+ // never contributes to `layers`, so it cannot false-positive here.
62
+ const llm = mergedLlmOverrides(layers);
48
63
  if (engine.kind !== "llm" && llm !== undefined) {
49
- throw new ConfigError(`Workflow engine "${name}" is an agent engine and cannot receive llm overrides.`, "INVALID_CONFIG_FILE");
64
+ throw new ConfigError(`Workflow step "${stepId}" uses engine "${name}", which is an agent engine and cannot receive llm: ` +
65
+ `overrides — llm: tuning (from the step's unit or defaults.llm) applies only to engines of kind "llm". ` +
66
+ `Remove the llm: block or select an LLM engine for this step.`, "INVALID_CONFIG_FILE");
50
67
  }
51
68
  return { engine: name, model, timeoutMs, ...(llm ? { llm } : {}) };
52
69
  };
53
- const freezeUnit = (node, unit) => ({
70
+ /**
71
+ * Resolve an exec unit's wall-clock budget at the single freeze boundary:
72
+ * unit `timeout:` → document `defaults.timeout` → {@link DEFAULT_EXEC_TIMEOUT_MS}.
73
+ * There is no engine layer to consult — an exec unit names no engine — and
74
+ * `null` (the author's `timeout: none`) is honored as genuinely unbounded,
75
+ * exactly like `effectiveTimeout`'s null.
76
+ */
77
+ const freezeExec = (exec, unit) => {
78
+ const layers = [...(documentDefaults ? [documentDefaults] : []), ...(unit ? [unit] : [])];
79
+ const declared = layeredTimeout(layers);
80
+ const timeoutMs = declared === undefined ? DEFAULT_EXEC_TIMEOUT_MS : declared;
81
+ // The shared structural projection, plus the one thing freezing adds: the
82
+ // RESOLVED timeout. The default allowlist stays the ABSENCE of both env
83
+ // keys — one encoding per state, which is what keeps the canonical hash
84
+ // preimage stable. `command` is non-empty by parser construction.
85
+ const core = projectExecCore(exec);
86
+ return {
87
+ ...core,
88
+ command: core.command,
89
+ timeoutMs,
90
+ };
91
+ };
92
+ const freezeUnit = (node, stepId, unit) => ({
54
93
  kind: "unit",
55
94
  id: node.id,
56
95
  instructions: node.instructions,
57
96
  templating: node.templating ?? "verbatim",
58
97
  ...(node.inputs && node.inputs.length > 0 ? { inputs: node.inputs } : {}),
59
- invocation: freezeInvocation(unit),
98
+ // An exec unit dispatches a child process, so it freezes an exec spec and
99
+ // NO invocation — engine selection is skipped entirely, which is why an
100
+ // exec-only workflow runs on an install with no engines configured at all.
101
+ ...(node.exec ? { exec: freezeExec(node.exec, unit) } : { invocation: freezeInvocation(unit, stepId) }),
60
102
  ...(node.schema ? { schema: node.schema } : {}),
61
103
  ...(node.retry ? { retry: node.retry } : {}),
62
104
  onError: node.onError,
@@ -64,8 +106,8 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
64
106
  isolation: node.isolation ?? "none",
65
107
  ...(node.source ? { source: node.source } : {}),
66
108
  });
67
- const steps = preliminary.steps.map((step, index) => {
68
- const sourceStep = asset.document.steps[index];
109
+ const steps = preliminary.steps.map((step) => {
110
+ const sourceStep = sourceStepsById.get(step.stepId);
69
111
  const sourceUnit = sourceStep?.map ? sourceStep.map.unit : sourceStep?.unit;
70
112
  const root = step.root
71
113
  ? step.root.kind === "map"
@@ -73,12 +115,14 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
73
115
  kind: "map",
74
116
  id: step.root.id,
75
117
  over: step.root.over,
76
- template: freezeUnit(step.root.template, sourceUnit),
77
- concurrency: step.root.concurrency ?? 1,
118
+ template: freezeUnit(step.root.template, step.stepId, sourceUnit),
119
+ // `?? ` — not `||` — keeps an authored `concurrency: 1` (explicit
120
+ // opt-out, serial) distinguishable from an unset one (the default).
121
+ concurrency: step.root.concurrency ?? mapDefaultConcurrency,
78
122
  reducer: step.root.reducer,
79
123
  ...(step.root.source ? { source: step.root.source } : {}),
80
124
  }
81
- : freezeUnit(step.root, sourceUnit)
125
+ : freezeUnit(step.root, step.stepId, sourceUnit)
82
126
  : undefined;
83
127
  const criteria = step.gate.criteria;
84
128
  const judge = criteria.length === 0 ? null : freezeGateJudge(config, engines);
@@ -161,11 +205,24 @@ function exactModel(config, name, engine, layers) {
161
205
  return resolveLlmModel(selected, name, config.modelAliases);
162
206
  return resolveModel(selected, engine.platform, engine.modelAliases, config.modelAliases);
163
207
  }
164
- function effectiveTimeout(config, engine, layers) {
208
+ /**
209
+ * Newest-layer-first scan for a declared `timeoutMs` across authoring layers
210
+ * (document `defaults`, then the unit). Returns `undefined` when no layer
211
+ * declares one; an explicit `timeoutMs: null` ("unbounded") wins over deeper
212
+ * layers — hence `hasOwn`, not a value test. The ONE definition of authoring
213
+ * timeout precedence, shared by engine and exec freezing.
214
+ */
215
+ function layeredTimeout(layers) {
165
216
  for (let index = layers.length - 1; index >= 0; index--) {
166
217
  if (Object.hasOwn(layers[index] ?? {}, "timeoutMs"))
167
218
  return layers[index]?.timeoutMs ?? null;
168
219
  }
220
+ return undefined;
221
+ }
222
+ function effectiveTimeout(config, engine, layers) {
223
+ const declared = layeredTimeout(layers);
224
+ if (declared !== undefined)
225
+ return declared;
169
226
  if (Object.hasOwn(engine, "timeoutMs"))
170
227
  return engine.timeoutMs ?? null;
171
228
  if (engine.kind === "llm")
@@ -199,7 +256,7 @@ function addSnapshot(config, name, target) {
199
256
  kind: "llm",
200
257
  endpoint: engine.endpoint,
201
258
  model: exactModel(config, name, engine, []),
202
- concurrency: engine.concurrency ?? 1,
259
+ concurrency: defaultLlmEngineConcurrency(engine.endpoint, engine.concurrency),
203
260
  ...(engine.provider ? { provider: engine.provider } : {}),
204
261
  ...(resolved.credential ? { credential: resolved.credential } : {}),
205
262
  ...(engine.temperature !== undefined ? { temperature: engine.temperature } : {}),
@@ -251,3 +308,13 @@ function frozenConcurrency(config) {
251
308
  const configured = config.workflow?.maxConcurrency;
252
309
  return workflowMaxConcurrency(typeof configured === "number" && Number.isFinite(configured) ? configured : undefined);
253
310
  }
311
+ /**
312
+ * Width to freeze into a `map` node that declared no `concurrency:`. Resolved
313
+ * HERE, at the single freeze boundary, so the number lands in `plan_json` and
314
+ * an in-flight run keeps the width it started with even if this default (or
315
+ * the config key) changes underneath it.
316
+ */
317
+ function frozenMapDefaultConcurrency(config) {
318
+ const configured = config.workflow?.defaultMapConcurrency;
319
+ return defaultMapConcurrency(typeof configured === "number" ? configured : undefined);
320
+ }
@@ -2,22 +2,37 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import path from "node:path";
5
+ import { isContainedRelativePath } from "../../core/common.js";
5
6
  import { UsageError } from "../../core/errors.js";
6
7
  import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
7
8
  import { HARNESS_BY_ID } from "../../integrations/harnesses/index.js";
8
9
  import { parseReference } from "../program/expressions.js";
9
10
  import { PROGRAM_PARAM_NAME_PATTERN, PROGRAM_RETRY_REASONS, PROGRAM_STEP_ID_PATTERN } from "../program/schema.js";
10
- import { jsonBytes, WORKFLOW_MAX_ENGINES, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_INSTRUCTION_BYTES, WORKFLOW_MAX_JSON_DEPTH, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_PLAN_BYTES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_STEPS, } from "../resource-limits.js";
11
+ import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, WORKFLOW_MAX_ENGINES, WORKFLOW_MAX_EXEC_ARG_BYTES, WORKFLOW_MAX_EXEC_ARGV, WORKFLOW_MAX_EXEC_CWD_LENGTH, WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_INSTRUCTION_BYTES, WORKFLOW_MAX_JSON_DEPTH, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_PLAN_BYTES, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_STEPS, WORKFLOW_MAX_TIMEOUT_MS, } from "../resource-limits.js";
11
12
  /** The only executable persisted workflow plan format. */
12
13
  export const WORKFLOW_IR_VERSION = 3;
13
- export const WORKFLOW_MAX_CONCURRENCY = 64;
14
- export const WORKFLOW_MAX_GATE_LOOPS = 100;
15
- export const WORKFLOW_MAX_RETRIES = 100;
14
+ /**
15
+ * The {@link IrRuntimeKind} a frozen engine dispatches as — the value journaled
16
+ * in `workflow_run_units.runner` and read back by `status --units`.
17
+ *
18
+ * Lives here, beside the union, because the mapping is a property OF the union:
19
+ * spelled at each call site instead, a kind added to `FrozenEngineSnapshot`
20
+ * would type-check against whichever site happens to end with a default and be
21
+ * silently mis-journaled. Callers supply their own answer for the ABSENCE of an
22
+ * engine, which genuinely differs — a unit without one is an `exec` unit, while
23
+ * a gate without one is an llm judge.
24
+ */
25
+ export function engineRuntimeKind(engine) {
26
+ return engine.kind === "llm" ? "llm" : engine.runnerKind;
27
+ }
28
+ // Shared dispatch-significant bounds now live in `../resource-limits` so the
29
+ // parser, the published JSON Schema, and this decoder enforce identical
30
+ // values. Re-exported here for existing importers (e.g. `commands/workflow-cli.ts`).
31
+ export { WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_TIMEOUT_MS };
16
32
  export const WORKFLOW_MAX_UNITS = WORKFLOW_MAX_MAP_EXPANSION;
17
- export const WORKFLOW_MAX_TIMEOUT_MS = 2 ** 31 - 1;
18
33
  const MAX_LIST_ITEMS = 1024;
19
34
  const MAX_STRING_LENGTH = 1_000_000;
20
- const ENGINE_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
35
+ const ENGINE_NAME_PATTERN = WORKFLOW_ENGINE_NAME_PATTERN;
21
36
  /** Strictly decode persisted v3 data before it can drive a workflow. */
22
37
  export function decodeWorkflowPlanV3(input, hooks = {}) {
23
38
  if (!isRecord(input) || input.irVersion !== WORKFLOW_IR_VERSION)
@@ -34,7 +49,7 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
34
49
  !Number.isInteger(plan.execution.maxConcurrency) ||
35
50
  plan.execution.maxConcurrency < 1 ||
36
51
  plan.execution.maxConcurrency > WORKFLOW_MAX_CONCURRENCY) {
37
- fail("execution.maxConcurrency must be an integer from 1 through 64");
52
+ fail(`execution.maxConcurrency must be an integer from 1 through ${WORKFLOW_MAX_CONCURRENCY}`);
38
53
  }
39
54
  assertKeys(plan.execution, ["maxConcurrency", "engines"], "execution");
40
55
  if (!isRecord(plan.execution.engines))
@@ -62,8 +77,7 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
62
77
  assertString(step.title, `step ${step.stepId} title`);
63
78
  if (!!step.root === !!step.route)
64
79
  fail(`step ${step.stepId} must contain exactly one of root or route`);
65
- assertKeys(step, ["stepId", "title", "sequenceIndex", "dependsOn", "root", "route", "outputSchema", "gate"], `step ${step.stepId}`);
66
- validateStringArray(step.dependsOn, `step ${step.stepId} dependsOn`, WORKFLOW_MAX_STEPS, true);
80
+ assertKeys(step, ["stepId", "title", "sequenceIndex", "root", "route", "outputSchema", "gate"], `step ${step.stepId}`);
67
81
  if (step.outputSchema !== undefined)
68
82
  validateSchema(step.outputSchema, `step ${step.stepId} outputSchema`);
69
83
  if (step.root)
@@ -83,13 +97,6 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
83
97
  if (targetIndex <= index)
84
98
  fail(`route target ${target} must come after step ${step.stepId}`);
85
99
  }
86
- const dependencies = new Set();
87
- for (const dependency of step.dependsOn ?? []) {
88
- const dependencyIndex = stepIndex.get(dependency);
89
- if (dependencyIndex === undefined || dependencyIndex >= index || dependencies.has(dependency))
90
- fail(`step ${step.stepId} has an invalid dependency`);
91
- dependencies.add(dependency);
92
- }
93
100
  validateStepExpressions(step, index, stepIndex);
94
101
  }
95
102
  for (const name of references) {
@@ -122,7 +129,7 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
122
129
  }
123
130
  function validateEngine(key, engine, references, hooks) {
124
131
  if (!ENGINE_NAME_PATTERN.test(key) ||
125
- key.length > 63 ||
132
+ key.length > WORKFLOW_MAX_ENGINE_NAME_LENGTH ||
126
133
  !isRecord(engine) ||
127
134
  engine.name !== key ||
128
135
  (engine.kind !== "llm" && engine.kind !== "agent"))
@@ -204,7 +211,7 @@ function validateEngine(key, engine, references, hooks) {
204
211
  !(typeof engine.workspace === "string" || engine.workspace === null) ||
205
212
  !Array.isArray(engine.envPassthrough) ||
206
213
  engine.envPassthrough.length > MAX_LIST_ITEMS ||
207
- !engine.envPassthrough.every((name) => typeof name === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) ||
214
+ !engine.envPassthrough.every((name) => typeof name === "string" && WORKFLOW_ENV_VAR_NAME_PATTERN.test(name)) ||
208
215
  new Set(engine.envPassthrough).size !== engine.envPassthrough.length ||
209
216
  typeof engine.commandBuilder !== "string" ||
210
217
  engine.commandBuilder !== engine.platform ||
@@ -255,6 +262,7 @@ function validateNode(node, stepId, references, nodeIds, hooks) {
255
262
  "templating",
256
263
  "inputs",
257
264
  "invocation",
265
+ "exec",
258
266
  "schema",
259
267
  "retry",
260
268
  "onError",
@@ -264,13 +272,13 @@ function validateNode(node, stepId, references, nodeIds, hooks) {
264
272
  ], `unit ${node.id}`);
265
273
  if (typeof node.instructions !== "string" ||
266
274
  !node.instructions ||
267
- (node.templating !== "expressions" && node.templating !== "verbatim") ||
275
+ node.templating !== "verbatim" ||
268
276
  (node.onError !== "fail" && node.onError !== "continue") ||
269
277
  (node.isolation !== "none" && node.isolation !== "worktree"))
270
278
  fail(`unit ${node.id} is invalid`);
271
279
  if (node.id !== stepId && node.id !== `${stepId}.unit`)
272
280
  fail(`unit ${node.id} does not belong to step ${stepId}`);
273
- if (Buffer.byteLength(node.instructions, "utf8") > WORKFLOW_MAX_INSTRUCTION_BYTES)
281
+ if (utf8Bytes(node.instructions) > WORKFLOW_MAX_INSTRUCTION_BYTES)
274
282
  fail(`unit ${node.id} instructions exceed the 256 KiB resource limit`);
275
283
  if (node.schema !== undefined)
276
284
  validateSchema(node.schema, `unit ${node.id} schema`);
@@ -278,8 +286,77 @@ function validateNode(node, stepId, references, nodeIds, hooks) {
278
286
  validateStringArray(node.env, `unit ${node.id} env`, MAX_LIST_ITEMS, true);
279
287
  validateStringArray(node.inputs, `unit ${node.id} inputs`, WORKFLOW_MAX_INPUTS, true);
280
288
  validateSource(node.source, `unit ${node.id} source`);
289
+ // Exactly one dispatch mechanism per unit. A node carrying both would let a
290
+ // tampered plan smuggle a shell command past the engine-compatibility checks
291
+ // (which key off the invocation); one carrying neither has nothing to run.
292
+ if ((node.invocation === undefined) === (node.exec === undefined)) {
293
+ fail(`unit ${node.id} must declare exactly one of invocation or exec`);
294
+ }
295
+ if (node.exec !== undefined) {
296
+ validateExecSpec(node.exec, `unit ${node.id} exec`);
297
+ return;
298
+ }
281
299
  validateInvocation(node.invocation, references, hooks);
282
300
  }
301
+ /**
302
+ * Strictly decode a frozen {@link IrExecSpec}. This is the corruption gate for
303
+ * a persisted plan: the argv bounds, the relative-and-contained `cwd`, and the
304
+ * timeout range are all re-checked here, because `plan_json` may have been
305
+ * hand-edited between freeze and dispatch.
306
+ */
307
+ function validateExecSpec(value, label) {
308
+ if (!isRecord(value))
309
+ fail(`${label} must be an object`);
310
+ assertKeys(value, ["command", "cwd", "passEnv", "inheritEnv", "timeoutMs"], label);
311
+ validateExecEnvScope(value, label);
312
+ const command = value.command;
313
+ if (!Array.isArray(command) ||
314
+ command.length === 0 ||
315
+ command.length > WORKFLOW_MAX_EXEC_ARGV ||
316
+ !command.every((arg) => typeof arg === "string" && arg.length > 0 && utf8Bytes(arg) <= WORKFLOW_MAX_EXEC_ARG_BYTES)) {
317
+ fail(`${label}.command must be an argv array of 1 through ${WORKFLOW_MAX_EXEC_ARGV} bounded non-empty strings`);
318
+ }
319
+ if (value.cwd !== undefined) {
320
+ if (typeof value.cwd !== "string" ||
321
+ value.cwd.length === 0 ||
322
+ value.cwd.length > WORKFLOW_MAX_EXEC_CWD_LENGTH ||
323
+ !isContainedRelativePath(value.cwd)) {
324
+ fail(`${label}.cwd must be a relative path contained in the unit working directory`);
325
+ }
326
+ }
327
+ if (!(value.timeoutMs === null ||
328
+ (Number.isSafeInteger(value.timeoutMs) &&
329
+ value.timeoutMs >= 1 &&
330
+ value.timeoutMs <= WORKFLOW_MAX_TIMEOUT_MS))) {
331
+ fail(`${label}.timeoutMs must be null or an integer from 1 through ${WORKFLOW_MAX_TIMEOUT_MS}`);
332
+ }
333
+ }
334
+ /**
335
+ * The child's ENVIRONMENT SCOPE half of a frozen exec spec. Split out so
336
+ * {@link validateExecSpec} keeps its shape (and the src-fn-size ratchet stays
337
+ * shrink-only).
338
+ *
339
+ * Both keys are canonical-form-only: `inheritEnv` may only be `true` and
340
+ * `passEnv` may only be a non-empty deduplicated name list. A persisted `false`
341
+ * or `[]` means exactly what absence means, and admitting a second spelling of
342
+ * the default would give the same unit two different input hashes.
343
+ */
344
+ function validateExecEnvScope(value, label) {
345
+ if (value.inheritEnv !== undefined && value.inheritEnv !== true) {
346
+ fail(`${label}.inheritEnv must be true when present (the allowlist default is the key's ABSENCE)`);
347
+ }
348
+ if (value.passEnv === undefined)
349
+ return;
350
+ const passEnv = value.passEnv;
351
+ if (!Array.isArray(passEnv) ||
352
+ passEnv.length === 0 ||
353
+ passEnv.length > WORKFLOW_MAX_EXEC_PASS_ENV ||
354
+ !passEnv.every((name) => typeof name === "string" && WORKFLOW_ENV_VAR_NAME_PATTERN.test(name)) ||
355
+ new Set(passEnv).size !== passEnv.length) {
356
+ fail(`${label}.passEnv must be 1 through ${WORKFLOW_MAX_EXEC_PASS_ENV} distinct environment variable names ` +
357
+ `matching ${WORKFLOW_ENV_VAR_NAME_PATTERN.source}`);
358
+ }
359
+ }
283
360
  function validateGate(gate, stepId, references, nodeIds, hooks) {
284
361
  if (!isRecord(gate) ||
285
362
  gate.kind !== "gate" ||
@@ -331,6 +408,14 @@ function validateRoute(route, stepId) {
331
408
  }
332
409
  function assertUnitEngineCompatibility(node, engines) {
333
410
  const unit = node.kind === "map" ? node.template : node;
411
+ // An exec unit names no engine at all, so there is no engine pairing to
412
+ // check. It is the ONE kind that may carry `env` + `isolation: worktree`
413
+ // without an engine behind it: it spawns a real child process, which is
414
+ // exactly the precondition those two features require. `validateNode` has
415
+ // already proven it carries no `invocation`, so nothing here can be bypassed
416
+ // by declaring both.
417
+ if (unit.exec !== undefined)
418
+ return;
334
419
  const engine = unit.invocation ? engines[unit.invocation.engine] : undefined;
335
420
  if (!engine)
336
421
  return;