akm-cli 0.9.0 → 0.9.1-beta.1

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 (109) hide show
  1. package/CHANGELOG.md +707 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/commands/agent/contribute-cli.js +1 -1
  5. package/dist/commands/feedback-cli.js +7 -1
  6. package/dist/commands/health/llm-usage.js +2 -1
  7. package/dist/commands/health/surfaces.js +4 -77
  8. package/dist/commands/health.js +65 -11
  9. package/dist/commands/improve/distill/quality-gate.js +6 -1
  10. package/dist/commands/improve/eligibility.js +7 -1
  11. package/dist/commands/improve/improve.js +126 -10
  12. package/dist/commands/improve/locks.js +7 -0
  13. package/dist/commands/improve/memory/memory-improve.js +8 -0
  14. package/dist/commands/improve/run-context.js +5 -0
  15. package/dist/commands/improve/session-asset.js +4 -0
  16. package/dist/commands/lint/base-linter.js +31 -7
  17. package/dist/commands/lint/index.js +200 -50
  18. package/dist/commands/lint/types.js +22 -1
  19. package/dist/commands/proposal/repository.js +17 -1
  20. package/dist/commands/sources/info.js +12 -2
  21. package/dist/commands/sources/installed-stashes.js +6 -1
  22. package/dist/commands/tasks/tasks.js +8 -2
  23. package/dist/commands/workflow-cli.js +17 -11
  24. package/dist/core/abort-deadline.js +28 -0
  25. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  26. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  27. package/dist/core/adapter/adapters/akm-lint.js +72 -20
  28. package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
  29. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  30. package/dist/core/asset/frontmatter.js +4 -0
  31. package/dist/core/common.js +66 -6
  32. package/dist/core/concurrent.js +32 -0
  33. package/dist/core/config/schema/workflow.js +11 -0
  34. package/dist/core/errors.js +25 -0
  35. package/dist/core/events.js +30 -24
  36. package/dist/core/file-lock.js +7 -1
  37. package/dist/core/improve-result.js +5 -0
  38. package/dist/core/json-schema.js +327 -9
  39. package/dist/core/loopback.js +89 -0
  40. package/dist/core/migration-operation.js +17 -2
  41. package/dist/core/path-access.js +107 -0
  42. package/dist/core/redaction.js +64 -17
  43. package/dist/core/spawn-env.js +234 -0
  44. package/dist/core/state-db-scope.js +134 -0
  45. package/dist/core/subprocess.js +181 -37
  46. package/dist/core/write-provenance.js +85 -0
  47. package/dist/core/write-source.js +7 -0
  48. package/dist/indexer/db/graph-db.js +17 -6
  49. package/dist/indexer/ensure-index.js +10 -3
  50. package/dist/indexer/index-written-assets.js +17 -2
  51. package/dist/indexer/indexer.js +55 -15
  52. package/dist/indexer/passes/memory-inference.js +4 -0
  53. package/dist/indexer/search/db-search.js +8 -15
  54. package/dist/integrations/agent/engine-resolution.js +24 -11
  55. package/dist/integrations/agent/model-aliases.js +1 -1
  56. package/dist/integrations/agent/profiles.js +9 -1
  57. package/dist/integrations/agent/spawn.js +15 -87
  58. package/dist/integrations/lockfile.js +45 -2
  59. package/dist/output/text/lint-format.js +17 -4
  60. package/dist/scripts/akm-migrate-node.js +1435 -753
  61. package/dist/scripts/akm-migrate.js +1403 -721
  62. package/dist/sources/providers/git-stash.js +19 -0
  63. package/dist/sources/providers/git.js +1 -1
  64. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  65. package/dist/storage/database.js +6 -1
  66. package/dist/storage/managed-db.js +19 -0
  67. package/dist/storage/repositories/index-connection.js +28 -3
  68. package/dist/storage/repositories/index-entries-repository.js +6 -1
  69. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  70. package/dist/tasks/log-redaction.js +156 -0
  71. package/dist/tasks/parser.js +82 -5
  72. package/dist/tasks/runner.js +139 -11
  73. package/dist/tasks/schema.js +65 -0
  74. package/dist/workflows/concurrency-policy.js +95 -1
  75. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  76. package/dist/workflows/exec/exec-unit.js +542 -0
  77. package/dist/workflows/exec/frozen-judge.js +114 -42
  78. package/dist/workflows/exec/native-executor.js +457 -238
  79. package/dist/workflows/exec/param-secrets.js +4 -3
  80. package/dist/workflows/exec/run-workflow.js +424 -219
  81. package/dist/workflows/exec/step-work.js +496 -165
  82. package/dist/workflows/exec/unit-dispatch.js +31 -1
  83. package/dist/workflows/exec/unit-writer.js +53 -13
  84. package/dist/workflows/exec/worktree.js +454 -41
  85. package/dist/workflows/ir/compile.js +26 -2
  86. package/dist/workflows/ir/freeze.js +82 -15
  87. package/dist/workflows/ir/schema.js +105 -20
  88. package/dist/workflows/parser.js +216 -18
  89. package/dist/workflows/program/schema.js +24 -0
  90. package/dist/workflows/renderer.js +32 -4
  91. package/dist/workflows/resource-limits.js +182 -0
  92. package/dist/workflows/runtime/runs.js +146 -6
  93. package/dist/workflows/validate-summary.js +17 -2
  94. package/docs/README.md +74 -32
  95. package/docs/migration/release-notes/0.9.0.md +2 -1
  96. package/docs/migration/v0.7-to-v0.8.md +2 -1
  97. package/docs/migration/v0.8-to-v0.9.md +3 -1
  98. package/docs/reference/README.md +11 -4
  99. package/docs/reference/bundle-types.md +19 -0
  100. package/docs/reference/cli.md +105 -16
  101. package/docs/reference/configuration.md +15 -2
  102. package/docs/reference/data-and-telemetry.md +30 -10
  103. package/docs/reference/supported-formats.md +50 -0
  104. package/docs/reference/workflow-schema.md +1014 -0
  105. package/docs/reference/workflows.md +37 -633
  106. package/package.json +13 -6
  107. package/schemas/akm-config.json +8 -0
  108. package/schemas/akm-task.json +27 -5
  109. package/schemas/akm-workflow.json +85 -10
