akm-cli 0.9.0-rc.0 → 0.9.0-rc.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 (90) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  3. package/dist/commands/env/env-binding.js +95 -0
  4. package/dist/commands/env/env-cli.js +8 -65
  5. package/dist/commands/sources/migration-help.js +7 -4
  6. package/dist/commands/workflow-cli.js +276 -12
  7. package/dist/core/asset/asset-spec.js +58 -1
  8. package/dist/core/config/config-schema.js +21 -0
  9. package/dist/core/json-schema.js +142 -0
  10. package/dist/indexer/db/db.js +2 -1
  11. package/dist/indexer/walk/matchers.js +39 -0
  12. package/dist/integrations/agent/builders.js +7 -5
  13. package/dist/integrations/agent/model-aliases.js +9 -0
  14. package/dist/integrations/agent/profiles.js +72 -5
  15. package/dist/integrations/agent/runner-dispatch.js +25 -1
  16. package/dist/integrations/agent/spawn.js +137 -14
  17. package/dist/integrations/harnesses/aider/agent-builder.js +113 -0
  18. package/dist/integrations/harnesses/aider/index.js +58 -0
  19. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  20. package/dist/integrations/harnesses/amazonq/agent-builder.js +153 -0
  21. package/dist/integrations/harnesses/amazonq/index.js +59 -0
  22. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  23. package/dist/integrations/harnesses/claude/agent-builder.js +45 -6
  24. package/dist/integrations/harnesses/claude/index.js +25 -23
  25. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  26. package/dist/integrations/harnesses/codex/agent-builder.js +137 -0
  27. package/dist/integrations/harnesses/codex/index.js +63 -0
  28. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  29. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  30. package/dist/integrations/harnesses/copilot/index.js +60 -0
  31. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  32. package/dist/integrations/harnesses/gemini/agent-builder.js +121 -0
  33. package/dist/integrations/harnesses/gemini/index.js +60 -0
  34. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  35. package/dist/integrations/harnesses/index.js +26 -4
  36. package/dist/integrations/harnesses/opencode/index.js +15 -16
  37. package/dist/integrations/harnesses/opencode-sdk/harness.js +65 -0
  38. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  39. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +581 -91
  40. package/dist/integrations/harnesses/openhands/agent-builder.js +126 -0
  41. package/dist/integrations/harnesses/openhands/index.js +58 -0
  42. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  43. package/dist/integrations/harnesses/pi/agent-builder.js +104 -0
  44. package/dist/integrations/harnesses/pi/index.js +58 -0
  45. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  46. package/dist/integrations/harnesses/types.js +7 -0
  47. package/dist/integrations/session-logs/index.js +24 -11
  48. package/dist/output/renderers.js +3 -2
  49. package/dist/output/shapes/passthrough.js +4 -0
  50. package/dist/output/text/helpers.js +212 -1
  51. package/dist/output/text/workflow.js +3 -1
  52. package/dist/schemas/akm-config.json +14225 -0
  53. package/dist/schemas/akm-workflow.json +328 -0
  54. package/dist/scripts/migrate-storage.js +1034 -6973
  55. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +768 -10
  56. package/dist/storage/repositories/workflow-runs-repository.js +189 -1
  57. package/dist/text-import-hook.mjs +1 -1
  58. package/dist/workflows/authoring/authoring.js +123 -10
  59. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  60. package/dist/workflows/cli.js +4 -0
  61. package/dist/workflows/db.js +135 -0
  62. package/dist/workflows/exec/brief.js +484 -0
  63. package/dist/workflows/exec/native-executor.js +975 -0
  64. package/dist/workflows/exec/param-secrets.js +115 -0
  65. package/dist/workflows/exec/report.js +1295 -0
  66. package/dist/workflows/exec/run-workflow.js +596 -0
  67. package/dist/workflows/exec/scheduler.js +100 -0
  68. package/dist/workflows/exec/step-work.js +1156 -0
  69. package/dist/workflows/exec/unit-writer.js +23 -0
  70. package/dist/workflows/exec/watch.js +116 -0
  71. package/dist/workflows/exec/worktree.js +171 -0
  72. package/dist/workflows/ir/compile.js +388 -0
  73. package/dist/workflows/ir/params.js +54 -0
  74. package/dist/workflows/ir/plan-hash.js +33 -0
  75. package/dist/workflows/ir/schema.js +4 -0
  76. package/dist/workflows/parser.js +3 -1
  77. package/dist/workflows/program/expressions.js +369 -0
  78. package/dist/workflows/program/parser.js +760 -0
  79. package/dist/workflows/program/project.js +105 -0
  80. package/dist/workflows/program/schema.js +54 -0
  81. package/dist/workflows/renderer.js +82 -5
  82. package/dist/workflows/runtime/agent-identity.js +59 -14
  83. package/dist/workflows/runtime/runs.js +206 -36
  84. package/dist/workflows/runtime/unit-checkin.js +45 -0
  85. package/dist/workflows/runtime/workflow-asset-loader.js +64 -1
  86. package/dist/workflows/validate-summary.js +24 -3
  87. package/dist/workflows/validator.js +1 -1
  88. package/docs/data-and-telemetry.md +2 -1
  89. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  90. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,129 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - **Workflow orchestration engine (experimental).** akm can now execute
