akm-cli 0.9.10 → 0.9.12

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 (145) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/STABILITY.md +28 -15
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +209 -78
  17. package/dist/commands/health/improve-metrics.js +12 -0
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +15 -7
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/eval-cases.js +9 -2
  22. package/dist/commands/improve/execution.js +1 -2
  23. package/dist/commands/improve/extract.js +82 -56
  24. package/dist/commands/improve/improve-strategies.js +26 -8
  25. package/dist/commands/improve/improve.js +32 -4
  26. package/dist/commands/improve/loop-stages.js +13 -3
  27. package/dist/commands/improve/preparation.js +9 -6
  28. package/dist/commands/improve/reflect.js +61 -77
  29. package/dist/commands/lint/index.js +3 -1
  30. package/dist/commands/migrate-cli.js +6 -4
  31. package/dist/commands/proposal/drain-policies.js +22 -2
  32. package/dist/commands/proposal/repository.js +4 -4
  33. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  34. package/dist/commands/proposal/validators/proposals.js +10 -19
  35. package/dist/commands/read/show.js +42 -31
  36. package/dist/commands/registry-cli.js +4 -2
  37. package/dist/commands/sources/init.js +4 -8
  38. package/dist/commands/sources/self-update.js +2 -2
  39. package/dist/commands/sources/source-clone.js +5 -7
  40. package/dist/commands/sources/sources-cli.js +3 -5
  41. package/dist/commands/tasks/tasks-cli.js +36 -12
  42. package/dist/commands/tasks/tasks.js +38 -35
  43. package/dist/commands/tasks/validate.js +186 -0
  44. package/dist/commands/url-checker.js +75 -16
  45. package/dist/commands/workflow-cli.js +17 -15
  46. package/dist/core/activation-policy.js +31 -3
  47. package/dist/core/adapter/execution-source.js +39 -11
  48. package/dist/core/asset/stash-meta.js +7 -41
  49. package/dist/core/bundle-id.js +7 -1
  50. package/dist/core/common.js +8 -17
  51. package/dist/core/config/config-schema.js +3 -23
  52. package/dist/core/config/config-walker.js +50 -4
  53. package/dist/core/config/config.js +21 -5
  54. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  55. package/dist/core/config/schema/embedding.js +2 -2
  56. package/dist/core/config/schema/engines.js +19 -2
  57. package/dist/core/config/schema/index-config.js +19 -21
  58. package/dist/core/config/schema/primitives.js +21 -10
  59. package/dist/core/config/schema/sources-bundles.js +1 -6
  60. package/dist/core/errors.js +2 -3
  61. package/dist/core/improve-result.js +8 -0
  62. package/dist/core/improve-types.js +17 -0
  63. package/dist/core/json-schema.js +1 -11
  64. package/dist/core/maintenance-barrier.js +17 -2
  65. package/dist/core/paths.js +124 -15
  66. package/dist/core/state/migrations.js +28 -0
  67. package/dist/core/state-db.js +28 -1
  68. package/dist/core/write-source.js +6 -6
  69. package/dist/indexer/bundle-identity-guard.js +3 -0
  70. package/dist/indexer/ensure-index.js +5 -0
  71. package/dist/indexer/indexer.js +11 -3
  72. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  73. package/dist/indexer/passes/metadata.js +16 -5
  74. package/dist/indexer/search/search-fields.js +1 -30
  75. package/dist/indexer/search/search-source.js +3 -2
  76. package/dist/integrations/agent/engine-resolution.js +107 -4
  77. package/dist/integrations/agent/execution-lowering.js +15 -2
  78. package/dist/integrations/agent/model-map.js +16 -10
  79. package/dist/integrations/agent/prompts.js +13 -6
  80. package/dist/integrations/agent/runner-dispatch.js +16 -3
  81. package/dist/integrations/agent/runner.js +2 -0
  82. package/dist/integrations/lockfile.js +22 -7
  83. package/dist/llm/client.js +16 -0
  84. package/dist/llm/index-passes.js +3 -2
  85. package/dist/output/shapes/passthrough.js +10 -3
  86. package/dist/output/shapes.js +5 -3
  87. package/dist/output/text/workflow-format.js +8 -1
  88. package/dist/scripts/akm-migrate-node.js +2028 -1613
  89. package/dist/scripts/akm-migrate.js +2027 -1612
  90. package/dist/setup/setup.js +14 -21
  91. package/dist/sources/include.js +150 -20
  92. package/dist/sources/providers/git-install.js +14 -12
  93. package/dist/sources/providers/git-provider.js +3 -3
  94. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  95. package/dist/sources/website-url.js +12 -4
  96. package/dist/storage/engines/sqlite-migrations.js +40 -10
  97. package/dist/storage/like-pattern.js +7 -0
  98. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  99. package/dist/storage/repositories/index-connection.js +27 -10
  100. package/dist/storage/repositories/index-entry-schema.js +19 -2
  101. package/dist/storage/repositories/index-schema.js +30 -9
  102. package/dist/storage/repositories/proposals-repository.js +2 -1
  103. package/dist/storage/repositories/task-history-repository.js +14 -7
  104. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  105. package/dist/storage/sqlite-read-snapshot.js +11 -9
  106. package/dist/tasks/backends/cron.js +34 -5
  107. package/dist/tasks/backends/launchd.js +23 -26
  108. package/dist/tasks/backends/schtasks.js +50 -3
  109. package/dist/tasks/frozen-script.js +2 -0
  110. package/dist/tasks/prepare/prepare.js +2 -7
  111. package/dist/tasks/prepare/script-capture.js +38 -6
  112. package/dist/tasks/schedule.js +154 -13
  113. package/dist/tasks/scheduler-sync.js +51 -25
  114. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  115. package/dist/tasks/source/task-source-v4.js +0 -1
  116. package/dist/workflows/exec/child-workflow.js +2 -3
  117. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  118. package/dist/workflows/exec/exec-unit.js +3 -4
  119. package/dist/workflows/exec/run-workflow.js +19 -10
  120. package/dist/workflows/exec/step-work.js +35 -56
  121. package/dist/workflows/freeze/resolve-steps.js +19 -11
  122. package/dist/workflows/freeze/source-freeze.js +7 -0
  123. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  124. package/dist/workflows/freeze/targets/command.js +14 -2
  125. package/dist/workflows/ir/environment-v4.js +4 -2
  126. package/dist/workflows/ir/freeze-v4.js +2 -5
  127. package/dist/workflows/ir/plan-hash.js +0 -3
  128. package/dist/workflows/ir/schema-v4.js +14 -9
  129. package/dist/workflows/ir/schema.js +1 -3
  130. package/dist/workflows/resource-limits.js +35 -48
  131. package/dist/workflows/runtime/plan-classifier.js +89 -41
  132. package/dist/workflows/runtime/run-outputs.js +1 -21
  133. package/dist/workflows/runtime/runs.js +76 -150
  134. package/dist/workflows/source-files.js +28 -54
  135. package/dist/workflows/source-ir/program.js +2 -2
  136. package/dist/workflows/source-ir/semantics.js +5 -23
  137. package/docs/integration/bundling-akm.md +1 -1
  138. package/docs/migration/v0.8-to-v0.9.md +32 -0
  139. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  140. package/docs/reference/cli.md +105 -22
  141. package/docs/reference/configuration.md +12 -2
  142. package/docs/reference/data-and-telemetry.md +1 -1
  143. package/docs/reference/tasks.md +8 -0
  144. package/package.json +1 -1
  145. package/schemas/akm-config.json +11 -8