@@ -33,10 +33,12 @@
33
33
  import { LineCounter, parseDocument } from "yaml";
34
34
  import { parseFrontmatterBlock } from "../core/asset/frontmatter.js";
35
35
  import { parseMarkdownToc } from "../core/asset/markdown.js";
36
+ import { isContainedRelativePath } from "../core/common.js";
36
37
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
38
+ import { checkJsonSchemaDefinition, JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS } from "../core/json-schema.js";
37
39
  import { parseReference } from "./program/expressions.js";
38
40
  import { PROGRAM_ISOLATION_KINDS, PROGRAM_ON_ERROR, PROGRAM_PARAM_NAME_PATTERN, PROGRAM_REDUCERS, PROGRAM_RETRY_REASONS, PROGRAM_STEP_ID_PATTERN, } from "./program/schema.js";
39
- import { jsonBytes, utf8Bytes, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_STEPS, } from "./resource-limits.js";
41
+ import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, 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_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_STEPS, WORKFLOW_MAX_TIMEOUT_MS, } from "./resource-limits.js";
40
42
  import { WORKFLOW_SCHEMA_VERSION, } from "./schema.js";
41
43
  import { runSemanticChecks } from "./validator.js";
42
44
  /** Envelope keys every AKM markdown asset carries ($ref'd from schemas/akm-asset-envelope.json). */
@@ -59,7 +61,10 @@ const TOP_LEVEL_KEYS = [...ENVELOPE_KEYS, ...WORKFLOW_KEYS];
59
61
  const DEFAULTS_KEYS = ["engine", "model", "timeout", "on_error", "llm"];
60
62
  const BUDGET_KEYS = ["max_tokens", "max_units"];
61
63
  const STEP_KEYS = ["id", "unit", "map", "route", "inputs", "output", "gate"];
62
- const UNIT_KEYS = ["engine", "model", "llm", "timeout", "retry", "on_error", "output", "env", "isolation"];
64
+ const UNIT_KEYS = ["exec", "engine", "model", "llm", "timeout", "retry", "on_error", "output", "env", "isolation"];
65
+ const EXEC_KEYS = ["command", "cwd", "pass_env", "inherit_env"];
66
+ /** Unit keys that name an ENGINE dispatch and therefore cannot appear beside `exec:`. */
67
+ const UNIT_ENGINE_KEYS = ["engine", "model", "llm"];
63
68
  const MAP_KEYS = ["over", "concurrency", "reducer", "unit"];
64
69
  const ROUTE_KEYS = ["input", "when", "default"];
65
70
  const RETRY_KEYS = ["max", "on"];
@@ -422,6 +427,10 @@ function parseParams(ctx, raw) {
422
427
  ctx.err(["params", paramName], `Param "${paramName}" must be a JSON Schema object (e.g. { type: string }).`);
423
428
  continue;
424
429
  }
430
+ if (jsonBytes(value) > WORKFLOW_MAX_SCHEMA_BYTES) {
431
+ ctx.err(["params", paramName], `Param "${paramName}" schema exceeds the 256 KiB resource limit.`);
432
+ }
433
+ checkSchemaDefinition(ctx, value, ["params", paramName], `Param "${paramName}" schema`);
425
434
  params[paramName] = value;
426
435
  }
427
436
  return Object.keys(params).length > 0 ? params : undefined;
