akm-cli 0.9.1-beta.1 → 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 (57) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/cli/parse-args.js +7 -1
  3. package/dist/commands/env/child-env.js +14 -0
  4. package/dist/commands/improve/eval-cases.js +2 -0
  5. package/dist/commands/improve/memory/memory-improve.js +1 -0
  6. package/dist/commands/lint/index.js +5 -1
  7. package/dist/commands/sources/add-cli.js +8 -2
  8. package/dist/commands/sources/migration-help.js +12 -3
  9. package/dist/commands/sources/self-update.js +9 -1
  10. package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
  11. package/dist/core/adapter/adapters/akm-lint.js +6 -2
  12. package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
  13. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  14. package/dist/core/asset/frontmatter.js +6 -1
  15. package/dist/core/common.js +81 -3
  16. package/dist/core/config/config-io.js +5 -45
  17. package/dist/core/config/schema/engines.js +14 -3
  18. package/dist/core/extra-params.js +11 -0
  19. package/dist/core/fs-txn.js +15 -2
  20. package/dist/core/json-schema.js +19 -2
  21. package/dist/core/paths.js +16 -2
  22. package/dist/core/redaction.js +22 -1
  23. package/dist/core/state-db.js +1 -0
  24. package/dist/core/write-source.js +26 -2
  25. package/dist/indexer/indexer.js +31 -6
  26. package/dist/indexer/search/db-search.js +17 -2
  27. package/dist/indexer/walk/walker.js +6 -1
  28. package/dist/integrations/agent/detect.js +13 -1
  29. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  30. package/dist/integrations/lockfile.js +10 -0
  31. package/dist/llm/client.js +14 -19
  32. package/dist/llm/embedder.js +23 -3
  33. package/dist/llm/embedders/remote.js +27 -2
  34. package/dist/output/html-render.js +40 -1
  35. package/dist/runtime.js +23 -1
  36. package/dist/scripts/akm-migrate-node.js +303 -107
  37. package/dist/scripts/akm-migrate.js +303 -107
  38. package/dist/setup/setup.js +22 -7
  39. package/dist/sources/providers/git-install.js +25 -2
  40. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  41. package/dist/storage/database.js +71 -12
  42. package/dist/storage/engines/sqlite-migrations.js +61 -2
  43. package/dist/storage/repositories/index-connection.js +11 -1
  44. package/dist/storage/repositories/index-meta-repository.js +11 -0
  45. package/dist/storage/repositories/index-schema.js +17 -2
  46. package/dist/storage/repositories/index-vec-repository.js +43 -5
  47. package/dist/storage/sqlite-pragmas.js +12 -1
  48. package/dist/tasks/runner.js +84 -7
  49. package/dist/tasks/scheduler-invocation.js +19 -0
  50. package/dist/tasks/schema.js +21 -1
  51. package/dist/text-import-hook.mjs +1 -1
  52. package/dist/workflows/exec/native-executor.js +8 -0
  53. package/dist/workflows/exec/step-work.js +10 -2
  54. package/dist/workflows/parser.js +26 -1
  55. package/package.json +1 -1
  56. package/schemas/akm-config.json +10 -5
  57. package/schemas/akm-workflow.json +7 -3
@@ -49,12 +49,13 @@ import { resolveModel } from "../integrations/agent/model-aliases.js";
49
49
  import { executeRunner } from "../integrations/agent/runner-dispatch.js";
50
50
  import { chatCompletion } from "../llm/client.js";
51
51
  import { resolveAssetPath } from "../sources/resolve.js";
52
- import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
52
+ import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, getTaskHistoryRuns, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
53
53
  import { runWorkflowSteps } from "../workflows/exec/run-workflow.js";
54
54
  import { findBareAkmExecutableIndex } from "./command-executable.js";
55
55
  import { collectTaskLogSensitiveValues } from "./log-redaction.js";
56
56
  import { parseTaskDocument } from "./parser.js";
57
57
  import { resolveAkmInvocation } from "./resolve-akm-bin.js";
58
+ import { scheduledTaskContextEnv } from "./scheduler-invocation.js";
58
59
  import { validateTaskId } from "./task-id.js";
59
60
  export const INVALID_TASK_ATTEMPT_ID = "_invalid-task-id";