@@ -1,11 +1,14 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
+ import fs from "node:fs";
5
+ import os from "node:os";
4
6
  import path from "node:path";
5
7
  import { deepMergeConfig } from "../../core/config/deep-merge.js";
6
8
  import { ConfigError } from "../../core/errors.js";
7
9
  import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
8
10
  import { collectSensitiveValues } from "../../core/redaction.js";
11
+ import { warn } from "../../core/warn.js";
9
12
  import { getHarness } from "../harnesses/index.js";
10
13
  import { DEFAULT_AGENT_TIMEOUT_MS, DEFAULT_LLM_TIMEOUT_MS } from "./config.js";
11
14
  import { getBuiltinAgentProfile } from "./profiles.js";
@@ -22,6 +25,51 @@ function envName(reference) {
22
25
  const match = /^\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)(?:\})?$/.exec(reference);
23
26
  return match?.[1];
24
27
  }
28
+ /** Expand a leading `~` the same way `loadSetupConfigFromFile` does for `--from <file>`. */
29
+ function expandHomePath(filePath) {
30
+ return filePath.startsWith("~") ? path.join(os.homedir(), filePath.slice(1)) : filePath;
31
+ }
32
+ /** Trim exactly one trailing newline (`\n` or `\r\n`) — never interior whitespace. */
33
+ function trimTrailingNewline(raw) {
34
+ return raw.replace(/\r?\n$/, "");
35
+ }
36
+ /**
37
+ * Read a file-backed credential (#905) at the dispatch boundary. Never
38
+ * includes the file's content in a thrown message — only the engine name and
39
+ * path, so a misconfigured `apiKeyFile` cannot leak its (partial) contents
40
+ * into a log or error report.
41
+ */
42
+ function readApiKeyFile(engineName, filePath) {
43
+ let raw;
44
+ try {
45
+ raw = fs.readFileSync(filePath, "utf8");
46
+ }
47
+ catch (err) {
48
+ const code = err instanceof Error ? err.code : undefined;
49
+ const reason = code === "ENOENT" ? "does not exist" : "could not be read";
50
+ throw new ConfigError(`Engine "${engineName}" apiKeyFile ${reason}: ${filePath}`, "INVALID_CONFIG_FILE");
51
+ }
52
+ const value = trimTrailingNewline(raw);
53
+ if (value.length === 0) {
54
+ throw new ConfigError(`Engine "${engineName}" apiKeyFile is empty: ${filePath}`, "INVALID_CONFIG_FILE");
55
+ }
56
+ return value;
57
+ }
58
+ /**
59
+ * Best-effort, non-throwing read of a file-backed credential's current value
60
+ * (#905), for redaction inventories and health probes that must never fail
61
+ * just because a value collector ran ahead of the real dispatch — a missing
62
+ * or empty file is reported by {@link readApiKeyFile} at the actual call.
63
+ */
64
+ export function lookupApiKeyFileValue(filePath) {
65
+ try {
66
+ const value = trimTrailingNewline(fs.readFileSync(filePath, "utf8"));
67
+ return value.length > 0 ? value : undefined;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
25
73
  function selectedEngineName(config, layers, llmOnly) {
26
74
  for (let index = layers.length - 1; index >= 0; index--) {
27
75
  const layer = layers[index];
@@ -50,6 +98,11 @@ function resolveCredential(name, engine, config) {
50
98
  throw new ConfigError(`Engine "${name}" has an invalid symbolic apiKey reference.`, "INVALID_CONFIG_FILE");
51
99
  return { names: [explicit], required: true };
52
100
  }
101
+ // #905: an explicit apiKeyFile is its own credential source — resolved
102
+ // separately onto `ResolvedLlmUse.apiKeyFile` — so it does not also fall
103
+ // through to the implicit AKM_ENGINE_<NAME>_API_KEY convention below.
104
+ if (ownValue(engine, "apiKeyFile") !== undefined)
105
+ return undefined;
53
106
  const specific = `AKM_ENGINE_${name.toUpperCase().replaceAll("-", "_")}_API_KEY`;
54
107
  const defaults = ownValue(config, "defaults");
55
108
  return (defaults ? ownValue(defaults, "llmEngine") : undefined) === name
@@ -81,6 +134,20 @@ export function resolveCredentialFromEnv(credential, envSource = process.env) {
81
134
  }
82
135
  return undefined;
83
136
  }
137
+ /**
138
+ * The enforcing credential seam for one resolved LLM engine or SDK fallback
139
+ * (#905): the symbolic env-var descriptor first (throws if a required one is
140
+ * missing), then the file-backed alternative when no env descriptor applies.
141
+ * Call this once per operation — lease acquisition, or direct materialize —
142
+ * so a whole operation observes one stable credential value instead of
143
+ * re-reading the file on every dispatch within it.
144
+ */
145
+ export function resolveLlmCredentialValue(engine, credential, apiKeyFile, envSource = process.env) {
146
+ const envValue = resolveCredentialFromEnv(credential, envSource);
147
+ if (envValue !== undefined)
148
+ return envValue;
149
+ return apiKeyFile !== undefined ? readApiKeyFile(engine, apiKeyFile) : undefined;
150
+ }
84
151
  /** Collect materialized engine credentials for output and persistence redaction. */
85
152
  export function collectEngineCredentialValues(config, envSource = process.env) {
86
153
  const values = new Set();
@@ -92,6 +159,15 @@ export function collectEngineCredentialValues(config, envSource = process.env) {
92
159
  if (value)
93
160
  values.add(value);
94
161
  }
162
+ // #905: file-backed credential — best-effort, so a broken apiKeyFile on
163
+ // one engine never stops redaction from collecting every other engine's
164
+ // credential too.
165
+ const apiKeyFile = ownValue(engine, "apiKeyFile");
166
+ if (apiKeyFile !== undefined) {
167
+ const value = lookupApiKeyFileValue(expandHomePath(apiKeyFile));
168
+ if (value)
169
+ values.add(value);
170
+ }
95
171
  }
96
172
  return collectSensitiveValues(values);
97
173
  }
@@ -130,7 +206,20 @@ export function resolveLlmEngineUse(config, layers, options = {}) {
130
206
  }
131
207
  const engine = resolveEngineConfig(name, config);
132
208
  if (engine.kind !== "llm") {
133
- throw new ConfigError(`Engine "${name}" is not an LLM engine.`, "INVALID_CONFIG_FILE");
209
+ const defaults = ownValue(config, "defaults");
210
+ const fallbackName = ownValue(engine, "llmEngine") ?? (defaults ? ownValue(defaults, "llmEngine") : undefined);
211
+ const fallbackEngine = fallbackName ? resolveEngineConfig(fallbackName, config) : undefined;
212
+ if (!fallbackEngine || fallbackEngine.kind !== "llm") {
213
+ if (options.optional)
214
+ return undefined;
215
+ throw new ConfigError(fallbackName
216
+ ? `Engine "${name}" is not an LLM engine, and its llmEngine fallback "${fallbackName}" is not one either.`
217
+ : `Engine "${name}" is not an LLM engine, and has no llmEngine fallback configured.`, "INVALID_CONFIG_FILE");
218
+ }
219
+ warn(`[akm] Engine "${name}" is an agent engine, not an LLM engine; using its llmEngine "${fallbackName}" instead.`);
220
+ return options.optional
221
+ ? resolveLlmEngineUse(config, [{ engine: fallbackName }], { optional: true })
222
+ : resolveLlmEngineUse(config, [{ engine: fallbackName }]);
134
223
  }
135
224
  let connection = rawLlmConnection(engine);
136
225
  for (const layer of layers) {
@@ -145,14 +234,22 @@ export function resolveLlmEngineUse(config, layers, options = {}) {
145
234
  if (connection[key] === undefined)
146
235
  delete connection[key];
147
236
  }
237
+ const apiKeyFile = ownValue(engine, "apiKeyFile");
148
238
  return {
149
239
  engine: name,
150
240
  connection: sterileRecord(connection),
151
241
  credential: resolveCredential(name, engine, config),
242
+ ...(apiKeyFile !== undefined ? { apiKeyFile: expandHomePath(apiKeyFile) } : {}),
152
243
  timeoutMs: effectiveTimeout(engine, layers, DEFAULT_LLM_TIMEOUT_MS),
153
244
  };
154
245
  }
155
- /** Read a resolved symbolic credential only at the runtime dispatch boundary. */
246
+ /**
247
+ * Inject an already-resolved credential value into a connection. Callers
248
+ * resolve the value themselves via {@link resolveLlmCredentialValue} (or its
249
+ * lease-cached equivalent) — this function never reads env or disk itself, so
250
+ * a frozen `ResolvedLlmUse`/`RunnerSpec` plan object can be materialized
251
+ * repeatedly without re-triggering I/O per call.
252
+ */
156
253
  export function materializeLlmConnectionWithCredential(resolved, credentialValue) {
157
254
  const extraParams = ownValue(resolved.connection, "extraParams");
158
255
  if (extraParams !== undefined) {
@@ -167,9 +264,13 @@ export function materializeLlmConnectionWithCredential(resolved, credentialValue
167
264
  timeoutMs: resolved.timeoutMs,
168
265
  });
169
266
  }
170
- /** Read and inject one resolved symbolic credential at the runtime boundary. */
267
+ /**
268
+ * Read and inject one resolved credential at the runtime boundary: the
269
+ * symbolic `$VAR` reference, or the file-backed alternative (#905) when the
270
+ * engine has no env descriptor.
271
+ */
171
272
  export function materializeLlmConnection(resolved, envSource = process.env) {
172
- return materializeLlmConnectionWithCredential(resolved, resolveCredentialFromEnv(resolved.credential, envSource));
273
+ return materializeLlmConnectionWithCredential(resolved, resolveLlmCredentialValue(resolved.engine, resolved.credential, resolved.apiKeyFile, envSource));
173
274
  }
174
275
  function lowerAgentEngine(name, engine, config) {
175
276
  const harness = getHarness(engine.platform);
@@ -217,6 +318,7 @@ function lowerAgentEngine(name, engine, config) {
217
318
  ? {
218
319
  fallbackConnection: fallback.connection,
219
320
  ...(fallback.credential ? { fallbackCredential: fallback.credential } : {}),
321
+ ...(fallback.apiKeyFile ? { fallbackApiKeyFile: fallback.apiKeyFile } : {}),
220
322
  fallbackTimeoutMs: fallback.timeoutMs,
221
323
  }
222
324
  : {}),
@@ -237,6 +339,7 @@ export function resolveEngine(name, config) {
237
339
  engine: name,
238
340
  connection: resolved.connection,
239
341
  ...(resolved.credential ? { credential: resolved.credential } : {}),
342
+ ...(resolved.apiKeyFile ? { apiKeyFile: resolved.apiKeyFile } : {}),
240
343
  timeoutMs: resolved.timeoutMs,
241
344
  };
242
345
  }
@@ -210,17 +210,29 @@ function snapshotRunnerSpec(input, options = {}) {
210
210
  if (own(cloned, "timeoutMs"))
211
211
  validateTimeout(cloned.timeoutMs, "execution runner material.timeoutMs");
212
212
  if (kind === "llm") {
213
- assertKeys(cloned, ["kind", "engine", "connection", "credential", "timeoutMs"], "execution runner material");
213
+ assertKeys(cloned, ["kind", "engine", "connection", "credential", "apiKeyFile", "timeoutMs"], "execution runner material");
214
214
  validateConnection(cloned.connection, "execution runner material.connection", !options.allowMissingLlmModel);
215
215
  if (own(cloned, "credential"))
216
216
  validateCredential(cloned.credential, "execution runner material.credential");
217
+ // #905: a path, not the secret itself — as safe to freeze as `credential`'s
218
+ // env-var name.
219
+ validateOptionalString(cloned, "apiKeyFile", "execution runner material");
217
220
  }
218
221
  else if (kind === "agent") {
219
222
  assertKeys(cloned, ["kind", "engine", "profile", "timeoutMs"], "execution runner material");
220
223
  validateProfile(cloned.profile, "execution runner material.profile");
221
224
  }
222
225
  else if (kind === "sdk") {
223
- assertKeys(cloned, ["kind", "engine", "profile", "fallbackConnection", "fallbackCredential", "fallbackTimeoutMs", "timeoutMs"], "execution runner material");
226
+ assertKeys(cloned, [
227
+ "kind",
228
+ "engine",
229
+ "profile",
230
+ "fallbackConnection",
231
+ "fallbackCredential",
232
+ "fallbackApiKeyFile",
233
+ "fallbackTimeoutMs",
234
+ "timeoutMs",
235
+ ], "execution runner material");
224
236
  validateProfile(cloned.profile, "execution runner material.profile");
225
237
  if (own(cloned, "fallbackConnection")) {
226
238
  validateConnection(cloned.fallbackConnection, "execution runner material.fallbackConnection", !options.allowMissingSdkFallbackModel);
@@ -228,6 +240,7 @@ function snapshotRunnerSpec(input, options = {}) {
228
240
  if (own(cloned, "fallbackCredential")) {
229
241
  validateCredential(cloned.fallbackCredential, "execution runner material.fallbackCredential");
230
242
  }
243
+ validateOptionalString(cloned, "fallbackApiKeyFile", "execution runner material");
231
244
  if (own(cloned, "fallbackTimeoutMs")) {
232
245
  validateTimeout(cloned.fallbackTimeoutMs, "execution runner material.fallbackTimeoutMs");
233
246
  }
@@ -9,6 +9,7 @@ import { writeFileAtomic } from "../../core/common.js";
9
9
  import { ENGINE_NAME_PATTERN_SOURCE } from "../../core/config/engine-semantics.js";
10
10
  import { ConfigError, UsageError } from "../../core/errors.js";
11
11
  import { getConfigDir } from "../../core/paths.js";
12
+ import { warnOnce } from "../../core/warn.js";
12
13
  import { cloneExecutionJsonObject } from "../../execution/json.js";
13
14
  /**
14
15
  * Installed and operator-owned model intent aliases (#802 / WP2).
@@ -218,10 +219,16 @@ export function resolveModelMapAlias(input, engine, map) {
218
219
  const profile = ownValue(tier, selectedEngine);
219
220
  if (profile !== undefined)
220
221
  return selectionFromProfile(input, profile);
221
- if (tier !== undefined) {
222
- throw new ConfigError(`Known alias ${JSON.stringify(input)} has no model mapping for selected engine ${JSON.stringify(engine)}.`, "INVALID_CONFIG_FILE", `Add $.aliases.${alias}.${engine} to models.json.`);
222
+ const knownAliasUnmappedForEngine = tier !== undefined;
223
+ if (knownAliasUnmappedForEngine) {
224
+ warnOnce(`model-map-alias-no-engine-mapping:${alias}:${selectedEngine}`, `[akm] Model alias ${JSON.stringify(input)} has no mapping for engine ${JSON.stringify(engine)}; using ${JSON.stringify(input)} as the literal model name. Add $.aliases.${alias}.${engine} to models.json to map it.`);
223
225
  }
224
- return Object.freeze({ input, interpretation: "exact", model: input });
226
+ return Object.freeze({
227
+ input,
228
+ interpretation: "exact",
229
+ model: input,
230
+ ...(knownAliasUnmappedForEngine ? { unmappedForEngine: true } : {}),
231
+ });
225
232
  }
226
233
  export function userModelMapPath(env = process.env) {
227
234
  return path.join(getConfigDir(env), "models.json");
@@ -235,25 +242,24 @@ function modelMapFileError(label, filePath, action) {
235
242
  * absence; dangling links and every non-regular type are configuration errors.
236
243
  */
237
244
  function readModelMapFile(filePath, label, optional) {
238
- let linkStat;
245
+ let targetStat;
239
246
  try {
240
- linkStat = fs.lstatSync(filePath);
247
+ targetStat = fs.statSync(filePath);
241
248
  }
242
249
  catch (error) {
243
250
  if (optional && error?.code === "ENOENT")
244
251
  return undefined;
245
252
  throw modelMapFileError(label, filePath, optional ? "inspected" : "found");
246
253
  }
247
- if (!linkStat.isFile()) {
248
- throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Move the symlink or non-regular target aside, or replace it with a readable regular models.json file.");
254
+ if (!targetStat.isFile()) {
255
+ throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Replace it with a readable regular models.json file, or a symlink to one.");
249
256
  }
250
- const noFollow = process.platform !== "win32" && typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
251
257
  const nonblock = process.platform !== "win32" && typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
252
258
  let fd;
253
259
  try {
254
- fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonblock);
260
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | nonblock);
255
261
  const openedStat = fs.fstatSync(fd);
256
- if (!sameFileIdentity(linkStat, openedStat)) {
262
+ if (!sameFileIdentity(targetStat, openedStat)) {
257
263
  throw new ConfigError(`${label} changed while it was being opened: ${filePath}.`, "INVALID_CONFIG_FILE", "Retry after ensuring no other process is replacing models.json.");
258
264
  }
259
265
  const text = fs.readFileSync(fd, "utf8");
@@ -315,9 +315,18 @@ export function buildReflectPrompt(input) {
315
315
  // asset content into shorter prose, drops concrete structure, or strips
316
316
  // load-bearing frontmatter. Loud and explicit so small models follow.
317
317
  //
318
- // maxOutputChars is hoisted so the return value can include it for callers
319
- // on the LLM path that want to set a hard max_tokens cap on the request.
320
- let maxOutputChars;
318
+ // Guard-audit finding 15: this used to also hand back a maxOutputChars
319
+ // value so an LLM-path caller could convert it into a hard `max_tokens`
320
+ // cap on the API request. llm/client.ts's own doc comment (and
321
+ // commands/improve/reflect.ts's recorded history of responses actually
322
+ // getting cut off) is explicit that a character-derived max_tokens causes
323
+ // silent truncation — a real model's output is measured in tokens, not
324
+ // characters, and the ratio between the two varies enough that any fixed
325
+ // conversion either truncates legitimate output or provides no real cap at
326
+ // all. The size policy below is already enforced twice more (the prompt
327
+ // rules the model reads, and the post-processor's own size check), so nothing
328
+ // is lost by not adding a THIRD, byte-derived enforcement point that can
329
+ // only ever cut a response off early, never usefully re-check it.
321
330
  if (input.ref && input.assetContent?.trim()) {
322
331
  // Strip frontmatter to get source body length — mirrors checkReflectSize which
323
332
  // compares body-only lengths. Inline regex avoids importing parseFrontmatter.
@@ -332,8 +341,6 @@ export function buildReflectPrompt(input) {
332
341
  const showCharBounds = sourceBodyLen >= 200;
333
342
  const minChars = Math.max(Math.round(0.5 * sourceBodyLen), 150);
334
343
  const maxChars = Math.min(Math.max(Math.round(2.5 * sourceBodyLen), 2500), 25000);
335
- if (showCharBounds)
336
- maxOutputChars = maxChars;
337
344
  sections.push([
338
345
  "## Content preservation rules (MUST follow)",
339
346
  "1. PRESERVE ALL concrete content: code blocks, fenced snippets, CLI commands, numbered/bulleted checklists, tables, YAML/JSON examples, file paths, configuration keys, environment variable names, and CSS/HTML selectors. These are load-bearing — do NOT replace them with prose summaries.",
@@ -353,7 +360,7 @@ export function buildReflectPrompt(input) {
353
360
  sections.push(`IMPORTANT: The JSON "ref" field is REQUIRED. It MUST be exactly: "${input.ref}"`);
354
361
  }
355
362
  sections.push(reflectResponseContract(input));
356
- return { prompt: sections.join("\n\n"), ...(maxOutputChars !== undefined ? { maxOutputChars } : {}) };
363
+ return { prompt: sections.join("\n\n") };
357
364
  }
358
365
  /**
359
366
  * Build the prompt for `akm propose <type> <name> --task ...`. Asks the
@@ -20,7 +20,7 @@ import { assertNever } from "../../core/assert.js";
20
20
  import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js";
21
21
  import { spawnEnvNamesFor } from "../../core/spawn-env.js";
22
22
  import { closeServer as disposeOpencodeSdkServers, opencodeSdkServerEnvironmentNames, runOpencodeSdk, } from "../harnesses/opencode-sdk/sdk-runner.js";
23
- import { lookupCredentialFromEnv, materializeLlmConnection, materializeLlmConnectionWithCredential, resolveCredentialFromEnv, } from "./engine-resolution.js";
23
+ import { lookupApiKeyFileValue, lookupCredentialFromEnv, materializeLlmConnection, materializeLlmConnectionWithCredential, resolveLlmCredentialValue, } from "./engine-resolution.js";
24
24
  import { materializeLlmRunnerConnection, materializeLlmRunnerConnectionWithCredential, } from "./runner.js";
25
25
  import { runAgent } from "./spawn.js";
26
26
  const liveRunnerDispatchLeases = new WeakMap();
@@ -78,6 +78,7 @@ function runnerLeaseBinding(spec) {
78
78
  endpoint: spec.connection.endpoint,
79
79
  provider: spec.connection.provider ?? null,
80
80
  credential: credentialBinding(spec.credential),
81
+ apiKeyFile: spec.apiKeyFile ?? null,
81
82
  });
82
83
  case "agent":
83
84
  return JSON.stringify({
@@ -99,6 +100,7 @@ function runnerLeaseBinding(spec) {
99
100
  fallbackEndpoint: spec.fallbackConnection?.endpoint ?? null,
100
101
  fallbackProvider: spec.fallbackConnection?.provider ?? null,
101
102
  fallbackCredential: credentialBinding(spec.fallbackCredential),
103
+ fallbackApiKeyFile: spec.fallbackApiKeyFile ?? null,
102
104
  });
103
105
  default:
104
106
  return assertNever(spec);
@@ -131,8 +133,12 @@ export function acquireRunnerDispatchLease(spec, envSource = process.env) {
131
133
  return typeof property === "string" ? environment.read(property) : undefined;
132
134
  },
133
135
  });
134
- const primaryCredential = spec.kind === "llm" ? resolveCredentialFromEnv(spec.credential, credentialSource) : undefined;
135
- const fallbackCredential = spec.kind === "sdk" ? resolveCredentialFromEnv(spec.fallbackCredential, credentialSource) : undefined;
136
+ const primaryCredential = spec.kind === "llm"
137
+ ? resolveLlmCredentialValue(spec.engine, spec.credential, spec.apiKeyFile, credentialSource)
138
+ : undefined;
139
+ const fallbackCredential = spec.kind === "sdk"
140
+ ? resolveLlmCredentialValue(spec.engine, spec.fallbackCredential, spec.fallbackApiKeyFile, credentialSource)
141
+ : undefined;
136
142
  const handle = Object.create(null);
137
143
  Object.defineProperty(handle, "toJSON", {
138
144
  configurable: false,
@@ -217,6 +223,12 @@ export function collectDispatchSensitiveValues(spec, opts, envSource = opts.envS
217
223
  add(lookupCredentialFromEnv(spec.credential, envSource));
218
224
  if (spec.kind === "sdk")
219
225
  add(lookupCredentialFromEnv(spec.fallbackCredential, envSource));
226
+ // #905: a file-backed credential is read at dispatch, so it must be in the
227
+ // scrub set for the same reason the env-backed one is.
228
+ if (spec.kind === "llm" && spec.apiKeyFile)
229
+ add(lookupApiKeyFileValue(spec.apiKeyFile));
230
+ if (spec.kind === "sdk" && spec.fallbackApiKeyFile)
231
+ add(lookupApiKeyFileValue(spec.fallbackApiKeyFile));
220
232
  if (spec.kind !== "llm") {
221
233
  for (const value of Object.values(spec.profile.env ?? {}))
222
234
  add(value);
@@ -315,6 +327,7 @@ export async function executeRunner(spec, prompt, opts, seams = {}, lease) {
315
327
  engine: spec.engine ?? "unnamed-sdk-fallback",
316
328
  connection: spec.fallbackConnection,
317
329
  ...(spec.fallbackCredential ? { credential: spec.fallbackCredential } : {}),
330
+ ...(spec.fallbackApiKeyFile ? { apiKeyFile: spec.fallbackApiKeyFile } : {}),
318
331
  timeoutMs: spec.fallbackTimeoutMs !== undefined
319
332
  ? spec.fallbackTimeoutMs
320
333
  : Object.hasOwn(spec.fallbackConnection, "timeoutMs")
@@ -8,6 +8,7 @@ export function materializeLlmRunnerConnection(runner) {
8
8
  engine: runner.engine,
9
9
  connection: runner.connection,
10
10
  ...(runner.credential ? { credential: runner.credential } : {}),
11
+ ...(runner.apiKeyFile ? { apiKeyFile: runner.apiKeyFile } : {}),
11
12
  timeoutMs: runner.timeoutMs ?? null,
12
13
  });
13
14
  }
@@ -17,6 +18,7 @@ export function materializeLlmRunnerConnectionWithCredential(runner, credentialV
17
18
  engine: runner.engine,
18
19
  connection: runner.connection,
19
20
  ...(runner.credential ? { credential: runner.credential } : {}),
21
+ ...(runner.apiKeyFile ? { apiKeyFile: runner.apiKeyFile } : {}),
20
22
  timeoutMs: runner.timeoutMs ?? null,
21
23
  }, credentialValue);
22
24
  }
@@ -9,14 +9,24 @@ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquire
9
9
  import { acquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
10
10
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
11
11
  import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js";
12
+ import { warn } from "../core/warn.js";
12
13
  // ── Lock sentinel ────────────────────────────────────────────────────────────
13
- const LOCK_MAX_RETRIES = 3;
14
- const LOCK_RETRY_DELAY_MS = 100;
14
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
15
+ const LOCK_RETRY_INITIAL_DELAY_MS = 50;
16
+ const LOCK_RETRY_MAX_DELAY_MS = 1_000;
17
+ let lockAcquireTimeoutMsForTests;
18
+ export function _setLockAcquireTimeoutMsForTests(ms) {
19
+ lockAcquireTimeoutMsForTests = ms;
20
+ }
15
21
  async function acquireLockSentinel() {
16
22
  const sentinelPath = getLockfileLockPath();
17
23
  // Ensure the directory exists before attempting to create the sentinel.
18
24
  fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
19
- for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
25
+ const timeoutMs = lockAcquireTimeoutMsForTests ?? LOCK_ACQUIRE_TIMEOUT_MS;
26
+ const deadline = Date.now() + timeoutMs;
27
+ let delayMs = LOCK_RETRY_INITIAL_DELAY_MS;
28
+ let announced = false;
29
+ for (;;) {
20
30
  const releaseBarrier = acquireMaintenanceBarrier();
21
31
  try {
22
32
  const ownership = tryAcquireLockSync(sentinelPath, createLockPayload());
@@ -31,12 +41,17 @@ async function acquireLockSentinel() {
31
41
  finally {
32
42
  releaseBarrier();
33
43
  }
34
- // Another process holds the lock — wait briefly before retrying.
35
- if (attempt < LOCK_MAX_RETRIES - 1) {
36
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
44
+ // Another process holds the lock.
45
+ if (Date.now() >= deadline) {
46
+ throw new ConfigError(`Could not acquire lockfile sentinel at ${sentinelPath} after ${(timeoutMs / 1000).toFixed(1)}s; refusing to write without exclusive ownership.`, "INVALID_CONFIG_FILE");
47
+ }
48
+ if (!announced) {
49
+ announced = true;
50
+ warn("[akm] Waiting for another akm process to release the lockfile...");
37
51
  }
52
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
53
+ delayMs = Math.min(delayMs * 2, LOCK_RETRY_MAX_DELAY_MS);
38
54
  }
39
- throw new ConfigError(`Could not acquire lockfile sentinel at ${sentinelPath}; refusing to write without exclusive ownership.`, "INVALID_CONFIG_FILE");
40
55
  }
41
56
  // ── Read / Write ────────────────────────────────────────────────────────────
42
57
  export function readLockfile() {
@@ -447,3 +447,19 @@ export async function probeLlmReachable(config) {
447
447
  return { reachable: false, error: err instanceof Error ? err.message : String(err) };
448
448
  }
449
449
  }
450
+ /**
451
+ * Reachability probe for `akm health` (#914): one GET against the
452
+ * OpenAI-compatible `/models` route, bounded by `timeoutMs`. Any HTTP
453
+ * response counts as reachable — the question is whether the endpoint
454
+ * answers, not whether the route exists or the credential is right — so a
455
+ * cold local server is never asked to load a model just to be checked.
456
+ */
457
+ export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
458
+ try {
459
+ await fetch(`${config.endpoint.replace(/\/+$/, "")}/models`, { signal: AbortSignal.timeout(timeoutMs) });
460
+ return { reachable: true };
461
+ }
462
+ catch (err) {
463
+ return { reachable: false, error: err instanceof Error ? err.message : String(err) };
464
+ }
465
+ }
@@ -1,7 +1,7 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
- import { ConfigError } from "../core/errors.js";
4
+ import { warn } from "../core/warn.js";
5
5
  import { cloneExecutionJsonObject } from "../execution/json.js";
6
6
  import { lowerResolvedExecutionRequest } from "../integrations/agent/execution-lowering.js";
7
7
  import { prepareInlineExecution } from "../integrations/agent/inline-execution.js";
@@ -48,7 +48,8 @@ export function resolveIndexPassExecution(passName, config) {
48
48
  });
49
49
  const lowered = lowerResolvedExecutionRequest(prepared.request, prepared.config);
50
50
  if (lowered.runner.kind !== "llm") {
51
- throw new ConfigError(`Index pass ${JSON.stringify(passName)} requires an LLM engine; ${JSON.stringify(selectedEngine)} is not one.`, "INVALID_CONFIG_FILE");
51
+ warn("[akm] Index pass %s requires an LLM engine; %s is not one. Skipping this pass.", passName, selectedEngine);
52
+ return Object.freeze({ runner: undefined, notices: NO_LOWERING_NOTICES });
52
53
  }
53
54
  return Object.freeze({ runner: lowered.runner, notices: lowered.notices });
54
55
  }
@@ -3,7 +3,11 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  // #484: stamp schemaVersion + shape discriminator on passthrough envelopes so
5
5
  // third-party consumers can pin a schema version and dispatch on shape uniformly.
6
- // Idempotent never overwrites an existing schemaVersion or shape field.
6
+ // #918: also stamp `ok: true` so a caller branching on `.ok` sees the same
7
+ // field on every success envelope that it sees on the `{ok:false,...}` error
8
+ // envelope. Never overwrites an existing `shape`, `schemaVersion`, or `ok`;
9
+ // a command whose exit code grades the outcome emits through
10
+ // `outputWithExitCode` (src/cli/shared.ts), which sets `ok` from that code.
7
11
  //
8
12
  // Builds a shallow copy rather than mutating `result` in place: several
9
13
  // command results (e.g. `akm task sync --dry-run`'s `SchedulerPlanPreview`,
@@ -20,9 +24,11 @@ function makeStampHandler(command) {
20
24
  if (typeof result !== "object" || Array.isArray(result))
21
25
  return result;
22
26
  const obj = result;
23
- if (obj.shape !== undefined && obj.schemaVersion !== undefined)
24
- return obj;
25
27
  return {
28
+ // `ok` first so it heads the printed envelope instead of trailing a
29
+ // (possibly large) dump — the spread below still wins with `obj`'s own
30
+ // `ok` value when present, only its position is fixed here.
31
+ ok: obj.ok ?? true,
26
32
  ...obj,
27
33
  shape: obj.shape ?? command,
28
34
  schemaVersion: obj.schemaVersion ?? 1,
@@ -70,6 +76,7 @@ const PASSTHROUGH_COMMANDS = [
70
76
  "task-run",
71
77
  "task-sync",
72
78
  "task-sync-dry-run",
79
+ "task-validate",
73
80
  "update",
74
81
  "upgrade",
75
82
  "workflow-abandon",
@@ -17,7 +17,7 @@
17
17
  * and throws for unknown commands (v1 spec §9 — exhaustive registry, no silent
18
18
  * fallback).
19
19
  */
20
- import { UsageError } from "../core/errors.js";
20
+ import { warnOnce } from "../core/warn.js";
21
21
  import { curateShapes } from "./shapes/curate.js";
22
22
  import { envListShapes } from "./shapes/env-list.js";
23
23
  import { eventsShapes } from "./shapes/events.js";
@@ -67,12 +67,14 @@ registerOutputShapes(BUILT_IN_OUTPUT_SHAPES);
67
67
  */
68
68
  const SHAPE_SUMMARY_COMMANDS = new Set(["show"]);
69
69
  export function shapeForCommand(command, result, detail, shape = "human") {
70
+ let effectiveShape = shape;
70
71
  if (shape === "summary" && !SHAPE_SUMMARY_COMMANDS.has(command)) {
71
- throw new UsageError(`'--shape summary' is not supported for 'akm ${command}'. It is only available on 'akm show'.`, "INVALID_SHAPE_VALUE");
72
+ warnOnce(`shape-summary-unsupported:${command}`, `[output] '--shape summary' is not supported for 'akm ${command}' (only 'akm show' has a summary projection); falling back to 'agent'.`);
73
+ effectiveShape = "agent";
72
74
  }
73
75
  const handler = getOutputShapeHandler(command);
74
76
  if (handler) {
75
- return handler(result, detail, shape);
77
+ return handler(result, detail, effectiveShape);
76
78
  }
77
79
  // v1 spec §9 (output-shape registry exhaustive): no silent JSON.stringify
78
80
  // fallback. A missing case here is a registration bug — fail loudly so
@@ -172,7 +172,14 @@ export function formatWorkflowRunPlain(result) {
172
172
  const run = typeof result.run === "object" && result.run !== null ? result.run : undefined;
173
173
  if (!run)
174
174
  return null;
175
- const lines = [`run: ${String(run.id ?? "unknown")}`, `status: ${String(run.status ?? "unknown")}`];
175
+ const lines = [];
176
+ // #919: `workflow run <ref>` silently resuming an existing active run (the
177
+ // #485 concurrency guard) is now announced up front, with the escape hatch.
178
+ if (result.resumed === true) {
179
+ lines.push(`resuming existing run ${String(run.id ?? "unknown")} for ${String(run.workflowRef ?? "this ref")}; ` +
180
+ `pass --new to start a fresh run`);
181
+ }
182
+ lines.push(`run: ${String(run.id ?? "unknown")}`, `status: ${String(run.status ?? "unknown")}`);
176
183
  // Creation-time notices (e.g. the implicit engine fallback) must survive the
177
184
  // text renderer: JSON/YAML pass them through, and dropping them here would
178
185
  // hide the announcement from the DEFAULT output mode — where it matters most.