@@ -437,10 +446,9 @@ function parseDefaults(ctx, raw) {
437
446
  checkUnknownKeys(ctx, raw, path, DEFAULTS_KEYS, `"defaults"`);
438
447
  const defaults = {};
439
448
  if (raw.engine !== undefined) {
440
- if (typeof raw.engine === "string" && raw.engine.trim() !== "")
441
- defaults.engine = raw.engine.trim();
442
- else
443
- ctx.err([...path, "engine"], `"defaults.engine" must be a non-empty engine name.`);
449
+ const engine = parseEngineName(ctx, raw.engine, [...path, "engine"], `"defaults.engine"`);
450
+ if (engine !== undefined)
451
+ defaults.engine = engine;
444
452
  }
445
453
  if (raw.model !== undefined) {
446
454
  if (typeof raw.model === "string" && raw.model.trim() !== "")
@@ -605,11 +613,24 @@ function parseUnit(ctx, raw, path, stepLabel) {
605
613
  }
606
614
  checkUnknownKeys(ctx, raw, path, UNIT_KEYS, `${stepLabel} "unit"`);
607
615
  const unit = { source: ctx.refAt(path) };
616
+ if (raw.exec !== undefined) {
617
+ const exec = parseExec(ctx, raw.exec, [...path, "exec"], stepLabel);
618
+ if (exec !== undefined)
619
+ unit.exec = exec;
620
+ // An exec unit dispatches no engine call, so every engine-selection key is
621
+ // a contradiction rather than a harmless extra. Reported per key so the
622
+ // author sees exactly which line to delete.
623
+ for (const key of UNIT_ENGINE_KEYS) {
624
+ if (raw[key] === undefined)
625
+ continue;
626
+ ctx.err([...path, key], `${stepLabel} "unit" declares both "exec" and "${key}". An exec unit runs a shell command and never ` +
627
+ `reaches an engine, so "${key}" would have no effect — remove one of the two.`);
628
+ }
629
+ }
608
630
  if (raw.engine !== undefined) {
609
- if (typeof raw.engine === "string" && raw.engine.trim() !== "")
610
- unit.engine = raw.engine.trim();
611
- else
612
- ctx.err([...path, "engine"], `${stepLabel} "engine" must be a non-empty engine name.`);
631
+ const engine = parseEngineName(ctx, raw.engine, [...path, "engine"], `${stepLabel} "engine"`);
632
+ if (engine !== undefined)
633
+ unit.engine = engine;
613
634
  }
614
635
  if (raw.model !== undefined) {
615
636
  if (typeof raw.model === "string" && raw.model.trim() !== "")
@@ -645,6 +666,124 @@ function parseUnit(ctx, raw, path, stepLabel) {
645
666
  unit.isolation = isolation;
646
667
  return unit;
647
668
  }
669
+ /**
670
+ * Parse `unit.exec` — the argv-array shell-command surface.
671
+ *
672
+ * Deliberately NO shell-string spelling: the child is spawned directly from
673
+ * this array, so shell metacharacters are inert literal bytes and the whole
674
+ * quoting/injection class is structurally absent. An author who wants a
675
+ * pipeline writes the interpreter explicitly (`["bash", "-lc", "…"]`), which
676
+ * keeps that decision visible in the frontmatter diff.
677
+ */
678
+ function parseExec(ctx, raw, path, stepLabel) {
679
+ if (!isPlainRecord(raw)) {
680
+ ctx.err(path, `${stepLabel} "exec" must be a mapping with a "command" argv list.`);
681
+ return undefined;
682
+ }
683
+ checkUnknownKeys(ctx, raw, path, EXEC_KEYS, `${stepLabel} "exec"`);
684
+ const command = parseExecCommand(ctx, raw.command, [...path, "command"], stepLabel);
685
+ if (command === undefined)
686
+ return undefined;
687
+ const exec = { command };
688
+ const cwd = parseExecCwd(ctx, raw.cwd, [...path, "cwd"], stepLabel);
689
+ if (cwd !== undefined)
690
+ exec.cwd = cwd;
691
+ const passEnv = parseExecPassEnv(ctx, raw.pass_env, [...path, "pass_env"], stepLabel);
692
+ if (passEnv !== undefined)
693
+ exec.passEnv = passEnv;
694
+ if (raw.inherit_env !== undefined) {
695
+ if (typeof raw.inherit_env !== "boolean") {
696
+ ctx.err([...path, "inherit_env"], `${stepLabel} "exec.inherit_env" must be true or false. true gives the command akm's whole environment ` +
697
+ `instead of the default allowlist; omit it (or write false) to keep the allowlist.`);
698
+ }
699
+ else if (raw.inherit_env) {
700
+ exec.inheritEnv = true;
701
+ }
702
+ }
703
+ return exec;
704
+ }
705
+ /**
706
+ * `pass_env:` — extra parent-process env var NAMES the child may see on top of
707
+ * the default allowlist. NAMES ONLY: a value would be a plaintext secret in the
708
+ * frozen plan, which is exactly what `env:` bindings exist to avoid.
709
+ */
710
+ function parseExecPassEnv(ctx, raw, path, stepLabel) {
711
+ if (raw === undefined)
712
+ return undefined;
713
+ if (!Array.isArray(raw) || raw.length === 0) {
714
+ ctx.err(path, `${stepLabel} "exec.pass_env" must be a non-empty list of environment variable NAMES to copy through from ` +
715
+ `akm's own environment, e.g. pass_env: [CARGO_HOME]. Values never appear here — use "env:" bindings for those.`);
716
+ return undefined;
717
+ }
718
+ if (raw.length > WORKFLOW_MAX_EXEC_PASS_ENV) {
719
+ ctx.err(path, `${stepLabel} "exec.pass_env" must have at most ${WORKFLOW_MAX_EXEC_PASS_ENV} entries. A command needing ` +
720
+ `more than that wants "inherit_env: true", which says so explicitly.`);
721
+ return undefined;
722
+ }
723
+ const names = [];
724
+ for (const [index, entry] of raw.entries()) {
725
+ if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
726
+ ctx.err(path, `${stepLabel} "exec.pass_env[${index}]" must be an environment variable name matching ` +
727
+ `${WORKFLOW_ENV_VAR_NAME_PATTERN.source}.`);
728
+ return undefined;
729
+ }
730
+ if (names.includes(entry)) {
731
+ ctx.err(path, `${stepLabel} "exec.pass_env" lists "${entry}" more than once.`);
732
+ return undefined;
733
+ }
734
+ names.push(entry);
735
+ }
736
+ return names;
737
+ }
738
+ /** The argv array itself: 1..WORKFLOW_MAX_EXEC_ARGV bounded non-empty strings. */
739
+ function parseExecCommand(ctx, raw, path, stepLabel) {
740
+ if (!Array.isArray(raw) || raw.length === 0) {
741
+ ctx.err(path, `${stepLabel} "exec" requires "command": a non-empty argv list, e.g. command: ["bun", "run", "test:unit"]. ` +
742
+ `A single shell string is not accepted — the command is spawned directly, never through a shell.`);
743
+ return undefined;
744
+ }
745
+ if (raw.length > WORKFLOW_MAX_EXEC_ARGV) {
746
+ ctx.err(path, `${stepLabel} "exec.command" must have at most ${WORKFLOW_MAX_EXEC_ARGV} entries.`);
747
+ return undefined;
748
+ }
749
+ const argv = [];
750
+ for (const [index, entry] of raw.entries()) {
751
+ if (typeof entry !== "string" || entry === "") {
752
+ ctx.err(path, `${stepLabel} "exec.command[${index}]" must be a non-empty string.`);
753
+ return undefined;
754
+ }
755
+ if (utf8Bytes(entry) > WORKFLOW_MAX_EXEC_ARG_BYTES) {
756
+ ctx.err(path, `${stepLabel} "exec.command[${index}]" exceeds ${WORKFLOW_MAX_EXEC_ARG_BYTES} bytes.`);
757
+ return undefined;
758
+ }
759
+ argv.push(entry);
760
+ }
761
+ return argv;
762
+ }
763
+ /**
764
+ * The optional relative `cwd:`. Rejected here (statically) for absolute paths
765
+ * and `..` segments; containment against the resolved base directory is
766
+ * re-checked at dispatch, so a symlinked subdirectory cannot escape either.
767
+ */
768
+ function parseExecCwd(ctx, raw, path, stepLabel) {
769
+ if (raw === undefined)
770
+ return undefined;
771
+ if (typeof raw !== "string" || raw.trim() === "") {
772
+ ctx.err(path, `${stepLabel} "exec.cwd" must be a non-empty relative path inside the unit's working directory.`);
773
+ return undefined;
774
+ }
775
+ const value = raw.trim();
776
+ if (value.length > WORKFLOW_MAX_EXEC_CWD_LENGTH) {
777
+ ctx.err(path, `${stepLabel} "exec.cwd" exceeds ${WORKFLOW_MAX_EXEC_CWD_LENGTH} characters.`);
778
+ return undefined;
779
+ }
780
+ if (!isContainedRelativePath(value)) {
781
+ ctx.err(path, `${stepLabel} "exec.cwd" (${JSON.stringify(value)}) must be a RELATIVE path inside the unit's working ` +
782
+ `directory — absolute paths, Windows drive letters, "~", and ".." segments are rejected.`);
783
+ return undefined;
784
+ }
785
+ return value;
786
+ }
648
787
  function parseMap(ctx, raw, path, stepLabel) {
649
788
  if (!isPlainRecord(raw)) {
650
789
  ctx.err(path, `${stepLabel} "map" must be a mapping with an "over" key.`);
@@ -661,11 +800,14 @@ function parseMap(ctx, raw, path, stepLabel) {
661
800
  }
662
801
  let concurrency;
663
802
  if (raw.concurrency !== undefined) {
664
- if (typeof raw.concurrency === "number" && Number.isInteger(raw.concurrency) && raw.concurrency > 0) {
803
+ if (typeof raw.concurrency === "number" &&
804
+ Number.isInteger(raw.concurrency) &&
805
+ raw.concurrency > 0 &&
806
+ raw.concurrency <= WORKFLOW_MAX_CONCURRENCY) {
665
807
  concurrency = raw.concurrency;
666
808
  }
667
809
  else {
668
- ctx.err([...path, "concurrency"], `${stepLabel} "concurrency" must be a positive integer.`);
810
+ ctx.err([...path, "concurrency"], `${stepLabel} "concurrency" must be an integer from 1 through ${WORKFLOW_MAX_CONCURRENCY}.`);
669
811
  }
670
812
  }
671
813
  const reducer = parseEnumField(ctx, raw.reducer, [...path, "reducer"], `${stepLabel} "reducer"`, PROGRAM_REDUCERS);
@@ -778,11 +920,14 @@ function parseGate(ctx, raw, path, stepLabel) {
778
920
  checkUnknownKeys(ctx, raw, path, GATE_KEYS, `${stepLabel} "gate"`);
779
921
  const gate = {};
780
922
  if (raw.max_loops !== undefined) {
781
- if (typeof raw.max_loops === "number" && Number.isInteger(raw.max_loops) && raw.max_loops >= 1) {
923
+ if (typeof raw.max_loops === "number" &&
924
+ Number.isInteger(raw.max_loops) &&
925
+ raw.max_loops >= 1 &&
926
+ raw.max_loops <= WORKFLOW_MAX_GATE_LOOPS) {
782
927
  gate.maxLoops = raw.max_loops;
783
928
  }
784
929
  else {
785
- ctx.err([...path, "max_loops"], `${stepLabel} "gate.max_loops" must be an integer >= 1.`);
930
+ ctx.err([...path, "max_loops"], `${stepLabel} "gate.max_loops" must be an integer from 1 through ${WORKFLOW_MAX_GATE_LOOPS}.`);
786
931
  }
787
932
  }
788
933
  return gate;
@@ -790,6 +935,26 @@ function parseGate(ctx, raw, path, stepLabel) {
790
935
  // ---------------------------------------------------------------------------
791
936
  // Field helpers
792
937
  // ---------------------------------------------------------------------------
938
+ /**
939
+ * Engine names must already satisfy the frozen-plan grammar
940
+ * (`WORKFLOW_ENGINE_NAME_PATTERN`, max 63 chars) at parse time — the decoder
941
+ * enforces the same bound on persisted plans, and a name that only fails there
942
+ * surfaces as an unlocated "Invalid frozen workflow plan" at `workflow run`.
943
+ */
944
+ function parseEngineName(ctx, raw, path, label) {
945
+ if (typeof raw !== "string" || raw.trim() === "") {
946
+ ctx.err(path, `${label} must be a non-empty engine name.`);
947
+ return undefined;
948
+ }
949
+ const name = raw.trim();
950
+ if (!WORKFLOW_ENGINE_NAME_PATTERN.test(name) || name.length > WORKFLOW_MAX_ENGINE_NAME_LENGTH) {
951
+ ctx.err(path, `${label} has an invalid engine name ${JSON.stringify(name)}. Engine names are lowercase words of letters ` +
952
+ `and digits separated by single dashes, starting with a letter (e.g. "code-review-llm"), at most ` +
953
+ `${WORKFLOW_MAX_ENGINE_NAME_LENGTH} characters.`);
954
+ return undefined;
955
+ }
956
+ return name;
957
+ }
793
958
  function parseRetry(ctx, raw, path, stepLabel) {
794
959
  if (raw === undefined)
795
960
  return undefined;
@@ -799,8 +964,8 @@ function parseRetry(ctx, raw, path, stepLabel) {
799
964
  }
800
965
  checkUnknownKeys(ctx, raw, path, RETRY_KEYS, `${stepLabel} "retry"`);
801
966
  let ok = true;
802
- if (!(typeof raw.max === "number" && Number.isInteger(raw.max) && raw.max >= 0)) {
803
- ctx.err([...path, "max"], `${stepLabel} "retry.max" is required and must be a non-negative integer.`);
967
+ if (!(typeof raw.max === "number" && Number.isInteger(raw.max) && raw.max >= 0 && raw.max <= WORKFLOW_MAX_RETRIES)) {
968
+ ctx.err([...path, "max"], `${stepLabel} "retry.max" is required and must be an integer from 0 through ${WORKFLOW_MAX_RETRIES}.`);
804
969
  ok = false;
805
970
  }
806
971
  const on = [];
@@ -826,7 +991,7 @@ function parseTimeoutField(ctx, raw, path, label) {
826
991
  return undefined;
827
992
  if (typeof raw === "number") {
828
993
  if (Number.isInteger(raw) && raw > 0)
829
- return raw;
994
+ return checkTimeoutCeiling(ctx, raw, path, label, String(raw));
830
995
  ctx.err(path, `${label} has a non-positive timeout ${JSON.stringify(raw)}. ${TIMEOUT_HINT}.`);
831
996
  return undefined;
832
997
  }
@@ -849,7 +1014,20 @@ function parseTimeoutField(ctx, raw, path, label) {
849
1014
  ctx.err(path, `${label} has a non-positive timeout "${raw}". Use a positive duration or "none".`);
850
1015
  return undefined;
851
1016
  }
852
- return timeoutMs;
1017
+ return checkTimeoutCeiling(ctx, timeoutMs, path, label, raw);
1018
+ }
1019
+ /**
1020
+ * Timeouts freeze into `IrInvocation.timeoutMs`, whose decoder bound is
1021
+ * `WORKFLOW_MAX_TIMEOUT_MS` (setTimeout's 32-bit signed ceiling). Enforce the
1022
+ * same ceiling here so an oversized duration fails with a line anchor instead
1023
+ * of an unlocated decode error at `workflow run`.
1024
+ */
1025
+ function checkTimeoutCeiling(ctx, timeoutMs, path, label, raw) {
1026
+ if (timeoutMs <= WORKFLOW_MAX_TIMEOUT_MS)
1027
+ return timeoutMs;
1028
+ ctx.err(path, `${label} has a timeout "${raw}" above the maximum of ${WORKFLOW_MAX_TIMEOUT_MS} ms (about 24.8 days). ` +
1029
+ `Use a shorter duration or "none" for no timeout.`);
1030
+ return undefined;
853
1031
  }
854
1032
  function parseEnumField(ctx, raw, path, label, allowed) {
855
1033
  if (raw === undefined)
@@ -938,8 +1116,28 @@ function parseSchemaObject(ctx, raw, path, label) {
938
1116
  if (jsonBytes(raw) > WORKFLOW_MAX_SCHEMA_BYTES) {
939
1117
  ctx.err(path, `${label} exceeds the 256 KiB resource limit.`);
940
1118
  }
1119
+ checkSchemaDefinition(ctx, raw, path, label);
941
1120
  return raw;
942
1121
  }
1122
+ /**
1123
+ * Validate an author-declared schema AS a schema (`output:` and `params`
1124
+ * declarations). The runtime enforces only a JSON Schema subset
1125
+ * (`core/json-schema.ts`); a typo'd `type` or a keyword the subset ignores
1126
+ * would silently constrain nothing at run time — a gate depending on a no-op
1127
+ * schema is worse than a loud failure here, so both are parse ERRORS.
1128
+ */
1129
+ function checkSchemaDefinition(ctx, schema, path, label) {
1130
+ for (const issue of checkJsonSchemaDefinition(schema)) {
1131
+ const issuePath = [...path, ...issue.path];
1132
+ if (issue.kind === "unsupported") {
1133
+ ctx.err(issuePath, `${label} (at ${issue.pointer}): ${issue.message}. Supported JSON Schema keywords: ` +
1134
+ `${JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS}.`);
1135
+ }
1136
+ else {
1137
+ ctx.err(issuePath, `${label} is not a valid JSON Schema (at ${issue.pointer}): ${issue.message}.`);
1138
+ }
1139
+ }
1140
+ }
943
1141
  function checkReferenceSyntax(ctx, text, path, label) {
944
1142
  const result = parseReference(text);
945
1143
  if (!result.ok)
@@ -49,3 +49,27 @@ export const PROGRAM_STEP_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
49
49
  * identifiers (no dots/dashes).
50
50
  */
51
51
  export const PROGRAM_PARAM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
52
+ /**
53
+ * The ONE structural projection of an exec spec — ONE encoding per state.
54
+ *
55
+ * Every layer that carries an exec forward derives from this: the compiled
56
+ * draft, the frozen plan (which layers `timeoutMs` on top), and the summary
57
+ * `akm workflow show` prints. Written out per layer instead, a field added to
58
+ * {@link ProgramExec} reaches whichever copies were remembered and silently
59
+ * vanishes from the rest — and nothing catches it, because every key here is
60
+ * optional, so an omitted spread is not a type error. What it would cost:
61
+ * either the frozen plan loses a field the author wrote (and the canonical
62
+ * hash preimage stops matching the authored intent), or `show` describes
63
+ * something other than what runs.
64
+ *
65
+ * The ABSENCE of both env keys is the default allowlist. The parser rejects an
66
+ * empty `pass_env` outright, so no layer has to distinguish absent from empty.
67
+ */
68
+ export function projectExecCore(exec) {
69
+ return {
70
+ command: [...exec.command],
71
+ ...(exec.cwd ? { cwd: exec.cwd } : {}),
72
+ ...(exec.passEnv && exec.passEnv.length > 0 ? { passEnv: [...exec.passEnv] } : {}),
73
+ ...(exec.inheritEnv ? { inheritEnv: true } : {}),
74
+ };
75
+ }
@@ -6,12 +6,13 @@
6
6
  * unification). One format now: reads the frontmatter+body document via
7
7
  * `parseWorkflow` and projects the validated `WorkflowDocument` down to the
8
8
  * public `ShowResponse` shape and into search hints for the indexer,
9
- * including a compact per-step orchestration summary (engine/model,
10
- * `map.over` reference, route table) when the step declares one.
9
+ * including a compact per-step orchestration summary (engine/model or an exec
10
+ * unit's argv, `map.over` reference, route table) when the step declares one.
11
11
  */
12
12
  import { displayRef } from "../core/asset/resolve-ref.js";
13
13
  import { UsageError } from "../core/errors.js";
14
14
  import { parseWorkflow } from "./parser.js";
15
+ import { projectExecCore } from "./program/schema.js";
15
16
  function shellQuote(value) {
16
17
  return `'${value.replace(/'/g, `'\\''`)}'`;
17
18
  }
@@ -65,16 +66,43 @@ function projectParameters(document) {
65
66
  * compiler does (per-unit override wins), `fanOut.over` carries the raw
66
67
  * reference string, and `route` carries the explicit input + branch table.
67
68
  * Returns undefined when the step declares nothing worth summarizing.
69
+ *
70
+ * ## exec units
71
+ *
72
+ * An `exec` unit runs a shell command and names NO engine — the parser rejects
73
+ * `engine`/`model`/`llm` alongside `exec:`. Merging `defaults.engine` into its
74
+ * summary would make `show` state something untrue about what will run, so the
75
+ * two fields are suppressed and the argv is projected instead, under `exec`
76
+ * (field presence carries the dispatch kind, exactly like `fanOut`/`route`
77
+ * carry the step kind).
78
+ *
79
+ * `timeoutMs` still merges the defaults: an exec unit really does inherit
80
+ * `defaults.timeout`, so that number stays true for it.
81
+ *
82
+ * The argv is shown IN FULL, never clipped. It is authored literally in the
83
+ * asset — the `${{ … }}` interpolation language is gone, so nothing in it is
84
+ * resolved from the environment, from a secret ref, or from a prior step's
85
+ * output — which makes it (a) safe to display, since every byte is already
86
+ * visible in the workflow file `show` is rendering, and (b) pointless to clip:
87
+ * the whole finding this projection answers is `show` describing something
88
+ * other than what runs, and a truncated argv is that same bug in miniature.
89
+ * The parser bounds it anyway (`WORKFLOW_MAX_EXEC_ARGV` entries of
90
+ * `WORKFLOW_MAX_EXEC_ARG_BYTES`), and it is far smaller than the step
91
+ * `instructions` this same projection already carries whole.
68
92
  */
69
93
  function summarizeStepOrchestration(step, defaults) {
70
94
  const unit = step.unit ?? step.map?.unit;
71
- const engine = unit?.engine ?? defaults?.engine;
72
- const model = unit?.model ?? defaults?.model;
95
+ const exec = unit?.exec;
96
+ const engine = exec ? undefined : (unit?.engine ?? defaults?.engine);
97
+ const model = exec ? undefined : (unit?.model ?? defaults?.model);
73
98
  const timeoutMs = unit?.timeoutMs !== undefined ? unit.timeoutMs : defaults?.timeoutMs;
74
99
  const summary = {
75
100
  ...(engine !== undefined ? { engine } : {}),
76
101
  ...(model !== undefined ? { model } : {}),
77
102
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
103
+ // Same projection the draft and the frozen plan use, so what `show` prints
104
+ // cannot drift from what runs.
105
+ ...(exec ? { exec: projectExecCore(exec) } : {}),
78
106
  ...(step.map
79
107
  ? {
80
108
  fanOut: {
@@ -14,6 +14,188 @@ export const WORKFLOW_MAX_JSON_DEPTH = 64;
14
14
  export const WORKFLOW_MAX_MAP_EXPANSION = 10_000;
15
15
  /** Max declared `inputs:` reference strings on one unit/map step. */
16
16
  export const WORKFLOW_MAX_INPUTS = 64;
17
+ // ── Dispatch-significant bounds shared across validation layers ──────────────
18
+ //
19
+ // Defined ONCE here so the three enforcement layers cannot drift:
20
+ // 1. the parser (`../parser.ts`) — line-anchored authoring-time errors,
21
+ // 2. the published JSON Schema (`schemas/akm-workflow.json`) — mirrored
22
+ // `maximum`/`pattern`/`maxLength` values, pinned against these constants
23
+ // by `tests/integration/workflows/schema-drift.test.ts`,
24
+ // 3. the strict frozen-plan decoder (`./ir/schema.ts`) — the corruption
25
+ // gate for persisted plans.
26
+ // A bound enforced only by the decoder surfaces as a terse, unlocated
27
+ // "Invalid frozen workflow plan" at `workflow run` — after lint and
28
+ // `workflow create` already said the document was fine.
29
+ /** Max per-step map fan-out concurrency (also the run-level concurrency ceiling). */
30
+ export const WORKFLOW_MAX_CONCURRENCY = 64;
31
+ /** Max evaluator-optimizer gate loops per step. */
32
+ export const WORKFLOW_MAX_GATE_LOOPS = 100;
33
+ /** Max retry attempts per unit. */
34
+ export const WORKFLOW_MAX_RETRIES = 100;
35
+ /** Max timeout in milliseconds (setTimeout's 32-bit signed ceiling: 2^31-1, ~24.8 days). */
36
+ export const WORKFLOW_MAX_TIMEOUT_MS = 2 ** 31 - 1;
37
+ /** Engine names: lowercase dash-separated runs of letters/digits, starting with a letter. */
38
+ export const WORKFLOW_ENGINE_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
39
+ export const WORKFLOW_MAX_ENGINE_NAME_LENGTH = 63;
40
+ // ── exec (shell) unit bounds ─────────────────────────────────────────────────
41
+ /**
42
+ * Max entries in an exec unit's `command:` argv array. Generous for a real
43
+ * command line, small enough that a corrupted plan cannot ask the OS to spawn
44
+ * a megabyte of arguments.
45
+ */
46
+ export const WORKFLOW_MAX_EXEC_ARGV = 64;
47
+ /** Max UTF-8 bytes of ONE argv entry (well under every platform's ARG_MAX per-arg limit). */
48
+ export const WORKFLOW_MAX_EXEC_ARG_BYTES = 4096;
49
+ /** Max characters of an exec unit's relative `cwd:`. */
50
+ export const WORKFLOW_MAX_EXEC_CWD_LENGTH = 1024;
51
+ /**
52
+ * Max entries in an exec unit's `pass_env:` list.
53
+ *
54
+ * `pass_env` is the "one or two more toolchain variables" escape hatch, not a
55
+ * second way to spell `inherit_env:` — a workflow reaching for more than this
56
+ * many names wants full inheritance and should say so where a reviewer can see
57
+ * it.
58
+ */
59
+ export const WORKFLOW_MAX_EXEC_PASS_ENV = 32;
60
+ /**
61
+ * Grammar for an env var NAME in `pass_env:`. Matches the frozen-plan
62
+ * `envPassthrough` grammar in `ir/schema.ts` so both allowlist surfaces accept
63
+ * exactly the same identifiers.
64
+ */
65
+ export const WORKFLOW_ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
66
+ /**
67
+ * Default wall-clock timeout for an exec unit that declares no `timeout:` and
68
+ * inherits no document `defaults.timeout`.
69
+ *
70
+ * 10 minutes matches `DEFAULT_LLM_TIMEOUT_MS`. Unlike an agent harness (which
71
+ * owns its own lifetime, hence `DEFAULT_AGENT_TIMEOUT_MS === null`), a shell
72
+ * command has NO lifetime discipline of its own: an unbounded default would let
73
+ * a hung `npm install` or an interactive prompt wedge a workflow run forever.
74
+ * Authors who genuinely need an unbounded command write `timeout: none`.
75
+ */
76
+ export const DEFAULT_EXEC_TIMEOUT_MS = 600_000;
77
+ /**
78
+ * Max BYTES of ONE captured pipe an exec unit RETAINS in memory (stdout and
79
+ * stderr are retained separately).
80
+ *
81
+ * This is a RETENTION cap, not a permission to run. Without it the capture is
82
+ * bounded only by the command's exit or the wall timeout, so a command that
83
+ * writes continuously (`yes`, a verbose test loop) grows a string in the akm
84
+ * process until the host runs out of memory — and the default budget gives it
85
+ * ten minutes to do so. What the cap buys is a BOUND where there was none: the
86
+ * retained prefix is promoted into the unit's outcome text and every outcome is
87
+ * held until the step reduces, so the worst case is (units in the STEP × this
88
+ * cap) rather than unbounded — the step's width, not the in-flight width, is
89
+ * what sizes it, up to {@link WORKFLOW_MAX_MAP_EXPANSION}.
90
+ *
91
+ * On reaching the cap the reader switches to DRAIN-AND-DISCARD: it keeps
92
+ * pulling from the pipe (so the child never blocks on backpressure) and stops
93
+ * RETAINING. The command therefore runs to completion and its real exit code
94
+ * stands — a passing-but-chatty test suite is no longer failed over log volume.
95
+ * What overflow costs is honesty about the artifact, and that is paid two ways
96
+ * in `exec/exec-unit.ts`:
97
+ *
98
+ * - no declared `output:` schema → the unit succeeds and its artifact carries
99
+ * an unmistakable {@link WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER} block naming
100
+ * the total and retained byte counts, so truncated text can never be
101
+ * mistaken for the whole output;
102
+ * - a declared `output:` schema → the unit fails `exec_output_limit`, because
103
+ * validating a truncated JSON prefix is meaningless and promoting it would
104
+ * corrupt every downstream reference. That is the residual failure the cap
105
+ * genuinely justifies.
106
+ *
107
+ * 8 MiB is deliberately generous — 8× the whole-row evidence cap
108
+ * ({@link WORKFLOW_MAX_EVIDENCE_JSON_BYTES}), so any output that could survive
109
+ * persistence intact fits many times over, and an ordinary full test/build log
110
+ * is nowhere near it.
111
+ */
112
+ export const WORKFLOW_MAX_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024;
113
+ /**
114
+ * Marker stamped on an exec artifact that was RETAINED ONLY IN PART because the
115
+ * command wrote past {@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES}.
116
+ *
117
+ * Deliberately ugly and unique, exactly like `WORKFLOW_EVIDENCE_TRUNCATED_MARKER`
118
+ * (`runtime/runs.ts`) — the same idiom for the same reason: a truncated value
119
+ * must NEVER be mistakable for a complete one by a downstream
120
+ * `steps.<id>.output` reference, by a gate judge, by `akm workflow status`, or
121
+ * by a human reading the row. The artifact is TEXT here rather than a JSON
122
+ * value, so the marker is appended as a trailing block instead of replacing the
123
+ * value with an envelope: the retained prefix is still genuinely useful (it is
124
+ * the head of a real log), and the block says exactly how much is missing.
125
+ */
126
+ export const WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER = "__akm_exec_output_truncated__";
127
+ // Per-var: Win32 `SetEnvironmentVariable` caps one variable at 32 767 UTF-16
128
+ // code units; measuring UTF-8 bytes is conservative in the right direction.
129
+ // Total: akm's own share of the `CreateProcess` `lpEnvironment` block, which it
130
+ // shares with the allowlist, the unit's `env:` bindings and the argv.
131
+ const EXEC_CONTEXT_LIMITS_WIN32 = {
132
+ perVarBytes: 32_767,
133
+ totalBytes: 64_000,
134
+ source: "Windows caps one environment variable at 32 767 characters (SetEnvironmentVariable)",
135
+ };
136
+ // Per-var: 75% of Linux's `MAX_ARG_STRLEN` (32 pages = 131 072 bytes), leaving
137
+ // margin for the name, `=`, NUL and the kernel's own accounting — the guard must
138
+ // never reject a spawn the platform would have accepted.
139
+ // Total: half of macOS's 256 KiB `ARG_MAX` (the tightest supported total), so
140
+ // the other half remains for the argv, the allowlist and the `env:` bindings.
141
+ const EXEC_CONTEXT_LIMITS_POSIX = {
142
+ perVarBytes: 96 * 1024,
143
+ totalBytes: 128 * 1024,
144
+ source: "Linux caps one argv/environ string at MAX_ARG_STRLEN (32 pages = 131 072 bytes) and macOS caps argv+environ at ARG_MAX (256 KiB)",
145
+ };
146
+ /**
147
+ * The `AKM_*` context ceilings for THIS platform (or an explicitly named one,
148
+ * which is how the tests drive both branches deterministically).
149
+ */
150
+ export function execContextLimits(platform = process.platform) {
151
+ return platform === "win32" ? EXEC_CONTEXT_LIMITS_WIN32 : EXEC_CONTEXT_LIMITS_POSIX;
152
+ }
153
+ /**
154
+ * Max characters of the per-unit human diagnostic — the `error`/stderr text
155
+ * journaled on a failed unit row and rendered by `akm workflow status --units`.
156
+ *
157
+ * ONE constant for the write side (`exec/native-executor.ts`, which clips before
158
+ * journaling) and the read side (`runtime/runs.ts`, which clips whatever a row
159
+ * already holds), so a diagnostic can never be stored larger than the surface
160
+ * that displays it. Long enough for a real stack trace or a compiler's error
161
+ * block; short enough that a runaway command cannot turn the journal into its
162
+ * log file.
163
+ */
164
+ export const WORKFLOW_UNIT_DIAGNOSTIC_CLIP = 2_000;
165
+ /**
166
+ * Truncate to `max` chars with an ellipsis marker.
167
+ *
168
+ * Lives with the bounds rather than with either caller: the write side
169
+ * (`exec/step-work.ts`) and the read side (`runtime/runs.ts`) clip against the
170
+ * same constants, and `runtime/runs.ts` cannot take the helper from
171
+ * `exec/step-work.ts` — that module imports `runtime/runs.ts`.
172
+ */
173
+ export function clip(text, max) {
174
+ return text.length > max ? `${text.slice(0, max)}…` : text;
175
+ }
176
+ // ── Persistence bounds ───────────────────────────────────────────────────────
177
+ /**
178
+ * Max serialized size of one `workflow_run_steps.evidence_json` row value.
179
+ *
180
+ * The promoted step artifact (`evidence.output`) is deliberately NOT clipped
181
+ * when it is built — gates judge the full artifact and downstream
182
+ * `steps.<id>.output` references need it intact — but a `collect`
183
+ * reducer over a fan-out bounded only by {@link WORKFLOW_MAX_MAP_EXPANSION}
184
+ * (10 000 units, each contributing up to a full unit result) would otherwise
185
+ * write an unbounded blob into a single SQLite row. Persistence is therefore
186
+ * bounded here, at the write boundary, by
187
+ * `clipStepEvidenceForPersistence` (`runtime/runs.ts`), which replaces
188
+ * oversized values with an explicitly-marked truncation envelope rather than
189
+ * silently shortening them.
190
+ *
191
+ * 1 MiB is deliberately generous: it is 4× the per-instruction cap and half the
192
+ * whole-plan cap, so no realistic authored workflow reaches it, while a runaway
193
+ * fan-out is still bounded to something SQLite and `akm workflow status` can
194
+ * handle.
195
+ */
196
+ export const WORKFLOW_MAX_EVIDENCE_JSON_BYTES = 1024 * 1024;
197
+ /** Chars of the original value retained (as a marked preview) in a truncation envelope. */
198
+ export const WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS = 1000;
17
199
  export function utf8Bytes(value) {
18
200
  return Buffer.byteLength(value, "utf8");
19
201
  }