12
+ multi-step workflows as deterministic **YAML programs**, driven either by a
13
+ native engine or by any agent session. This is a new, self-contained
14
+ surface; classic linear **markdown workflows and the stable workflow CLI
15
+ contract (`start`/`next`/`complete`/`status`/`list`) are unchanged**. What
16
+ ships:
17
+ - **Authoring.** Orchestrated workflows are YAML programs
18
+ (`workflows/*.yaml`, `version: 1`) validated against a published JSON
19
+ Schema (`schemas/akm-workflow.json`) by `akm workflow validate`; scaffold
20
+ one with `akm workflow template --yaml` or `akm workflow create
21
+ <name>.yaml`. A closed `${{ … }}` expression language (exactly
22
+ `params.<name>`, `steps.<id>.output.<path>`, `item`, `item_index`, parsed
23
+ once into an AST) wires steps together. `validate` also surfaces non-fatal
24
+ **warnings** (a step with no typed `output:` schema; a `${{ params.<name> }}`
25
+ reference to a param the declared `params:` block omits) that never change
26
+ the frozen plan or its hash. Creating a workflow whose canonical name
27
+ collides with an existing asset of a **different** extension (`foo.yaml`
28
+ while `foo.md` exists, or vice-versa) is refused, since the two would
29
+ silently shadow each other.
30
+ - **Compilation + frozen plans.** `akm workflow start` compiles the program
31
+ into a backend-agnostic Workflow Plan Graph IR (`src/workflows/ir/`) and
32
+ freezes it on the run row (`plan_json` + `plan_hash`); a run executes the
33
+ plan compiled at start, and edits to the source file require a new run.
34
+ - **Per-step orchestration.** A step can declare a runner, model, timeout,
35
+ fan-out (`map`/`over` with a `concurrency` cap and a `collect` | `vote`
36
+ reducer), a typed `output` JSON Schema (validated via a `runStructured`
37
+ retry-with-feedback loop), `env` bindings (resolved through the existing
38
+ `akm env run` machinery — secret tokens, dangerous-key policy, keys-only
39
+ audit events), classify-and-dispatch `route` steps, and `depends_on`
40
+ ordering.
41
+ - **Determinism + replay.** Journaled unit identity is content-derived
42
+ (`<step>:<sha256(item)[:12]>`, `:solo` for a single unit), so cached
43
+ results survive item-list reordering; a completed unit whose recorded
44
+ inputs differ on replan is a hard **replay-divergence** failure naming the
45
+ unit, never a silent re-dispatch. Every unit is recorded in the new
46
+ `workflow_run_units` table behind a serialized writer queue.
47
+ - **Execution (`akm workflow run`).** A semaphore-bounded scheduler fans a
48
+ step's units out (concurrency defaults to 1 per the local-model
49
+ LLM-defaults rule, capped by the engine-wide `workflow.maxConcurrency`
50
+ config knob — unset = `min(16, max(1, cores − 2))`, explicit values
51
+ clamped to `[1, 64]`), enforces per-unit
52
+ timeouts (default 10 m) and run **budget ceilings** (`budget.max_tokens` /
53
+ `budget.max_units`, seeded from the journal so they span resumes), and
54
+ advances the run **strictly through `completeWorkflowStep`** so completion
55
+ gates are never bypassed. Every dispatched unit gets a standard akm
56
+ preamble (run/unit ids, knowledge + env/secret + reporting contract).
57
+ - **Typed artifacts + honest gates.** A step's promoted artifact is
58
+ validated against its declared `output` schema before completion; a
59
+ criteria-bearing gate judges that **artifact** (canonical JSON, clipped)
60
+ rather than machine prose, and each engine-driven evaluation is journaled
61
+ as a gate unit row. `gate.max_loops` bounds an evaluator-optimizer retry
62
+ loop (feedback threaded into re-dispatched unit prompts); `gate.required`
63
+ (or the run-wide `--require-gates`) makes a gate with no available judge
64
+ **block** for a human instead of failing open.
65
+ - **Failure policy.** Per-unit `on_error: fail | continue` (fail-fast
66
+ default) plus bounded `retry: { max, on: [<failure_reason>…] }` keyed on
67
+ the persisted failure taxonomy.
68
+ - **Isolation + leases.** `isolation: worktree` runs each file-mutating unit
69
+ in a fresh detached git worktree (journaled path; clean trees
70
+ auto-removed, dirty ones retained). A run **lease** (`engine_lease_*`)
71
+ ensures a run is driven by exactly one engine or one external driver at a
72
+ time; manual `complete` is refused while a live engine lease is held.
73
+ - **Harness-neutral driver protocol.** An orchestrated run can be driven by
74
+ ANY agent session (Claude Code, opencode, Codex, a human at a shell), not
75
+ only the native engine. **`akm workflow brief <run>`** is read-only (takes
76
+ no lease, mutates nothing) and emits the active step's expected work-list —
77
+ per-unit content-derived id, resolved instructions + input hash
78
+ (byte-identical to the engine's dispatch), output schema, env binding
79
+ NAMES only, and the exact `report` command lines. **`akm workflow report
80
+ <run> --unit <id> --status completed|failed|running`** is the one mutating
81
+ verb, ingesting a unit's result through the SAME shared step semantics the
82
+ engine uses (idempotent same-hash re-report, replay-divergence on a
83
+ differing hash, budget enforcement, schema validation, and the
84
+ artifact-judged gate/`max_loops` completion path). `--status running`
85
+ claims/heartbeats a unit for stale-driver detection without advancing the
86
+ spine; `--rerun` records a fresh attempt for a failed unit (carrying its
87
+ prior token total forward). Every report command carries `--expect-step`
88
+ (refused if the spine has moved since the brief), and `report --settle`
89
+ (no `--unit`) advances a step that dispatches no reportable units — a
90
+ params-only route, an empty fan-out, or an all-unresolvable work-list — so
91
+ a driver is never wedged. The engine and the brief/report surfaces are
92
+ proven to produce **identical unit graphs**
93
+ (`tests/workflows/conformance/driver-parity.test.ts`).
94
+ - **Observability.** `akm workflow watch <run>` tails the run's `workflow_*`
95
+ / `workflow_unit_*` events as NDJSON (`--stream` foreground-polls to a
96
+ terminal status, no daemon); `akm workflow status --units` lists per-unit
97
+ diagnostics (failure reason + result/error text) without feeding them into
98
+ the deterministic artifact graph; unit lifecycle emits
99
+ `workflow_unit_started` / `workflow_unit_finished` events carrying
100
+ ids/status/enums only. `akm show workflow:<name>` summarizes each step's
101
+ orchestration.
102
+ - **Harness adapters.** Seven local coding-agent CLIs are first-class
103
+ dispatch targets — Codex, Copilot CLI, Pi, Gemini, Aider, Amazon Q, and
104
+ OpenHands — each registered in `HARNESS_REGISTRY` with a command builder +
105
+ result extractor; agent-identity detection and the session-log provider
106
+ list are derived from the registry, and harness-native session ids are
107
+ journaled opportunistically for future session reuse.
108
+ - **Storage.** Additive `workflow.db` migrations 004–009 (unit journal,
109
+ harness session ids, frozen plans + run leases, check-in heartbeats,
110
+ attempt counter, unit claims); migrations 001–003 are untouched and linear
111
+ workflows behave exactly as before.
112
+
113
+ See "Orchestrated steps" and "Driving a run from any agent" in
114
+ `docs/features/workflows.md`, the redesign addendum in
115
+ `docs/technical/akm-workflows-orchestration-plan.md`, and `STABILITY.md`
116
+ (Experimental).
117
+ - **`fable` built-in model alias** — resolves to `claude-fable-5`
118
+ (`opencode/claude-fable-5` on opencode); recommended resolution target for
119
+ the `deep` workflow model tier.
120
+
121
+ ### Fixed
122
+
123
+ - **Check-in directives now survive plain-text output and `workflow
124
+ status`** (check-in review C2/M1): `formatWorkflowNextPlain` and
125
+ `formatWorkflowStatusPlain` render the `CONTINUE` directive, and every
126
+ run-detail response (status/start/complete) evaluates the check-in instead
127
+ of only `workflow next`.
128
+ - Workflow frontmatter validator error message now lists the actually-allowed
129
+ keys (`name`, `updated` were missing); removed the documented-but-nonexistent
130
+ `akm workflow step` alias from `docs/features/workflows.md`.
131
+
9
132
  ## [0.9.0] — 2026-06-30
10
133
 
11
134
  ### Fixed
@@ -0,0 +1,26 @@
1
+ You are executing one unit of an akm workflow run.
2
+
3
+ - Workflow run: {{RUN_ID}}
4
+ - Step: {{STEP_ID}}
5
+ - Unit: {{UNIT_ID}}
6
+ - Run parameters: {{PARAMS_JSON}}
7
+
8
+ Ground rules for this unit:
9
+
10
+ 1. Pull knowledge on demand instead of guessing: `akm search '<query>'` to find
11
+ relevant assets, `akm show <ref>` to read one, `akm curate '<query>'` to let
12
+ akm select the best match. Only pull what this unit actually needs.
13
+ 2. Environment values and secrets are provided through your process
14
+ environment when the workflow declares them. Never print secret values to
15
+ stdout or embed them in your answer. If you need an env file path, use
16
+ `akm env path <ref>`; never `cat` secrets.
17
+ 3. Do exactly the work described in the instructions below — no more. Other
18
+ units may be running concurrently on sibling items; do not touch files or
19
+ state outside the scope this unit was given.
20
+ 4. Your final output IS the unit result recorded by the engine. When a JSON
21
+ schema is requested, respond with ONLY the JSON value (no prose, no code
22
+ fences). Otherwise finish with a concise factual summary of what you did.
23
+
24
+ Unit instructions follow.
25
+
26
+ ---
@@ -0,0 +1,95 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Env-ref → resolved values, shared between `akm env run` and the workflow
6
+ * engine's per-unit env bindings (orchestration plan, *Dispatch context*).
7
+ *
8
+ * Extracted from `env-cli.ts`'s `runEnvInjected` so the native executor
9
+ * REUSES the exact same machinery instead of forking it. Every safety
10
+ * invariant carries over unchanged:
11
+ *
12
+ * - `${secret:NAME}` tokens resolve against the env's own stash; a missing
13
+ * secret is a hard error and NOTHING is injected (no partial injection).
14
+ * - Known process-hijacking keys (LD_PRELOAD, PATH, …) are blocked for
15
+ * third-party-sourced stashes and warned about for first-party ones.
16
+ * - An `env_access` audit event records key NAMES only, never values.
17
+ * - Resolved values must never be written to stdout or logs by callers.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { resolveAssetPathFromName } from "../../core/asset/asset-spec.js";
22
+ import { isWithin } from "../../core/common.js";
23
+ import { makeEnvRef, resolveEnvPath } from "../../core/env-secret-ref.js";
24
+ import { NotFoundError, UsageError } from "../../core/errors.js";
25
+ import { appendEvent } from "../../core/events.js";
26
+ import { isDangerousEnvKey } from "../lint/env-key-rules.js";
27
+ import { loadEnv, resolveSecretTokens } from "./env.js";
28
+ import { readValue } from "./secret.js";
29
+ /**
30
+ * Resolve an env ref to its injectable values: load the file, apply key
31
+ * filtering, substitute `${secret:NAME}` tokens from the sibling secrets
32
+ * directory, enforce the dangerous-key policy, and append the keys-only
33
+ * `env_access` audit event.
34
+ */
35
+ export function resolveEnvBinding(target, options = {}) {
36
+ const warn = options.warn ?? ((message) => process.stderr.write(`warning: ${message}\n`));
37
+ const { name, absPath, source } = resolveEnvPath(target);
38
+ const envRef = makeEnvRef(name, source);
39
+ if (!fs.existsSync(absPath)) {
40
+ throw new NotFoundError(`Env not found: ${envRef}`);
41
+ }
42
+ const allValues = loadEnv(absPath);
43
+ // Value-safe key filtering (--only / --except operate on key NAMES only).
44
+ let envValues = allValues;
45
+ if (options.only && options.except) {
46
+ throw new UsageError("Pass only one of --only or --except.", "INVALID_FLAG_VALUE");
47
+ }
48
+ if (options.only) {
49
+ const wanted = new Set(options.only);
50
+ const missing = options.only.filter((k) => !(k in allValues));
51
+ if (missing.length > 0) {
52
+ warn(`--only key(s) not present in ${envRef}: ${missing.join(", ")}`);
53
+ }
54
+ envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => wanted.has(k)));
55
+ }
56
+ else if (options.except) {
57
+ const excluded = new Set(options.except);
58
+ envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => !excluded.has(k)));
59
+ }
60
+ // Substitute `${secret:NAME}` tokens with the sibling secret asset in the
61
+ // SAME stash. A missing secret is a hard error — inject NOTHING.
62
+ const secretsRoot = path.join(source.path, "secrets");
63
+ const resolveSecret = (secretName) => {
64
+ const secretPath = resolveAssetPathFromName("secret", secretsRoot, secretName);
65
+ // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
66
+ if (!isWithin(secretPath, secretsRoot)) {
67
+ throw new UsageError(`Secret name "${secretName}" escapes the secrets directory.`);
68
+ }
69
+ if (!fs.existsSync(secretPath))
70
+ return undefined;
71
+ // Match `secret run`: read utf8, do not trim (stay consistent with that path).
72
+ return readValue(secretPath).toString("utf8");
73
+ };
74
+ const { values: substituted, missing } = resolveSecretTokens(envValues, resolveSecret);
75
+ if (missing.length > 0) {
76
+ throw new NotFoundError(`Env "${envRef}" references secret(s) not found in its stash: ${missing.map((n) => `secret:${n}`).join(", ")}. Nothing was injected.`, "FILE_NOT_FOUND", `Create the missing secret, e.g. \`akm secret set secret:${missing[0]}\`.`);
77
+ }
78
+ envValues = substituted;
79
+ const keys = Object.keys(envValues);
80
+ // Scan injected keys for known process-hijacking variables. Block for
81
+ // third-party-sourced stashes (origin has a registryId); warn for the
82
+ // operator's own first-party stash, where they own the file.
83
+ const dangerous = keys.filter(isDangerousEnvKey);
84
+ if (dangerous.length > 0) {
85
+ const detail = `Env "${envRef}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
86
+ if (source.registryId) {
87
+ throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
88
+ ` Review the file, then copy the values into a first-party env if you trust them.`, "INVALID_FLAG_VALUE");
89
+ }
90
+ warn(`${detail} Injecting anyway (first-party stash).`);
91
+ }
92
+ // Audit trail: keys only, never values.
93
+ appendEvent({ eventType: "env_access", ref: envRef, metadata: { keys } });
94
+ return { ref: envRef, values: envValues, keys };
95
+ }
@@ -27,7 +27,6 @@ import { isWithin, writeFileAtomic } from "../../core/common.js";
27
27
  import { loadConfig } from "../../core/config/config.js";
28
28
  import { findEnvSource, makeEnvRef, parseEnvRef, resolveEnvPath } from "../../core/env-secret-ref.js";
29
29
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
30
- import { appendEvent } from "../../core/events.js";
31
30
  import { isQuiet } from "../../core/warn.js";
32
31
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
33
32
  import { parseFlagValue } from "../../output/context.js";
@@ -232,64 +231,14 @@ async function runEnvInjected(target, opts) {
232
231
  }
233
232
  throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
234
233
  }
235
- const { loadEnv } = await import("./env.js");
236
- const allValues = loadEnv(absPath);
237
- // Value-safe key filtering (--only / --except operate on key NAMES only).
238
- let envValues = allValues;
239
- if (opts.only && opts.except) {
240
- throw new UsageError("Pass only one of --only or --except.", "INVALID_FLAG_VALUE");
241
- }
242
- if (opts.only) {
243
- const wanted = new Set(opts.only);
244
- const missing = opts.only.filter((k) => !(k in allValues));
245
- if (missing.length > 0) {
246
- process.stderr.write(`warning: --only key(s) not present in ${makeEnvRef(name, source)}: ${missing.join(", ")}\n`);
247
- }
248
- envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => wanted.has(k)));
249
- }
250
- else if (opts.except) {
251
- const excluded = new Set(opts.except);
252
- envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => !excluded.has(k)));
253
- }
254
- // Substitute `${secret:NAME}` tokens in values with the value of the sibling
255
- // secret asset in the SAME stash. The lookup is injected so commands/env.ts
256
- // keeps its narrow dependency surface; we resolve each name against this env's
257
- // own `source`. A missing secret is a hard error — inject NOTHING (no partial
258
- // injection). Resolved values are never logged or printed.
259
- const { resolveSecretTokens } = await import("./env.js");
260
- const { readValue } = await import("./secret.js");
261
- const secretsRoot = path.join(source.path, "secrets");
262
- const resolveSecret = (secretName) => {
263
- const secretPath = resolveAssetPathFromName("secret", secretsRoot, secretName);
264
- // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
265
- if (!isWithin(secretPath, secretsRoot)) {
266
- throw new UsageError(`Secret name "${secretName}" escapes the secrets directory.`);
267
- }
268
- if (!fs.existsSync(secretPath))
269
- return undefined;
270
- // Match `secret run`: read utf8, do not trim (stay consistent with that path).
271
- return readValue(secretPath).toString("utf8");
272
- };
273
- const { values: substituted, missing } = resolveSecretTokens(envValues, resolveSecret);
274
- if (missing.length > 0) {
275
- const envRef = makeEnvRef(name, source);
276
- throw new NotFoundError(`Env "${envRef}" references secret(s) not found in its stash: ${missing.map((n) => `secret:${n}`).join(", ")}. Nothing was injected.`, "FILE_NOT_FOUND", `Create the missing secret, e.g. \`akm secret set secret:${missing[0]}\`.`);
277
- }
278
- envValues = substituted;
279
- const keys = Object.keys(envValues);
280
- // Scan injected keys for known process-hijacking variables (LD_PRELOAD,
281
- // PATH, ...). Block for third-party-sourced stashes (origin has a registryId);
282
- // warn for the operator's own first-party stash, where they own the file.
283
- const { isDangerousEnvKey } = await import("../lint/env-key-rules.js");
284
- const dangerous = keys.filter(isDangerousEnvKey);
285
- if (dangerous.length > 0) {
286
- const detail = `Env "${makeEnvRef(name, source)}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
287
- if (source.registryId) {
288
- throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
289
- ` Review the file, then copy the values into a first-party env if you trust them.`, "INVALID_FLAG_VALUE");
290
- }
291
- process.stderr.write(`warning: ${detail} Injecting anyway (first-party stash).\n`);
292
- }
234
+ // Load filter secret-substitute → dangerous-key policy → keys-only
235
+ // audit event. Shared with the workflow engine's per-unit env bindings —
236
+ // see env-binding.ts for the extracted core and its safety invariants.
237
+ const { resolveEnvBinding } = await import("./env-binding.js");
238
+ const { values: envValues } = resolveEnvBinding(target, {
239
+ only: opts.only,
240
+ except: opts.except,
241
+ });
293
242
  const mergedEnv = buildChildEnv(process.env, {
294
243
  clean: opts.clean === true,
295
244
  inherit: opts.inherit ?? [],
@@ -297,12 +246,6 @@ async function runEnvInjected(target, opts) {
297
246
  for (const [envKey, envValue] of Object.entries(envValues)) {
298
247
  mergedEnv[envKey] = envValue;
299
248
  }
300
- // Audit trail: keys only, never values.
301
- appendEvent({
302
- eventType: "env_access",
303
- ref: makeEnvRef(name, source),
304
- metadata: { keys },
305
- });
306
249
  const result = spawnSync(command[0], command.slice(1), {
307
250
  stdio: "inherit",
308
251
  env: mergedEnv,
@@ -116,12 +116,15 @@ export function renderMigrationHelp(versionInput, changelogText = loadChangelog(
116
116
  if (changelogText) {
117
117
  for (const candidate of candidates) {
118
118
  const section = extractChangelogSection(changelogText, candidate);
119
- if (section) {
120
- const bundled = loadReleaseNote(candidate);
121
- if (!bundled)
122
- return `${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
119
+ const bundled = loadReleaseNote(candidate);
120
+ if (bundled) {
121
+ if (!section)
122
+ return `${bundled.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
123
123
  return `${bundled.trim()}\n\nRelease notes\n-------------\n${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
124
124
  }
125
+ if (section) {
126
+ return `${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
127
+ }
125
128
  }
126
129
  }
127
130
  const fallbackVersion = candidates.find((candidate) => candidate !== "latest") ?? requested;