60
61
  export async function runTask(id, options) {
@@ -175,7 +176,8 @@ async function runCommandTask(input) {
175
176
  throw new Error("invariant: command target");
176
177
  const { cmd } = task.target;
177
178
  const spawnCmd = resolveNestedAkmCommand(cmd);
178
- const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : null;
179
+ // Unset the unattended default; `null` the explicit no-timeout opt-out.
180
+ const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS;
179
181
  const header = `[akm task] task=${task.id} kind=command cmd=${cmd.join(" ")}`;
180
182
  const logLines = [header];
181
183
  const dbLines = [{ line: header }];
@@ -280,6 +282,18 @@ function resolveNestedAkmCommand(cmd) {
280
282
  * the explicit opt-out back to unbounded.
281
283
  */
282
284
  export const DEFAULT_WORKFLOW_TASK_TIMEOUT_MS = 6 * 60 * 60 * 1000;
285
+ /**
286
+ * The same unattended default for command and prompt tasks.
287
+ *
288
+ * The reasoning above is about SCHEDULED runs, not about workflows: nobody is
289
+ * watching, and one wedged run silently stops the schedule. Command tasks
290
+ * defaulted to `null` (no kill timer) and prompt tasks inherited
291
+ * DEFAULT_AGENT_TIMEOUT_MS, also null — so a hung `curl`, a prompting agent
292
+ * waiting on stdin, or a stuck engine wedged the task forever while the
293
+ * workflow arm was protected. Same value, same opt-out: an explicit
294
+ * `timeoutMs:` wins, and `timeoutMs: null` restores unbounded.
295
+ */
296
+ export const DEFAULT_SCHEDULED_TASK_TIMEOUT_MS = DEFAULT_WORKFLOW_TASK_TIMEOUT_MS;
283
297
  async function runWorkflowTask(input) {
284
298
  const { task, logPath, startedAt, now, runWorkflowStepsImpl, historyReserved } = input;
285
299
  if (task.target.kind !== "workflow")
@@ -310,6 +324,13 @@ async function runWorkflowTask(input) {
310
324
  // The prompt path logs the engine-fallback announcement; a workflow-backed
311
325
  // task must leave the same trace rather than silently using a chosen engine.
312
326
  let runWarnings = [];
327
+ // Stamp task-runner provenance for the duration of the run (DRIFT-6), as the
328
+ // command and prompt arms do. This arm executes IN-PROCESS, so the stamp goes
329
+ // on process.env — child akm invocations made by workflow steps inherit it.
330
+ // Without it, workflow-task traffic was recorded as user demand. A more
331
+ // specific stamp already present wins, matching the command arm.
332
+ const priorEventSource = process.env.AKM_EVENT_SOURCE;
333
+ process.env.AKM_EVENT_SOURCE = priorEventSource ?? "task";
313
334
  try {
314
335
  const execution = await runWorkflowStepsImpl({
315
336
  target: workflowTarget.ref,
@@ -331,6 +352,10 @@ async function runWorkflowTask(input) {
331
352
  }
332
353
  finally {
333
354
  deadline.disarm();
355
+ if (priorEventSource === undefined)
356
+ delete process.env.AKM_EVENT_SOURCE;
357
+ else
358
+ process.env.AKM_EVENT_SOURCE = priorEventSource;
334
359
  }
335
360
  // A timeout is a failed ATTEMPT even though the engine stopped cleanly: the
336
361
  // aborted run comes back `active` (resumable), which on its own would map to
@@ -483,7 +508,10 @@ async function runPromptTask(input) {
483
508
  runner = {
484
509
  ...runner,
485
510
  profile: { ...runner.profile, ...(model ? { model, modelIsExact: true } : {}) },
486
- ...(promptTarget.timeoutMs !== undefined ? { timeoutMs: promptTarget.timeoutMs } : {}),
511
+ // Unset the unattended default (DEFAULT_AGENT_TIMEOUT_MS is null, which
512
+ // let a prompting or wedged agent CLI hang the schedule); `null` → the
513
+ // explicit no-timeout opt-out.
514
+ timeoutMs: promptTarget.timeoutMs !== undefined ? promptTarget.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS,
487
515
  };
488
516
  }
489
517
  const promptText = await resolvePromptText(task, stashDir);
@@ -494,7 +522,13 @@ async function runPromptTask(input) {
494
522
  // Stamp task-runner provenance for any akm invocation the agent makes
495
523
  // (DRIFT-6: agent-task traffic must not be recorded as user demand).
496
524
  // Caller-supplied env still wins on conflicts.
497
- env: { AKM_EVENT_SOURCE: "task", ...agentOptions?.env },
525
+ //
526
+ // The agent child env is built from an allowlist, not inherited, so the
527
+ // scheduler's AKM_* directory context was dropped here — an agent's `akm`
528
+ // sub-commands then targeted the DEFAULT stash and DB rather than the
529
+ // ones the scheduled run was configured for. The command arm keeps this
530
+ // context because it inherits process.env; forward it explicitly.
531
+ env: { AKM_EVENT_SOURCE: "task", ...scheduledTaskContextEnv(), ...agentOptions?.env },
498
532
  }, {
499
533
  ...(input.runAgentImpl ? { runAgent: input.runAgentImpl } : {}),
500
534
  llm: async (spec, prompt, options) => {
@@ -598,12 +632,49 @@ function resolveTaskLogPath(logDir, taskId, startedAtIso) {
598
632
  return "";
599
633
  }
600
634
  }
635
+ /**
636
+ * Redact logs.db rows against the SAME contiguous text the file sink sees.
637
+ *
638
+ * The rows arrive already split on "\n" (see {@link streamLines}), but the
639
+ * redaction needles are whole env values — and a needle containing a newline
640
+ * can never match inside a single line. Scrubbing row-by-row therefore left
641
+ * multi-line secrets (PEM keys, multi-line service-account credentials) intact
642
+ * in logs.db while the flat .log was correctly scrubbed, defeating all three
643
+ * tiers including the explicit `redact:` opt-in.
644
+ *
645
+ * Consecutive rows sharing a stream and level are rejoined, scrubbed as one
646
+ * string, and re-split, so a needle spanning lines matches. Collapsing a
647
+ * multi-line secret into a single [REDACTED] row is the intended outcome.
648
+ */
649
+ export function scrubDbLines(dbLines, scrub) {
650
+ const out = [];
651
+ for (let i = 0; i < dbLines.length;) {
652
+ const { stream, level } = dbLines[i];
653
+ let end = i;
654
+ while (end < dbLines.length && dbLines[end].stream === stream && dbLines[end].level === level)
655
+ end++;
656
+ const joined = dbLines
657
+ .slice(i, end)
658
+ .map((entry) => entry.line)
659
+ .join("\n");
660
+ for (const line of scrub(joined).split("\n")) {
661
+ if (line.length > 0)
662
+ out.push({ stream, level, line });
663
+ }
664
+ i = end;
665
+ }
666
+ return out;
667
+ }
601
668
  /** Split captured pipe output into per-line logs.db rows (blank lines dropped). */
602
669
  function streamLines(text, stream, level) {
603
- return text
670
+ return (text
604
671
  .split("\n")
672
+ // Windows child output is CRLF-terminated. Splitting on "\n" alone left a
673
+ // trailing "\r" on every row and turned blank CRLF lines into phantom
674
+ // rows containing just "\r" (length 1 passes the filter below).
675
+ .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
605
676
  .filter((line) => line.length > 0)
606
- .map((line) => ({ stream, level, line }));
677
+ .map((line) => ({ stream, level, line })));
607
678
  }
608
679
  /**
609
680
  * Persist a finished run's log: the flat text file (so `log_path` in
@@ -661,7 +732,7 @@ function persistRunLog(input) {
661
732
  ? redactSensitiveText(redactCredentialPatterns(text), sensitive)
662
733
  : redactCredentialPatterns(text);
663
734
  const fileText = scrub(input.fileText);
664
- const dbLines = input.dbLines.map((entry) => ({ ...entry, line: scrub(entry.line) }));
735
+ const dbLines = scrubDbLines(input.dbLines, scrub);
665
736
  if (input.logPath) {
666
737
  try {
667
738
  // Written at the process umask. #756 pinned 0600/0700 here; that went out
@@ -820,6 +891,12 @@ export function readTaskHistory(options = {}) {
820
891
  if (options.limit === 0)
821
892
  return [];
822
893
  if (options.id) {
894
+ // An id-scoped query used the single-row helper, so `--limit` was silently
895
+ // discarded and `akm task history --id X --limit 20` always returned one
896
+ // run. The CLI documents --limit as "Maximum rows to return"; honour it.
897
+ if (options.limit !== undefined && options.limit > 0) {
898
+ return getTaskHistoryRuns(db, options.id, options.limit).map(taskHistoryRowToResult);
899
+ }
823
900
  const row = getTaskHistory(db, options.id);
824
901
  return row ? [taskHistoryRowToResult(row)] : [];
825
902
  }
@@ -14,6 +14,25 @@ export const SCHEDULED_TASK_CONTEXT_KEYS = [
14
14
  "AKM_CACHE_DIR",
15
15
  "AKM_STATE_DIR",
16
16
  ];
17
+ /**
18
+ * The AKM_* directory context currently in effect, as a plain env fragment.
19
+ *
20
+ * A scheduled run restores these into `process.env` from its
21
+ * `--scheduler-context` descriptor precisely because such installs have
22
+ * non-default directories. Paths that build a child environment from an
23
+ * allowlist rather than inheriting (the agent spawn) must forward this
24
+ * explicitly, or the child's `akm` sub-commands silently target the default
25
+ * stash and DB.
26
+ */
27
+ export function scheduledTaskContextEnv(env = process.env) {
28
+ const out = {};
29
+ for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
30
+ const value = env[key];
31
+ if (value)
32
+ out[key] = value;
33
+ }
34
+ return out;
35
+ }
17
36
  export const SCHEDULER_CONTEXT_ARG = "--scheduler-context";
18
37
  /** Resolve the complete non-secret AKM directory context captured by schedulers. */
19
38
  export function resolveScheduledTaskContext(env = process.env, platform = process.platform) {
@@ -52,8 +52,28 @@ export const TASK_MAX_REDACT_NAMES = WORKFLOW_MAX_EXEC_PASS_ENV;
52
52
  * at runtime with TASK_SCHEMA_VERSION_UNSUPPORTED). `schemas/akm-task.json`
53
53
  * agrees with the parser: `required: [version, schedule]`, `version:
54
54
  * {const: 2}`, `enabled` optional but boolean. Target-arity rules stay with
55
- * each caller (they legitimately differ: at-least-one vs exactly-one).
55
+ * each caller (they legitimately differ: at-least-one vs exactly-one), but
56
+ * what COUNTS as a target is shared — see {@link isPresentTarget}.
56
57
  */
58
+ /**
59
+ * Whether a task-target field counts as declared.
60
+ *
61
+ * The arity rules differ between the two linters, but the presence test must
62
+ * not: the runtime parser treats `""` as absent, so a key-existence check let
63
+ * `workflow: ""` lint clean and then die with MISSING_REQUIRED_ARGUMENT. An
64
+ * array target (`command`) counts only when it has entries, for the same
65
+ * reason. Kept beside {@link taskFieldProblems} so all three definitions of
66
+ * "valid task" stay in one file.
67
+ */
68
+ export function isPresentTarget(value) {
69
+ if (value === undefined || value === null)
70
+ return false;
71
+ if (typeof value === "string")
72
+ return value.trim() !== "";
73
+ if (Array.isArray(value))
74
+ return value.length > 0;
75
+ return true;
76
+ }
57
77
  export function taskFieldProblems(data) {
58
78
  const problems = [];
59
79
  if (data.version !== TASK_SCHEMA_VERSION)
@@ -16,7 +16,7 @@
16
16
  import { readFile } from "node:fs/promises";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
- const TEXT_EXTENSIONS = new Set([".md", ".xml", ".txt", ".sql", ".yaml", ".yml"]);
19
+ const TEXT_EXTENSIONS = new Set([".md", ".xml", ".txt", ".sql", ".yaml", ".yml", ".html"]);
20
20
 
21
21
  function isTextImport(url, importAttributes) {
22
22
  if (importAttributes && importAttributes.type === "text") return true;
@@ -948,6 +948,14 @@ async function dispatchUnit(request, dispatcher) {
948
948
  return {
949
949
  unitId: request.unitId,
950
950
  ok: false,
951
+ // NOTE: `validation_error` is deliberately outside PROGRAM_RETRY_REASONS,
952
+ // so no `retry.on:` can name it and a schema-violating unit is not
953
+ // re-run — see "fails with `validation_error` and is NOT re-run" in
954
+ // tests/integration/workflows/exec-unit.test.ts. A sweep finding
955
+ // proposed mapping it onto `llm_invalid_json` (which the parser accepts
956
+ // but this path never emits) to make such failures retryable; that is a
957
+ // behaviour change against an intentional design, not a bug fix, so it
958
+ // is left alone. Reconciling the vocabulary is a 0.9.2 decision.
951
959
  failureReason: structured.reason,
952
960
  error: structured.errors.join("; "),
953
961
  text: structured.raw,
@@ -667,8 +667,16 @@ export function buildEvidence(units, reducer, isFanOut) {
667
667
  evidence.voteError = `Vote reducer tied at ${ranked[0].count} vote(s) — no majority.`;
668
668
  }
669
669
  else {
670
- evidence.vote = { winner: ranked[0].value, votes: ranked[0].count, total: units.length };
671
- evidence.output = ranked[0].value;
670
+ const winner = ranked[0].value;
671
+ evidence.vote = { winner, votes: ranked[0].count, total: units.length };
672
+ // An empty free-text unit normalizes to absent text, so its vote value is
673
+ // `undefined`. Assigning that to `evidence.output` made the key vanish
674
+ // under JSON serialization: a LIVE run then saw `output` absent (and fell
675
+ // back to the whole evidence envelope), while a RESUMED run rehydrated the
676
+ // same step from the journal and produced a different artifact — with the
677
+ // raw envelope exposed as `steps.<id>.output`. Normalize to an explicit
678
+ // empty string so both paths promote the same value.
679
+ evidence.output = winner === undefined ? "" : winner;
672
680
  }
673
681
  }
674
682
  return evidence;
@@ -655,7 +655,16 @@ function parseUnit(ctx, raw, path, stepLabel) {
655
655
  unit.output = output;
656
656
  if (raw.env !== undefined) {
657
657
  if (Array.isArray(raw.env) && raw.env.every((entry) => typeof entry === "string" && entry.trim() !== "")) {
658
- unit.env = raw.env.map((entry) => entry.trim());
658
+ const envRefs = raw.env.map((entry) => entry.trim());
659
+ // Same decoder uniqueness requirement as `inputs` above: duplicates linted
660
+ // clean and then failed the run with an unlocated frozen-plan error.
661
+ const duplicate = envRefs.find((ref, i) => envRefs.indexOf(ref) !== i);
662
+ if (duplicate !== undefined) {
663
+ ctx.err([...path, "env"], `${stepLabel} "env" contains a duplicate entry: "${duplicate}".`);
664
+ }
665
+ else {
666
+ unit.env = envRefs;
667
+ }
659
668
  }
660
669
  else {
661
670
  ctx.err([...path, "env"], `${stepLabel} "env" must be a list of non-empty env asset refs.`);
@@ -859,6 +868,13 @@ function parseRoute(ctx, raw, path, stepLabel, stepIndex, routeChecks) {
859
868
  return;
860
869
  }
861
870
  const match = String(branch.match);
871
+ // The frozen-plan decoder requires every `when` key to be non-empty, so an
872
+ // empty match parsed and linted clean and then failed the run with an
873
+ // unlocated "Invalid frozen workflow plan". Reject it here, at the line.
874
+ if (match === "") {
875
+ ctx.errAtLine(matchLine, `${stepLabel} "when[${i}].match" must not be empty.`);
876
+ return;
877
+ }
862
878
  if (typeof branch.step !== "string" || branch.step.trim() === "") {
863
879
  ctx.err([...branchPath, "step"], `${stepLabel} "when[${i}].step" must be a step id string.`);
864
880
  return;
@@ -901,12 +917,21 @@ function parseInputs(ctx, raw, path, stepLabel) {
901
917
  ctx.err(path, `${stepLabel} "inputs" must contain at most ${WORKFLOW_MAX_INPUTS} entries.`);
902
918
  }
903
919
  const out = [];
920
+ // The frozen-plan decoder requires uniqueness (validateStringArray(..., true)),
921
+ // so duplicates parsed and linted clean and then failed the run with an
922
+ // unlocated "Invalid frozen workflow plan". Reject them here, on the entry.
923
+ const seen = new Set();
904
924
  raw.forEach((entry, i) => {
905
925
  if (typeof entry !== "string" || entry.trim() === "") {
906
926
  ctx.err([...path, i], `${stepLabel} "inputs[${i}]" must be a non-empty reference string.`);
907
927
  return;
908
928
  }
909
929
  const value = entry.trim();
930
+ if (seen.has(value)) {
931
+ ctx.err([...path, i], `${stepLabel} "inputs[${i}]" duplicates an earlier entry: "${value}".`);
932
+ return;
933
+ }
934
+ seen.add(value);
910
935
  checkReferenceSyntax(ctx, value, [...path, i], `${stepLabel} "inputs[${i}]"`);
911
936
  out.push(value);
912
937
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.1-beta.1",
3
+ "version": "0.9.1-beta.2",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [
@@ -45,7 +45,8 @@
45
45
  "anyOf": [
46
46
  {
47
47
  "type": "integer",
48
- "exclusiveMinimum": 0
48
+ "exclusiveMinimum": 0,
49
+ "maximum": 2147483647
49
50
  },
50
51
  {
51
52
  "type": "null"
@@ -122,7 +123,8 @@
122
123
  "anyOf": [
123
124
  {
124
125
  "type": "integer",
125
- "exclusiveMinimum": 0
126
+ "exclusiveMinimum": 0,
127
+ "maximum": 2147483647
126
128
  },
127
129
  {
128
130
  "type": "null"
@@ -1697,7 +1699,8 @@
1697
1699
  "anyOf": [
1698
1700
  {
1699
1701
  "type": "integer",
1700
- "exclusiveMinimum": 0
1702
+ "exclusiveMinimum": 0,
1703
+ "maximum": 2147483647
1701
1704
  },
1702
1705
  {
1703
1706
  "type": "null"
@@ -1774,7 +1777,8 @@
1774
1777
  "anyOf": [
1775
1778
  {
1776
1779
  "type": "integer",
1777
- "exclusiveMinimum": 0
1780
+ "exclusiveMinimum": 0,
1781
+ "maximum": 2147483647
1778
1782
  },
1779
1783
  {
1780
1784
  "type": "null"
@@ -3332,7 +3336,8 @@
3332
3336
  "anyOf": [
3333
3337
  {
3334
3338
  "type": "integer",
3335
- "exclusiveMinimum": 0
3339
+ "exclusiveMinimum": 0,
3340
+ "maximum": 2147483647
3336
3341
  },
3337
3342
  {
3338
3343
  "type": "null"
@@ -309,11 +309,12 @@
309
309
  },
310
310
  "env": {
311
311
  "type": "array",
312
+ "uniqueItems": true,
312
313
  "items": {
313
314
  "type": "string",
314
315
  "minLength": 1
315
316
  },
316
- "description": "Env asset refs injected into the dispatched unit env."
317
+ "description": "Env asset refs injected into the dispatched unit env. Entries must be unique: the frozen-plan decoder rejects duplicates at run start."
317
318
  },
318
319
  "isolation": {
319
320
  "$ref": "#/definitions/isolation"
@@ -350,7 +351,9 @@
350
351
  "additionalProperties": false,
351
352
  "properties": {
352
353
  "match": {
353
- "type": ["string", "number", "boolean"]
354
+ "type": ["string", "number", "boolean"],
355
+ "minLength": 1,
356
+ "description": "Branch key. An empty string is rejected: the frozen-plan decoder requires every `when` key to be non-empty."
354
357
  },
355
358
  "step": {
356
359
  "$ref": "#/definitions/identifier"
@@ -397,10 +400,11 @@
397
400
  "type": "array",
398
401
  "minItems": 1,
399
402
  "maxItems": 64,
403
+ "uniqueItems": true,
400
404
  "items": {
401
405
  "$ref": "#/definitions/reference"
402
406
  },
403
- "description": "Prior-step artifacts this unit/map step consumes, as reference strings (sub-paths legal). Attached to the dispatched unit as structured context; never spliced into instructions."
407
+ "description": "Prior-step artifacts this unit/map step consumes, as reference strings (sub-paths legal). Attached to the dispatched unit as structured context; never spliced into instructions. Entries must be unique: the frozen-plan decoder rejects duplicates at run start."
404
408
  },
405
409
  "step": {
406
410
  "type": "object",