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
@@ -4,6 +4,7 @@
4
4
  import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { migrateLegacySourceShape } from "./config/legacy-source-shape-shim.js";
7
8
  import { ConfigError } from "./errors.js";
8
9
  import { getConfigPath, getDefaultStashDir, getRegistryCacheDir, getRegistryIndexCacheDir } from "./paths.js";
9
10
  // ── Constants ───────────────────────────────────────────────────────────────
@@ -302,23 +303,20 @@ function readStashDirFromConfig() {
302
303
  // in use. Parsing it raw here threw, the catch swallowed it, and every
303
304
  // caller silently fell back — operating on the wrong bundle or failing with
304
305
  // STASH_DIR_NOT_FOUND despite a perfectly good config.
305
- const raw = JSON.parse(stripJsonComments(text));
306
- if (typeof raw !== "object" || raw === null)
306
+ const parsed = JSON.parse(stripJsonComments(text));
307
+ if (typeof parsed !== "object" || parsed === null)
307
308
  return undefined;
309
+ const raw = migrateLegacySourceShape(parsed, configPath);
308
310
  // 0.9.0 config-shape cutover (spec §10.1): the primary stash is the
309
311
  // `defaultBundle`'s filesystem `path`. Read it directly (no config module
310
312
  // import) so the primary-stash location survives the stashDir → bundles
311
313
  // migration without a runtime rewire.
312
314
  const bundles = raw.bundles;
313
315
  const defaultBundle = raw.defaultBundle;
314
- if (bundles &&
315
- typeof bundles === "object" &&
316
- typeof defaultBundle === "string" &&
317
- bundles[defaultBundle] &&
318
- typeof bundles[defaultBundle] === "object" &&
319
- typeof bundles[defaultBundle].path === "string" &&
320
- bundles[defaultBundle].path.trim()) {
321
- const bundle = bundles[defaultBundle];
316
+ const selectedBundle = typeof defaultBundle === "string" ? bundles?.[defaultBundle] : undefined;
317
+ const selectedBundlePath = typeof selectedBundle?.path === "string" ? selectedBundle.path : undefined;
318
+ if (bundles && typeof bundles === "object" && typeof defaultBundle === "string" && selectedBundlePath?.trim()) {
319
+ const bundle = selectedBundle;
322
320
  const bundlePath = bundle.path.trim();
323
321
  if (bundle.components !== undefined) {
324
322
  if (typeof bundle.components !== "object" || bundle.components === null) {
@@ -343,13 +341,6 @@ function readStashDirFromConfig() {
343
341
  }
344
342
  return bundlePath;
345
343
  }
346
- // Retired pre-cutover shapes are not runtime inputs. Refuse them instead
347
- // of silently resolving old keys.
348
- for (const key of ["stashDir", "sources", "installed"]) {
349
- if (key in raw && raw[key] !== undefined) {
350
- throw new ConfigError(`${key} is not supported; configure the current bundles shape`, "INVALID_CONFIG_FILE");
351
- }
352
- }
353
344
  }
354
345
  catch (err) {
355
346
  // An unsupported-shape refusal must reach the caller; genuine missing/invalid
@@ -41,6 +41,7 @@
41
41
  * enforced at save time via `superRefine` on the top-level schema.
42
42
  */
43
43
  import { z } from "zod";
44
+ import { warnOnce } from "../warn.js";
44
45
  import { BUILTIN_IMPROVE_STRATEGY_NAMES, IMPROVE_PROCESS_ENGINE_CAPABILITIES } from "./engine-semantics.js";
45
46
  import { EmbeddingConnectionConfigSchema } from "./schema/embedding.js";
46
47
  import { EnginesSchema } from "./schema/engines.js";
@@ -121,25 +122,11 @@ const RETIRED_SOURCE_SHAPE_KEY_MESSAGES = {
121
122
  };
122
123
  export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) => {
123
124
  const raw = config;
124
- for (const key of ["profiles", "llm", "agent", "features", "stashes", "modelAliases"]) {
125
+ for (const key of ["profiles", "llm", "agent", "features", "stashes", "modelAliases", "bindings", "writable"]) {
125
126
  if (key in raw) {
126
- ctx.addIssue({
127
- code: z.ZodIssueCode.custom,
128
- path: [key],
129
- message: `${key} is retired in 0.9; configure engines and improve.strategies instead`,
130
- });
127
+ warnOnce(`config:retired-key:${key}`, `Config key "${key}" is retired in 0.9 and is ignored; configure engines/improve.strategies/bundles.<id> instead.`);
131
128
  }
132
129
  }
133
- // `bindings` (spec §10.1) is Tier B — never emitted, never accepted. The
134
- // top-level schema is `.passthrough()`, so without this it would round-trip
135
- // silently; reject it loudly so a stray/hand-written bindings block is caught.
136
- if ("bindings" in raw) {
137
- ctx.addIssue({
138
- code: z.ZodIssueCode.custom,
139
- path: ["bindings"],
140
- message: "bindings is not supported in 0.9.0 (Tier B); it is neither emitted nor accepted",
141
- });
142
- }
143
130
  // Only the current source shape enters the runtime. There is no config
144
131
  // compatibility path; `bundles` + `defaultBundle` fully supersede these keys.
145
132
  for (const key of ["stashDir", "sources", "installed"]) {
@@ -151,13 +138,6 @@ export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) =>
151
138
  });
152
139
  }
153
140
  }
154
- if ("writable" in raw) {
155
- ctx.addIssue({
156
- code: z.ZodIssueCode.custom,
157
- path: ["writable"],
158
- message: "top-level writable is not supported; configure bundles.<id>.writable instead",
159
- });
160
- }
161
141
  // `defaultBundle`, when present, must name a configured bundle.
162
142
  if (config.defaultBundle !== undefined) {
163
143
  if (config.bundles === undefined) {
@@ -23,6 +23,8 @@
23
23
  import { z } from "zod";
24
24
  import { isRecord } from "../common.js";
25
25
  import { UsageError } from "../errors.js";
26
+ import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../registry-url.js";
27
+ import { warnOnce } from "../warn.js";
26
28
  import { AkmConfigBaseSchema, EngineConfigSchema, listTopLevelConfigKeys } from "./config-schema.js";
27
29
  import { deepMergeConfig } from "./deep-merge.js";
28
30
  /**
@@ -175,11 +177,13 @@ export function configSet(config, dotted, raw) {
175
177
  }
176
178
  // #454: apiKey paths are not persistable. Throw at set time.
177
179
  rejectApiKeyPath(path, dotted);
180
+ rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted);
178
181
  const schema = resolveSchemaAt(path, config, raw);
179
182
  const symbolicApiKey = (path[0] === "engines" && path[2] === "apiKey") ||
180
183
  (path[0] === "embedding" && path.length === 2 && path[1] === "apiKey");
181
- if (!schema && !symbolicApiKey) {
182
- throw new UsageError(`Unknown config key: ${dotted}`, "INVALID_FLAG_VALUE", unknownKeyHint(dotted));
184
+ const isUnknownKey = !schema && !symbolicApiKey;
185
+ if (isUnknownKey) {
186
+ warnOnce(`config-set:unknown-key:${dotted}`, `"${dotted}" is not a known config key; storing it anyway. Run \`akm config get ${dotted}\` to confirm it round-trips as expected.`);
183
187
  }
184
188
  const judgmentObjectPath = isTriageJudgmentPath(path);
185
189
  const value = judgmentObjectPath && raw === "null"
@@ -188,7 +192,11 @@ export function configSet(config, dotted, raw) {
188
192
  ? parseObjectPatch(raw, dotted)
189
193
  : symbolicApiKey
190
194
  ? raw
191
- : coerceForSchema(schema, raw, dotted);
195
+ : isUnknownKey
196
+ ? bestEffortJsonValue(raw)
197
+ : coerceForSchema(schema, raw, dotted);
198
+ if (path[0] === "registries")
199
+ rejectRegistryCredentialValue(value, dotted);
192
200
  const existing = path.reduce((value, key) => {
193
201
  if (value && typeof value === "object")
194
202
  return value[key];
@@ -209,7 +217,9 @@ export function configSet(config, dotted, raw) {
209
217
  ? /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(raw)
210
218
  ? { success: true, data: value }
211
219
  : { success: false, error: { issues: [{ path: [], message: `apiKey must be $VAR or \${VAR}` }] } }
212
- : schema.safeParse(candidate);
220
+ : isUnknownKey
221
+ ? { success: true, data: value }
222
+ : schema.safeParse(candidate);
213
223
  if (!parsed.success) {
214
224
  const lines = parsed.error.issues
215
225
  .map((i) => {
@@ -273,6 +283,42 @@ function rejectApiKeyPath(path, dotted) {
273
283
  throw new UsageError(`apiKey cannot be persisted in config; export ${recipe} instead. (key: ${dotted})`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " +
274
284
  "Use the corresponding environment variable. AKM reads it at request time.");
275
285
  }
286
+ function rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted) {
287
+ const isWholeEngineSet = path[0] === "engines" && path.length === 2;
288
+ const isWholeEmbeddingSet = path.length === 1 && path[0] === "embedding";
289
+ if (!isWholeEngineSet && !isWholeEmbeddingSet)
290
+ return;
291
+ let parsed;
292
+ try {
293
+ parsed = JSON.parse(raw);
294
+ }
295
+ catch {
296
+ return; // Malformed JSON — the caller's own parse/coercion reports this.
297
+ }
298
+ if (!isRecord(parsed) || typeof parsed.apiKey !== "string")
299
+ return;
300
+ if (/^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(parsed.apiKey))
301
+ return;
302
+ throw new UsageError(`apiKey cannot be persisted in config; export ${recipeForApiKey([...path, "apiKey"], `${dotted}.apiKey`)} instead. (key: ${dotted}.apiKey)`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " +
303
+ "Use the corresponding environment variable. AKM reads it at request time.");
304
+ }
305
+ function rejectRegistryCredentialValue(value, dotted) {
306
+ const entries = Array.isArray(value) ? value : [value];
307
+ for (const entry of entries) {
308
+ const url = isRecord(entry) && typeof entry.url === "string" ? entry.url : undefined;
309
+ if (url && hasRegistryUrlCredentials(url)) {
310
+ throw new UsageError(`${REGISTRY_CREDENTIALS_UNSUPPORTED} (key: ${dotted})`, "INVALID_FLAG_VALUE", REGISTRY_CREDENTIALS_UNSUPPORTED);
311
+ }
312
+ }
313
+ }
314
+ function bestEffortJsonValue(raw) {
315
+ try {
316
+ return JSON.parse(raw);
317
+ }
318
+ catch {
319
+ return raw;
320
+ }
321
+ }
276
322
  function parseObjectPatch(raw, key) {
277
323
  try {
278
324
  const value = JSON.parse(raw);
@@ -5,14 +5,16 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { ConfigError } from "../errors.js";
7
7
  import { liftLegacyEngineExtraParams } from "../extra-params.js";
8
+ import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
8
9
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
9
10
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
10
11
  import { bundlesToSourceEntries } from "./config-sources.js";
11
12
  import { upgradeConfigVersion } from "./config-version-shim.js";
12
13
  import { deepMergeConfig } from "./deep-merge.js";
14
+ import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
13
15
  export { stripJsonComments } from "./config-io.js";
14
16
  import { getConfigPath } from "../paths.js";
15
- import { warn } from "../warn.js";
17
+ import { warn, warnOnce } from "../warn.js";
16
18
  // Canonical harness-id source of truth (#565) — runtime value re-export.
17
19
  export { VALID_HARNESS_IDS } from "./config-types.js";
18
20
  // ── Feedback failure-mode constants (F-3 / #384) ────────────────────────────
@@ -145,7 +147,8 @@ export function acquireConfigReadFence() {
145
147
  * is validated.
146
148
  */
147
149
  export function parseAndValidateConfigText(text, sourcePath) {
148
- const parsedRaw = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
150
+ const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
151
+ const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
149
152
  // #852 (following #815): a config still using legacy `extraParams` keys —
150
153
  // e.g. `reasoning_effort`, a documented 0.9.1 workaround — needs to be
151
154
  // rewritten onto the first-class engine field they now shadow. This used
@@ -155,7 +158,7 @@ export function parseAndValidateConfigText(text, sourcePath) {
155
158
  // config that has not been migrated yet fails closed here instead of
156
159
  // silently drifting from what's on disk.
157
160
  const where = sourcePath ? ` at ${sourcePath}` : "";
158
- const { lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
161
+ const { config: liftedConfig, lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
159
162
  if (conflicts.length > 0) {
160
163
  const lines = conflicts
161
164
  .map((c) => ` - engines.${c.engine}.extraParams.${c.key} (${JSON.stringify(c.extraParamsValue)}) conflicts with engines.${c.engine}.${c.field} (${JSON.stringify(c.fieldValue)})`)
@@ -163,9 +166,9 @@ export function parseAndValidateConfigText(text, sourcePath) {
163
166
  throw new ConfigError(`Invalid config${where}: extraParams and the first-class field disagree:\n${lines}\n\nEach extraParams key above has a first-class equivalent and akm will not guess which value you meant — remove the extraParams entry once the field carries the value you want.`, "INVALID_CONFIG_FILE");
164
167
  }
165
168
  if (lifted.length > 0) {
166
- throw new ConfigError(`Config${where} uses deprecated extraParams keys with first-class equivalents:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file, or move the values onto the first-class fields yourself.`, "INVALID_CONFIG_FILE");
169
+ warnOnce(`config:extra-params-lift${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses deprecated extraParams keys with first-class equivalents — auto-lifted in memory:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file and silence this warning.`);
167
170
  }
168
- const parsed = AkmConfigSchema.safeParse(parsedRaw);
171
+ const parsed = AkmConfigSchema.safeParse(liftedConfig);
169
172
  if (!parsed.success) {
170
173
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
171
174
  throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
@@ -327,6 +330,19 @@ export function sanitizeConfigForWrite(config) {
327
330
  if (stripped.length > 0) {
328
331
  warn(`Config sanitizer dropped API key(s) before writing to disk:\n - ${stripped.join("\n - ")}\n\nakm does not persist API keys to config.json. Set the listed environment variables to provide them at runtime, or use \`\${VAR}\` references in your config to defer lookup. See docs/reference/data-and-telemetry.md.`);
329
332
  }
333
+ if (config.registries) {
334
+ const droppedRegistries = [];
335
+ const registries = config.registries.filter((entry) => {
336
+ if (!hasRegistryUrlCredentials(entry.url))
337
+ return true;
338
+ droppedRegistries.push(formatRegistryLabel(entry));
339
+ return false;
340
+ });
341
+ if (droppedRegistries.length > 0) {
342
+ sanitized.registries = registries;
343
+ warn(`Config sanitizer dropped registry entr${droppedRegistries.length === 1 ? "y" : "ies"} with URL credentials before writing to disk:\n - ${droppedRegistries.join("\n - ")}\n\nRegistry URLs must be credential-free; configure a credential-free HTTPS endpoint.`);
344
+ }
345
+ }
330
346
  return sanitized;
331
347
  }
332
348
  /** Matches the only 0.9 symbolic secret forms: `${VAR}` or `$VAR`. */
@@ -0,0 +1,79 @@
1
+ import { isBundleSlug } from "../asset/asset-ref.js";
2
+ import { warnOnce } from "../warn.js";
3
+ function isPlainRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ const DEFAULT_WRITABLE_BY_TYPE = {
7
+ filesystem: true,
8
+ git: false,
9
+ website: false,
10
+ npm: false,
11
+ };
12
+ function bundleFromLegacySource(entry, index) {
13
+ if (!isPlainRecord(entry))
14
+ return undefined;
15
+ const type = typeof entry.type === "string" ? entry.type : undefined;
16
+ const bundle = {};
17
+ switch (type) {
18
+ case "filesystem":
19
+ if (typeof entry.path !== "string" || !entry.path)
20
+ return undefined;
21
+ bundle.path = entry.path;
22
+ break;
23
+ case "git":
24
+ if (typeof entry.url !== "string" || !entry.url)
25
+ return undefined;
26
+ bundle.git = entry.url;
27
+ break;
28
+ case "website":
29
+ if (typeof entry.url !== "string" || !entry.url)
30
+ return undefined;
31
+ bundle.website = { url: entry.url };
32
+ break;
33
+ case "npm": {
34
+ const spec = typeof entry.url === "string" && entry.url ? entry.url : entry.path;
35
+ if (typeof spec !== "string" || !spec)
36
+ return undefined;
37
+ bundle.npm = spec;
38
+ break;
39
+ }
40
+ default:
41
+ return undefined;
42
+ }
43
+ const writable = typeof entry.writable === "boolean" ? entry.writable : DEFAULT_WRITABLE_BY_TYPE[type ?? ""];
44
+ if (writable !== undefined)
45
+ bundle.writable = writable;
46
+ if (typeof entry.enabled === "boolean")
47
+ bundle.enabled = entry.enabled;
48
+ const name = typeof entry.name === "string" ? entry.name : undefined;
49
+ const key = name && isBundleSlug(name) ? name : `source-${index + 1}`;
50
+ return [key, bundle];
51
+ }
52
+ export function migrateLegacySourceShape(raw, sourcePath) {
53
+ const hasStashDir = typeof raw.stashDir === "string" && raw.stashDir.trim().length > 0;
54
+ const hasSources = Array.isArray(raw.sources) && raw.sources.length > 0;
55
+ const hasInstalled = "installed" in raw && raw.installed !== undefined;
56
+ if (!hasStashDir && !hasSources && !hasInstalled)
57
+ return raw;
58
+ const { stashDir: _stashDir, sources: _sources, installed: _installed, ...rest } = raw;
59
+ const bundles = isPlainRecord(rest.bundles) ? { ...rest.bundles } : {};
60
+ let defaultBundle = typeof rest.defaultBundle === "string" ? rest.defaultBundle : undefined;
61
+ if (hasStashDir) {
62
+ bundles.stash = { path: raw.stashDir, writable: true };
63
+ defaultBundle ??= "stash";
64
+ }
65
+ if (hasSources) {
66
+ raw.sources.forEach((entry, index) => {
67
+ const converted = bundleFromLegacySource(entry, index);
68
+ if (!converted)
69
+ return;
70
+ const [key, bundle] = converted;
71
+ bundles[key] = bundle;
72
+ defaultBundle ??= key;
73
+ });
74
+ }
75
+ const droppedKeys = [hasStashDir && "stashDir", hasSources && "sources", hasInstalled && "installed"].filter(Boolean);
76
+ const where = sourcePath ? ` at ${sourcePath}` : "";
77
+ warnOnce(`legacy-source-shape${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired ${droppedKeys.join("/")} shape — auto-migrated in memory to \`bundles\`/\`defaultBundle\`. Run \`akm migrate apply\` to rewrite the config file and silence this warning.`);
78
+ return { ...rest, bundles, ...(defaultBundle !== undefined ? { defaultBundle } : {}) };
79
+ }
@@ -6,7 +6,7 @@
6
6
  * `config-schema.ts` monolith — no behavior change.
7
7
  */
8
8
  import { z } from "zod";
9
- import { ENV_REFERENCE_PATTERN, positiveInt } from "./primitives.js";
9
+ import { positiveInt, symbolicOrWarnApiKey } from "./primitives.js";
10
10
  const EmbeddingOllamaOptionsSchema = z
11
11
  .object({
12
12
  num_ctx: positiveInt.optional(),
@@ -26,7 +26,7 @@ export const EmbeddingConnectionConfigSchema = z
26
26
  provider: z.string().optional(),
27
27
  endpoint: z.string().optional(),
28
28
  model: z.string().optional(),
29
- apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
29
+ apiKey: symbolicOrWarnApiKey("embedding.apiKey").optional(),
30
30
  // Bounded to the index schema's own vec-table guard (1–4096,
31
31
  // storage/repositories/index-schema.ts) so an out-of-range dimension
32
32
  // fails at config validation with a clear message instead of crashing
@@ -15,7 +15,7 @@ import { z } from "zod";
15
15
  // a config-schema ↔ config-types type cycle that collapses inference.
16
16
  import { HARNESS_AGENT_DISPATCH_IDS, VALID_HARNESS_IDS } from "../../../integrations/harnesses/ids.js";
17
17
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
18
- import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, nonEmptyString, positiveInt, } from "./primitives.js";
18
+ import { chatCompletionsEndpoint, ExtraParamsSchema, engineName, nonEmptyString, positiveInt, symbolicOrWarnApiKey, } from "./primitives.js";
19
19
  /**
20
20
  * Engine-config timeouts share the workflow ceiling.
21
21
  *
@@ -67,7 +67,12 @@ const LlmEngineSchema = z
67
67
  provider: z.string().optional(),
68
68
  endpoint: chatCompletionsEndpoint,
69
69
  model: nonEmptyString,
70
- apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
70
+ apiKey: symbolicOrWarnApiKey("engines.<name>.apiKey").optional(),
71
+ // #905: file-backed alternative to `apiKey` for hosts that refuse
72
+ // secrets in the process environment. A plain filesystem path (`~`
73
+ // expanded, read at dispatch) — see resolveLlmEngineUse/
74
+ // materializeLlmConnectionWithCredential in integrations/agent/engine-resolution.ts.
75
+ apiKeyFile: nonEmptyString.optional(),
71
76
  temperature: z.number().finite().optional(),
72
77
  maxTokens: positiveInt.optional(),
73
78
  timeoutMs: timeoutMsField,
@@ -83,6 +88,17 @@ const LlmEngineSchema = z
83
88
  if (key in value)
84
89
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: `${key} is not valid on an LLM engine` });
85
90
  }
91
+ // #905: apiKey and apiKeyFile are two alternative ways to supply the same
92
+ // credential; a third (the implicit AKM_ENGINE_<NAME>_API_KEY env var) is
93
+ // still available when neither is set, so only the both-set case is
94
+ // rejected here.
95
+ if (value.apiKey !== undefined && value.apiKeyFile !== undefined) {
96
+ ctx.addIssue({
97
+ code: z.ZodIssueCode.custom,
98
+ path: ["apiKeyFile"],
99
+ message: "apiKey and apiKeyFile cannot both be set",
100
+ });
101
+ }
86
102
  });
87
103
  const AgentEngineSchema = z
88
104
  .object({
@@ -103,6 +119,7 @@ const AgentEngineSchema = z
103
119
  "provider",
104
120
  "endpoint",
105
121
  "apiKey",
122
+ "apiKeyFile",
106
123
  "temperature",
107
124
  "maxTokens",
108
125
  "concurrency",
@@ -7,6 +7,7 @@
7
7
  * change.
8
8
  */
9
9
  import { z } from "zod";
10
+ import { warnOnce } from "../../warn.js";
10
11
  import { engineName, LlmInvocationOverridesSchema, nonEmptyString, positiveInt } from "./primitives.js";
11
12
  // ── Index / per-pass ────────────────────────────────────────────────────────
12
13
  //
@@ -53,26 +54,23 @@ export const IndexPassConfigSchema = z.preprocess((raw, ctx) => {
53
54
  return raw; // let z.object below produce the type error
54
55
  }
55
56
  const obj = raw;
57
+ let cleaned;
56
58
  for (const key of Object.keys(obj)) {
59
+ const dotted = [...(ctx.path ?? []), key].join(".");
57
60
  if (INDEX_PASS_RETIRED_KEYS.has(key)) {
58
- ctx.addIssue({
59
- code: z.ZodIssueCode.custom,
60
- message: `Retired or misplaced engine setting: \`${[...(ctx.path ?? []), key].join(".")}\` is not allowed. ` +
61
- "Select a named engine and use typed invocation fields instead.",
62
- });
63
- return raw;
61
+ warnOnce(`index-pass:retired:${dotted}`, `\`${dotted}\` is a retired engine setting and is ignored; select a named engine and use typed invocation fields instead.`);
62
+ cleaned ??= { ...obj };
63
+ delete cleaned[key];
64
64
  }
65
- if (!INDEX_PASS_KNOWN_KEYS.has(key)) {
66
- ctx.addIssue({
67
- code: z.ZodIssueCode.custom,
68
- message: `Unknown key \`${[...(ctx.path ?? []), key].join(".")}\`. Per-pass entries support ` +
69
- "`engine`, `model`, `timeoutMs`, `enabled`, `llm`, `graphExtractionBatchSize`, " +
70
- "`graphExtractionIncludeTypes`, and `lazyGraphExtraction`.",
71
- });
72
- return raw;
65
+ else if (!INDEX_PASS_KNOWN_KEYS.has(key)) {
66
+ warnOnce(`index-pass:unknown:${dotted}`, `Unknown key \`${dotted}\` ignored. Per-pass entries support ` +
67
+ "`engine`, `model`, `timeoutMs`, `enabled`, `llm`, `graphExtractionBatchSize`, " +
68
+ "`graphExtractionIncludeTypes`, and `lazyGraphExtraction`.");
69
+ cleaned ??= { ...obj };
70
+ delete cleaned[key];
73
71
  }
74
72
  }
75
- return raw;
73
+ return cleaned ?? raw;
76
74
  }, z
77
75
  .object({
78
76
  engine: engineName.optional(),
@@ -120,13 +118,13 @@ const IndexConfigRuntimeSchema = z.preprocess((raw, ctx) => {
120
118
  }
121
119
  if (typeof raw !== "object")
122
120
  return raw;
121
+ let cleaned;
123
122
  for (const [passName, value] of Object.entries(raw)) {
124
123
  if (passName === "stalenessDetection") {
125
- ctx.addIssue({
126
- code: z.ZodIssueCode.custom,
127
- message: "Invalid `index.stalenessDetection`: the removed pass is not supported.",
128
- });
129
- return raw;
124
+ warnOnce("index:stalenessDetection", "`index.stalenessDetection` is a retired pass and is ignored.");
125
+ cleaned ??= { ...raw };
126
+ delete cleaned.stalenessDetection;
127
+ continue;
130
128
  }
131
129
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
132
130
  ctx.addIssue({
@@ -136,7 +134,7 @@ const IndexConfigRuntimeSchema = z.preprocess((raw, ctx) => {
136
134
  return raw;
137
135
  }
138
136
  }
139
- return raw;
137
+ return cleaned ?? raw;
140
138
  }, z
141
139
  .object({
142
140
  defaults: IndexDefaultsSchema.optional(),
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { z } from "zod";
10
10
  import { validateExtraParams } from "../../extra-params.js";
11
+ import { warnOnce } from "../../warn.js";
11
12
  import { ENGINE_NAME_PATTERN_SOURCE } from "../engine-semantics.js";
12
13
  /** Persisted config schema version. Package prerelease/patch versions do not change this value. */
13
14
  export const CURRENT_CONFIG_VERSION = "0.9.0";
@@ -31,21 +32,31 @@ export const engineName = z
31
32
  .string()
32
33
  .max(63)
33
34
  .regex(ENGINE_NAME_PATTERN, "names must be lowercase kebab-case and must not begin with reserved akm-");
35
+ export function symbolicOrWarnApiKey(label) {
36
+ return z.string().superRefine((value) => {
37
+ if (ENV_REFERENCE_PATTERN.test(value))
38
+ return;
39
+ warnOnce(`config:literal-api-key:${label}`, `A ${label} in config.json is a literal API key, not a $VAR/\${VAR} reference; using it as configured. Prefer \`akm config set ...apiKey '$VAR'\` (with the corresponding env var set) — see docs/reference/data-and-telemetry.md.`);
40
+ });
41
+ }
34
42
  export const chatCompletionsEndpoint = z.string().superRefine((value, ctx) => {
43
+ let url;
35
44
  try {
36
- const url = new URL(value);
37
- if (url.protocol !== "http:" && url.protocol !== "https:") {
38
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "endpoint must use http:// or https://" });
39
- }
40
- if (url.username || url.password || url.search || url.hash || !url.pathname.endsWith("/chat/completions")) {
41
- ctx.addIssue({
42
- code: z.ZodIssueCode.custom,
43
- message: "endpoint must be a credential-free OpenAI chat-completions URL without query or fragment",
44
- });
45
- }
45
+ url = new URL(value);
46
46
  }
47
47
  catch {
48
48
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: "endpoint must be a complete URL" });
49
+ return;
50
+ }
51
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
52
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "endpoint must use http:// or https://" });
53
+ return;
54
+ }
55
+ if (url.username || url.password) {
56
+ warnOnce(`chatCompletionsEndpoint:userinfo:${value}`, `Config endpoint "${value}" embeds a username/password; consider moving the credential to the engine's apiKey field instead.`);
57
+ }
58
+ if (!url.pathname.endsWith("/chat/completions")) {
59
+ warnOnce(`chatCompletionsEndpoint:path:${value}`, `Config endpoint "${value}" does not end in /chat/completions; using it as configured.`);
49
60
  }
50
61
  });
51
62
  export const ExtraParamsSchema = z.record(z.unknown()).superRefine((value, ctx) => {
@@ -13,7 +13,6 @@ import { z } from "zod";
13
13
  // and, transitively, the indexer modules they delegate to).
14
14
  import { VALID_ADAPTER_IDS } from "../../adapter/adapter-ids.js";
15
15
  import { isBundleSlug } from "../../asset/asset-ref.js";
16
- import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../../registry-url.js";
17
16
  import { httpUrl, nonEmptyString, positiveInt } from "./primitives.js";
18
17
  const VALID_ADAPTER_IDS_SET = new Set(VALID_ADAPTER_IDS);
19
18
  // ── Sources / registries / installed ────────────────────────────────────────
@@ -56,11 +55,7 @@ export const SourceConfigEntrySchema = z
56
55
  });
57
56
  export const RegistryConfigEntrySchema = z
58
57
  .object({
59
- url: httpUrl.superRefine((value, ctx) => {
60
- if (hasRegistryUrlCredentials(value)) {
61
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: REGISTRY_CREDENTIALS_UNSUPPORTED });
62
- }
63
- }),
58
+ url: httpUrl,
64
59
  name: z.string().min(1).optional(),
65
60
  enabled: z.boolean().optional(),
66
61
  provider: z.string().min(1).optional(),
@@ -14,7 +14,6 @@ const CONFIG_HINTS = {
14
14
  EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
15
15
  LLM_NOT_CONFIGURED: 'Run `akm setup` or configure an `engines` entry with `kind: "llm"`, then select it with `defaults.llmEngine`.',
16
16
  TEST_ISOLATION_MISSING: "Under bun test, when AKM_BUNDLE_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
17
- SETUP_TMP_STASH_REFUSED: "Use a persistent directory, or set AKM_FORCE_SETUP_TMP_STASH=1 to opt in to a sandboxed setup (setup also pre-sets AKM_BUNDLE_DIR so config and cache writes auto-isolate into $stashDir/.akm/ — host config is preserved).",
18
17
  UNSAFE_STASH_DIR: "Choose a path inside your home directory (e.g. ~/akm) or another empty workspace. The bundle directory cannot be the filesystem root, your home directory itself, or a sensitive system path like /etc, /var, ~/.config, or ~/.ssh.",
19
18
  UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
20
19
  EXECUTION_NOT_AUTHORIZED: "Change the selected tools or update the machine/user execution policy, then retry.",
@@ -39,7 +38,7 @@ const USAGE_HINTS = {
39
38
  INVALID_SOURCE_VALUE: "Pick one of: local, registry, all, or a configured source name.",
40
39
  INVALID_FORMAT_VALUE: "Pick one of: json, jsonl, yaml, text, md, html.",
41
40
  INVALID_DETAIL_VALUE: "Pick one of: brief, normal, full. For agent/summary projections use --shape.",
42
- INVALID_SHAPE_VALUE: "Pick one of: human, agent, summary (summary is only valid on `akm show`).",
41
+ INVALID_SHAPE_VALUE: "Pick one of: human, agent, summary (summary falls back to agent, with a warning, on commands with no summary projection).",
43
42
  INVALID_JSON_CONFIG_VALUE: 'Quote JSON values in your shell, for example: akm config set embedding \'{"endpoint":"http://localhost:11434/v1/embeddings","model":"nomic-embed-text"}\'.',
44
43
  MISSING_OR_AMBIGUOUS_TARGET: "Use `akm bundle update --all` or pass a target like `akm bundle update npm:@scope/pkg` (not both).",
45
44
  TARGET_NOT_UPDATABLE: "Run `akm bundle list` to view your sources, then retry with one of those values.",
@@ -74,7 +73,7 @@ const USAGE_HINTS = {
74
73
  TASK_TARGET_UNSUPPORTED: "Task definitions support command, script, workflow, and shell (run:) targets; akm/command is layered by callers.",
75
74
  // P3a (docs/plans/specs/p3a-plan-v5-child-freeze.md §3.2, A-N2): the
76
75
  // complete-or-abandon policy for a stored pre-irVersion-5 run.
77
- WORKFLOW_IR_VERSION_UNSUPPORTED: "Abandon the run with `akm workflow abandon <id>`, then start it again from the workflow source — pre-0.9.2 frozen plans are not re-executable.",
76
+ WORKFLOW_IR_VERSION_UNSUPPORTED: "Abandon the run with `akm workflow abandon <id>`, then start it again from the workflow source — a frozen plan this akm cannot execute is not re-executable in place.",
78
77
  // P3b (docs/plans/specs/p3b-child-executor.md §4.3).
79
78
  WORKFLOW_OUTPUT_INVALID: "Check each `outputs:` entry's `from:` against the step artifact it names, and its `schema:` against the value that step actually promotes.",
80
79
  };
@@ -27,6 +27,7 @@ const COMMON_FIELDS = [
27
27
  "coverageGaps",
28
28
  "evalCasesWritten",
29
29
  "deadUrls",
30
+ "deadUrlCoverage",
30
31
  "reflectsWithErrorContext",
31
32
  "memoryInference",
32
33
  "graphExtraction",
@@ -440,6 +441,7 @@ function validateCommon(value) {
440
441
  "sync",
441
442
  "terminated",
442
443
  "plan",
444
+ "deadUrlCoverage",
443
445
  ]) {
444
446
  if (value[field] !== undefined && !isRecord(value[field]))
445
447
  fail(`${field} must be an object`);
@@ -453,6 +455,12 @@ function validateCommon(value) {
453
455
  fail("terminated.errorMessage must be a string when present");
454
456
  }
455
457
  }
458
+ if (isRecord(value.deadUrlCoverage)) {
459
+ requireExactFields(value.deadUrlCoverage, new Set(["checked", "total", "skipped"]));
460
+ for (const field of ["checked", "total", "skipped"]) {
461
+ requireCount(value.deadUrlCoverage, field, "deadUrlCoverage");
462
+ }
463
+ }
456
464
  }
457
465
  /**
458
466
  * Per-`schemaVersion` decoders for the persisted `improve_runs.result_json`
@@ -49,6 +49,23 @@ export function classifyImproveAction(mode) {
49
49
  return assertNever(mode);
50
50
  }
51
51
  }
52
+ /**
53
+ * #912 — skip reasons that mean infrastructure failed to run the extraction,
54
+ * not that a session was legitimately uninteresting. `already_extracted`,
55
+ * `too_short`, and `triaged_out` are the ledger and pre-filter doing their
56
+ * job and are deliberately excluded; `malformed_model_output` already gets
57
+ * its own per-session forwarding into the envelope's `warnings[]` and is
58
+ * excluded here to avoid double-reporting the same failure two ways.
59
+ *
60
+ * Typed against `ExtractedSessionResult["skipReason"]` so a future reason
61
+ * cannot be added to one union without a compiler error surfacing here.
62
+ */
63
+ export const EXTRACT_INFRASTRUCTURE_SKIP_REASONS = [
64
+ "llm_unavailable",
65
+ "read_failed",
66
+ "exception",
67
+ "locked_concurrent",
68
+ ];
52
69
  /** Upper bound on retained sample refs PER reason in {@link DistillSkippedAggregate}. */
53
70
  export const DISTILL_SKIPPED_SAMPLE_CAP_PER_REASON = 3;
54
71
  /**