akm-cli 0.9.14 → 0.9.15-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  4. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  5. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  6. package/dist/assets/tasks/core/improve.yml +1 -1
  7. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  8. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  9. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  13. package/dist/cli/retired-commands.js +0 -1
  14. package/dist/cli/shared.js +9 -0
  15. package/dist/cli/unknown-flags.js +1 -0
  16. package/dist/cli.js +3 -2
  17. package/dist/commands/config-cli.js +85 -3
  18. package/dist/commands/env/env-cli.js +1 -42
  19. package/dist/commands/env/env.js +1 -1
  20. package/dist/commands/env/secret-cli.js +1 -2
  21. package/dist/commands/health/checks.js +357 -63
  22. package/dist/commands/health/engine-usage.js +45 -0
  23. package/dist/commands/health/improve-metrics.js +18 -0
  24. package/dist/commands/health/llm-usage.js +41 -1
  25. package/dist/commands/health/plugin-staleness.js +7 -3
  26. package/dist/commands/health/version-drift.js +93 -0
  27. package/dist/commands/health/windows.js +3 -1
  28. package/dist/commands/health.js +44 -9
  29. package/dist/commands/improve/consolidate/chunking.js +4 -2
  30. package/dist/commands/improve/improve-cli.js +99 -5
  31. package/dist/commands/improve/improve-report.js +154 -0
  32. package/dist/commands/improve/improve-result-file.js +45 -33
  33. package/dist/commands/improve/improve-strategies.js +133 -3
  34. package/dist/commands/improve/improve-usage-report.js +182 -0
  35. package/dist/commands/improve/improve.js +40 -3
  36. package/dist/commands/improve/locks.js +27 -78
  37. package/dist/commands/improve/planner.js +1 -0
  38. package/dist/commands/improve/preparation.js +9 -1
  39. package/dist/commands/improve/reflect.js +44 -4
  40. package/dist/commands/models-cli.js +50 -1
  41. package/dist/commands/proposal/repository.js +8 -3
  42. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  43. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  44. package/dist/commands/read/search-cli.js +38 -2
  45. package/dist/commands/read/show.js +103 -4
  46. package/dist/commands/sources/info.js +5 -1
  47. package/dist/commands/sources/self-update.js +2 -2
  48. package/dist/commands/sources/stash-cli.js +31 -0
  49. package/dist/commands/tasks/tasks-cli.js +49 -2
  50. package/dist/commands/workflow-cli.js +86 -12
  51. package/dist/core/asset/markdown-fragments.js +35 -0
  52. package/dist/core/config/config-schema.js +14 -0
  53. package/dist/core/config/config.js +302 -24
  54. package/dist/core/env-secret-ref.js +58 -5
  55. package/dist/core/errors.js +30 -0
  56. package/dist/core/improve-result.js +51 -0
  57. package/dist/core/loopback.js +17 -0
  58. package/dist/core/paths.js +11 -0
  59. package/dist/core/run-lock.js +96 -0
  60. package/dist/core/sensitive-marker-path.js +19 -0
  61. package/dist/core/state-db.js +74 -14
  62. package/dist/indexer/index-rebuild-lock.js +73 -0
  63. package/dist/indexer/index-writer-lock.js +40 -1
  64. package/dist/indexer/index-written-assets.js +21 -1
  65. package/dist/indexer/indexer.js +18 -17
  66. package/dist/indexer/materialize-embeddings.js +282 -32
  67. package/dist/indexer/search/db-search.js +49 -2
  68. package/dist/integrations/agent/engine-resolution.js +96 -6
  69. package/dist/integrations/agent/execution-definitions.js +6 -15
  70. package/dist/integrations/agent/execution-lowering.js +6 -1
  71. package/dist/integrations/agent/execution-preparation.js +1 -1
  72. package/dist/integrations/agent/model-map.js +123 -20
  73. package/dist/integrations/agent/prompts.js +40 -8
  74. package/dist/integrations/agent/runner-dispatch.js +9 -3
  75. package/dist/integrations/agent/runner.js +2 -0
  76. package/dist/llm/client.js +8 -3
  77. package/dist/llm/embedder.js +20 -8
  78. package/dist/llm/embedders/local.js +10 -2
  79. package/dist/llm/embedders/remote.js +188 -21
  80. package/dist/output/shapes/helpers.js +38 -2
  81. package/dist/output/shapes/models-list.js +16 -0
  82. package/dist/output/shapes/passthrough.js +2 -0
  83. package/dist/output/shapes.js +4 -0
  84. package/dist/output/text/command-format.js +29 -0
  85. package/dist/output/text/helpers.js +1 -1
  86. package/dist/output/text/improve-report.js +27 -0
  87. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  88. package/dist/output/text/show-format.js +4 -0
  89. package/dist/output/text.js +4 -0
  90. package/dist/scripts/akm-migrate-node.js +24798 -21732
  91. package/dist/scripts/akm-migrate.js +23408 -20343
  92. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  93. package/dist/storage/repositories/index-fts-repository.js +49 -6
  94. package/dist/storage/repositories/index-vec-repository.js +30 -0
  95. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  96. package/dist/tasks/backends/cron.js +14 -7
  97. package/dist/tasks/run/run-workflow-task.js +16 -0
  98. package/dist/workflows/exec/child-workflow.js +2 -2
  99. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  100. package/dist/workflows/exec/run-workflow.js +6 -5
  101. package/dist/workflows/runtime/runs.js +33 -5
  102. package/docs/migration/release-notes/0.9.15.md +52 -0
  103. package/docs/migration/release-notes/README.md +4 -0
  104. package/docs/reference/cli.md +245 -29
  105. package/docs/reference/configuration.md +180 -19
  106. package/docs/reference/data-and-telemetry.md +8 -0
  107. package/docs/reference/tasks.md +16 -1
  108. package/docs/reference/workflow-schema.md +5 -1
  109. package/package.json +1 -1
  110. package/schemas/akm-config.json +8 -0
@@ -89,6 +89,20 @@ export const DefaultsSchema = z
89
89
  */
90
90
  export const AkmConfigShape = {
91
91
  configVersion: z.literal(CURRENT_CONFIG_VERSION),
92
+ // #945 — fleet inheritance. A filesystem path (relative to the directory of
93
+ // the config file that declares it; `~` expands) or a `bundle//<path>` ref
94
+ // naming an already-synced local file to deep-merge underneath this config
95
+ // (local keys win) — the part after `//` is a plain file path relative to
96
+ // that bundle's content root, not an asset conceptId; it needs no asset
97
+ // type and is never indexed. No URL form: config load is synchronous and
98
+ // runs on every invocation, and akm deliberately does not fetch network
99
+ // resources at load time (see `registries`, never fetched until a
100
+ // registry-touching command runs) — a URL-backed shared config should be
101
+ // synced via `akm bundle add` (git/website) and referenced as
102
+ // `extends: bundle//<path>` once materialized locally. Resolved in
103
+ // `resolveExtendsChain` (./config.ts), not validated here (a bad ref
104
+ // surfaces as a `ConfigError` at load, naming the ref).
105
+ extends: nonEmptyString.optional(),
92
106
  engines: EnginesSchema.optional(),
93
107
  defaults: DefaultsSchema.optional(),
94
108
  semanticSearchMode: z.enum(["off", "auto"]).default("off"),
@@ -2,15 +2,19 @@
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
4
  import fs from "node:fs";
5
+ import os from "node:os";
5
6
  import path from "node:path";
7
+ import { isDeepStrictEqual } from "node:util";
8
+ import { isBundleSlug } from "../asset/asset-ref.js";
9
+ import { isRecord } from "../common.js";
6
10
  import { ConfigError } from "../errors.js";
7
11
  import { liftLegacyEngineExtraParams } from "../extra-params.js";
8
12
  import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
9
13
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
10
14
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
11
- import { bundlesToSourceEntries } from "./config-sources.js";
15
+ import { bundleComponentConfig, bundleContentRoot, bundlesToSourceEntries } from "./config-sources.js";
12
16
  import { upgradeConfigVersion } from "./config-version-shim.js";
13
- import { deepMergeConfig } from "./deep-merge.js";
17
+ import { deepMergeConfig, isPlainObject } from "./deep-merge.js";
14
18
  import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
15
19
  import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
16
20
  export { stripJsonComments } from "./config-io.js";
@@ -139,25 +143,31 @@ export function acquireConfigReadFence() {
139
143
  }
140
144
  }
141
145
  /**
142
- * Parse raw config text and validate via Zod.
143
- * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
144
- *
145
- * The schema accepts only the current config version. A known older version
146
- * is auto-upgraded in memory first (see `./config-version-shim`); anything
147
- * else including anything newer is rejected before the canonical shape
148
- * is validated.
146
+ * Run the per-file config pipeline every raw config object goes through
147
+ * before it is either validated (the local/top-level file) or merged in as
148
+ * an `extends` base: JSONC parse already done by the caller, then version
149
+ * shim, then legacy `stashDir`/`sources[]`/`installed[]` shim, then the
150
+ * legacy `extraParams` lift (#852). Shared by {@link parseAndValidateConfigText}
151
+ * (the local file) and {@link resolveExtendsChain} (each base in the chain) so
152
+ * a fleet-shared base config can carry its own old `configVersion` / legacy
153
+ * shape independently of the file that extends it.
149
154
  */
150
- export function parseAndValidateConfigText(text, sourcePath) {
155
+ function runConfigFilePipeline(text, sourcePath) {
151
156
  const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
152
157
  const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
153
- // #852 (following #815): a config still using legacy `extraParams` keys —
154
- // e.g. `reasoning_effort`, a documented 0.9.1 workaround — needs to be
155
- // rewritten onto the first-class engine field they now shadow. This used
156
- // to happen silently, in memory, on every load; that ran forever and never
157
- // converged. The lift itself is now `akm migrate apply`'s job (see
158
- // scripts/akm-migrate/migrate/config-extra-params.ts) and persists to disk, so a
159
- // config that has not been migrated yet fails closed here instead of
160
- // silently drifting from what's on disk.
158
+ return liftExtraParamsOrThrow(parsedRaw, sourcePath);
159
+ }
160
+ /**
161
+ * #852 (following #815): a config still using legacy `extraParams` keys
162
+ * e.g. `reasoning_effort`, a documented 0.9.1 workaround needs to be
163
+ * rewritten onto the first-class engine field they now shadow. This used
164
+ * to happen silently, in memory, on every load; that ran forever and never
165
+ * converged. The lift itself is now `akm migrate apply`'s job (see
166
+ * scripts/akm-migrate/migrate/config-extra-params.ts) and persists to disk, so a
167
+ * config that has not been migrated yet fails closed here instead of
168
+ * silently drifting from what's on disk.
169
+ */
170
+ function liftExtraParamsOrThrow(parsedRaw, sourcePath) {
161
171
  const where = sourcePath ? ` at ${sourcePath}` : "";
162
172
  const { config: liftedConfig, lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
163
173
  if (conflicts.length > 0) {
@@ -169,7 +179,22 @@ export function parseAndValidateConfigText(text, sourcePath) {
169
179
  if (lifted.length > 0) {
170
180
  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.`);
171
181
  }
172
- const parsed = AkmConfigSchema.safeParse(liftedConfig);
182
+ return liftedConfig;
183
+ }
184
+ /**
185
+ * Resolve a local file's `extends` chain and validate the merged result
186
+ * (deep-merged under `DEFAULT_CONFIG`) via Zod ({@link AkmConfigSchema}).
187
+ * `liftedLocalRaw` must already be through {@link runConfigFilePipeline}.
188
+ *
189
+ * Split out of {@link parseAndValidateConfigText} so `mutateConfig` (#945
190
+ * finding: baking extends-inherited fields into the local file on every
191
+ * write) can build the same effective config from a `localRaw` it also
192
+ * keeps around, instead of only getting the final merged `AkmConfig` back.
193
+ */
194
+ function buildEffectiveConfig(liftedLocalRaw, sourcePath) {
195
+ const withExtends = resolveExtendsChain(liftedLocalRaw, sourcePath);
196
+ const where = sourcePath ? ` at ${sourcePath}` : "";
197
+ const parsed = AkmConfigSchema.safeParse(withExtends);
173
198
  if (!parsed.success) {
174
199
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
175
200
  throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
@@ -182,6 +207,199 @@ export function parseAndValidateConfigText(text, sourcePath) {
182
207
  }
183
208
  return finalResult.data;
184
209
  }
210
+ /**
211
+ * Parse raw config text and validate via Zod.
212
+ * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
213
+ *
214
+ * The schema accepts only the current config version. A known older version
215
+ * is auto-upgraded in memory first (see `./config-version-shim`); anything
216
+ * else — including anything newer — is rejected before the canonical shape
217
+ * is validated. When the config sets `extends` (#945), its resolved chain is
218
+ * deep-merged underneath before validation — see {@link resolveExtendsChain}.
219
+ */
220
+ export function parseAndValidateConfigText(text, sourcePath) {
221
+ const liftedConfig = runConfigFilePipeline(text, sourcePath);
222
+ return buildEffectiveConfig(liftedConfig, sourcePath);
223
+ }
224
+ /**
225
+ * Walk an `extends` chain starting at `localRaw` (already through
226
+ * {@link runConfigFilePipeline}), returning every layer from the local file
227
+ * outward to the chain's root, local first. A chain is allowed (a base may
228
+ * itself set `extends`); a cycle (a resolved source repeating) throws
229
+ * {@link ConfigError} instead of recursing forever.
230
+ */
231
+ function collectExtendsLayers(localRaw, configPath) {
232
+ const layers = [{ ref: undefined, raw: localRaw }];
233
+ const visited = new Set(configPath ? [path.resolve(configPath)] : []);
234
+ let current = localRaw;
235
+ let currentPath = configPath;
236
+ while (true) {
237
+ const ref = current.extends;
238
+ if (ref === undefined)
239
+ return layers;
240
+ if (typeof ref !== "string" || !ref.trim()) {
241
+ throw new ConfigError(`Invalid "extends"${currentPath ? ` at ${currentPath}` : ""}: expected a non-empty string (a file path or bundle//path ref), got ${JSON.stringify(ref)}.`, "INVALID_CONFIG_FILE");
242
+ }
243
+ const { text, resolvedPath } = resolveConfigRefSource(ref, current, currentPath);
244
+ if (visited.has(resolvedPath)) {
245
+ throw new ConfigError(`Config "extends" cycle detected: "${ref}"${currentPath ? ` (from ${currentPath})` : ""} resolves back to an already-visited config at ${resolvedPath}.`, "INVALID_CONFIG_FILE");
246
+ }
247
+ visited.add(resolvedPath);
248
+ const baseRaw = runConfigFilePipeline(text, resolvedPath);
249
+ layers.push({ ref, raw: baseRaw });
250
+ current = baseRaw;
251
+ currentPath = resolvedPath;
252
+ }
253
+ }
254
+ /**
255
+ * Deep-merge an `extends` chain into its effective raw shape: each layer's
256
+ * own fields win over its base's (`deepMergeConfig` "local wins" semantics),
257
+ * reduced root-to-local so the local file's fields win overall. `DEFAULT_CONFIG`
258
+ * is NOT applied here — the caller still layers it outermost, unchanged.
259
+ *
260
+ * The merged result's `extends` field, if any, is always the LOCAL file's own
261
+ * literal value (never a base's) — `deepMergeConfig`'s local-wins semantics
262
+ * already guarantee this at every level, since a layer only has `extends` set
263
+ * when it itself declares one. Keeping it (rather than stripping it) lets
264
+ * `akm config set`/`unset` round-trip the directive: `mutateConfig` reads the
265
+ * effective config as `current` for the mutation, but writes back only the
266
+ * fields `pruneUnchangedInheritedFields` finds changed-or-already-local (#945
267
+ * finding — the effective object used to be baked into the local file
268
+ * verbatim, duplicating every inherited field into it). That pruning keys off
269
+ * which fields are present in `current`/`next`, so `extends` has to still be
270
+ * one of them or it would silently vanish from disk on the very next
271
+ * `config set`.
272
+ */
273
+ function resolveExtendsChain(localRaw, configPath) {
274
+ const layers = collectExtendsLayers(localRaw, configPath);
275
+ let merged = {};
276
+ for (let i = layers.length - 1; i >= 0; i--) {
277
+ merged = deepMergeConfig(merged, layers[i].raw);
278
+ }
279
+ return merged;
280
+ }
281
+ /** `~` expands to the home directory, mirroring `apiKeyFile`'s resolution (engine-resolution.ts). */
282
+ function expandExtendsHomePath(p) {
283
+ return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
284
+ }
285
+ /** True when `ref`'s segment before the first `//` is a legal bundle slug — the `bundle//<path>` shape, not a plain filesystem path. */
286
+ function looksLikeBundleAssetRef(ref) {
287
+ const boundary = ref.indexOf("//");
288
+ return boundary > 0 && isBundleSlug(ref.slice(0, boundary));
289
+ }
290
+ /**
291
+ * Resolve an `extends` value (or a `config diff <ref>` CLI argument — same
292
+ * grammar) to its config text and the absolute path it was read from. Either
293
+ * a filesystem path (relative to the directory of `fromConfigPath`; `~`
294
+ * expands) or a `bundle//<path>` ref, where the part after `//` is a plain
295
+ * file path *relative to that bundle's content root* — NOT an asset
296
+ * conceptId. It needs no asset type (`scripts/`, `knowledge/`, …) and is
297
+ * never indexed; a shared config file just lives wherever the bundle puts
298
+ * it. No URL form and no fetch/sync — the source must already exist locally;
299
+ * a missing file/bundle throws {@link ConfigError} naming the ref, as does an
300
+ * empty, absolute, or content-root-escaping path after `//`.
301
+ */
302
+ function resolveConfigRefSource(ref, context, fromConfigPath) {
303
+ return looksLikeBundleAssetRef(ref)
304
+ ? resolveConfigBundleRefSource(ref, context)
305
+ : resolveConfigFileRefSource(ref, fromConfigPath);
306
+ }
307
+ function resolveConfigFileRefSource(ref, fromConfigPath) {
308
+ const expanded = expandExtendsHomePath(ref);
309
+ let resolvedPath;
310
+ if (path.isAbsolute(expanded)) {
311
+ resolvedPath = expanded;
312
+ }
313
+ else if (fromConfigPath) {
314
+ resolvedPath = path.resolve(path.dirname(fromConfigPath), expanded);
315
+ }
316
+ else {
317
+ throw new ConfigError(`extends "${ref}" is a relative path, but the current config has no known file location to resolve it against.`, "INVALID_CONFIG_FILE");
318
+ }
319
+ const text = readConfigText(resolvedPath);
320
+ if (text === undefined) {
321
+ throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist. Create the file first, or point "extends" at an existing config.`, "INVALID_CONFIG_FILE");
322
+ }
323
+ return { text, resolvedPath };
324
+ }
325
+ function resolveConfigBundleRefSource(ref, context) {
326
+ // Split by hand rather than through `parseBundleRef`: the part after `//`
327
+ // is a plain file path here, not an asset conceptId, so it must not be run
328
+ // through conceptId validation (which, for instance, rejects every `..`
329
+ // segment outright — stricter than the "must not escape the content root"
330
+ // rule this function enforces itself below via `path.resolve`).
331
+ // `looksLikeBundleAssetRef` already confirmed `ref` has this `bundle//`
332
+ // shape with a legal bundle slug before routing here.
333
+ const boundary = ref.indexOf("//");
334
+ const bundleId = ref.slice(0, boundary);
335
+ const relativePath = ref.slice(boundary + 2);
336
+ if (!relativePath) {
337
+ throw new ConfigError(`extends "${ref}" is missing a path after "${bundleId}//".`, "INVALID_CONFIG_FILE");
338
+ }
339
+ if (path.isAbsolute(relativePath)) {
340
+ throw new ConfigError(`extends "${ref}" must be a path relative to bundle "${bundleId}"'s content root, not absolute.`, "INVALID_CONFIG_FILE");
341
+ }
342
+ const bundles = isRecord(context.bundles) ? context.bundles : undefined;
343
+ const bundleEntry = bundles?.[bundleId];
344
+ const bundlePath = isRecord(bundleEntry) && typeof bundleEntry.path === "string" && bundleEntry.path.length > 0
345
+ ? bundleEntry.path
346
+ : undefined;
347
+ if (!bundlePath || !isRecord(bundleEntry)) {
348
+ throw new ConfigError(`extends "${ref}" names bundle "${bundleId}", which is not a configured filesystem bundle (bundles.${bundleId}.path). Only a filesystem bundle can host an "extends" source.`, "INVALID_CONFIG_FILE");
349
+ }
350
+ let componentRoot;
351
+ try {
352
+ componentRoot = bundleComponentConfig(bundleEntry)?.root;
353
+ }
354
+ catch (err) {
355
+ throw new ConfigError(`extends "${ref}" names bundle "${bundleId}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE");
356
+ }
357
+ const bundleRoot = bundleContentRoot(bundlePath, componentRoot);
358
+ const resolvedPath = path.resolve(bundleRoot, relativePath);
359
+ const relativeToRoot = path.relative(bundleRoot, resolvedPath);
360
+ if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
361
+ throw new ConfigError(`extends "${ref}" escapes bundle "${bundleId}"'s content root.`, "INVALID_CONFIG_FILE");
362
+ }
363
+ const text = readConfigText(resolvedPath);
364
+ if (text === undefined) {
365
+ throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist locally. Sync the bundle first, or point "extends" at an existing file.`, "INVALID_CONFIG_FILE");
366
+ }
367
+ return { text, resolvedPath };
368
+ }
369
+ /**
370
+ * `akm config get --show-source` (#945): which raw layer the dotted path's
371
+ * value comes from — `"local"` when the local file's own raw JSON sets it,
372
+ * `"extends:<ref>"` for the nearest chain member that sets it (the ref by
373
+ * which that member is reached), or `"default"` when no layer sets it
374
+ * (`DEFAULT_CONFIG` or schema default supplies it). Computed lazily by
375
+ * re-walking the raw layers on each call — no merge-time bookkeeping.
376
+ */
377
+ export function getConfigValueSource(dotted) {
378
+ const configPath = getConfigPath();
379
+ const text = readConfigText(configPath);
380
+ if (text === undefined)
381
+ return "default";
382
+ const liftedConfig = runConfigFilePipeline(text, configPath);
383
+ const segments = dotted.split(".").filter((s) => s.length > 0);
384
+ for (const layer of collectExtendsLayers(liftedConfig, configPath)) {
385
+ if (hasRawPath(layer.raw, segments)) {
386
+ return layer.ref === undefined ? "local" : `extends:${layer.ref}`;
387
+ }
388
+ }
389
+ return "default";
390
+ }
391
+ function hasRawPath(raw, segments) {
392
+ let cursor = raw;
393
+ for (const segment of segments) {
394
+ if (!isRecord(cursor) || !(segment in cursor))
395
+ return false;
396
+ cursor = cursor[segment];
397
+ }
398
+ return true;
399
+ }
400
+ // Exposed for `akm config diff` (config-cli.ts): the `<path|bundle//ref>`
401
+ // argument shares the exact same resolution grammar as `extends`.
402
+ export { resolveConfigRefSource };
185
403
  /**
186
404
  * The configured stash sources as an ordered {@link SourceConfigEntry} list.
187
405
  *
@@ -229,6 +447,56 @@ export function validateCompleteConfig(config) {
229
447
  throw new ConfigError(`Refusing to save invalid config:\n${lines}`, "INVALID_CONFIG_FILE", "Fix the listed fields, or undo the offending `akm config set`. " +
230
448
  "If this looks like an akm bug, re-run with --debug to attach the traceback.");
231
449
  }
450
+ /**
451
+ * #945 review finding: `mutateConfig` used to write the entire extends-merged
452
+ * *effective* config (`next`) back to the local file on every `config
453
+ * set`/`unset`, duplicating every inherited `engines`/`improve.strategies`
454
+ * field into the local file on the very first ordinary write after adopting
455
+ * `extends` — defeating the feature (shared config, ≤20-line local files)
456
+ * and silently freezing the local copy against future upstream changes.
457
+ *
458
+ * Reduces `after` (the mutated effective config) down to only what the
459
+ * mutation actually changed relative to `before` (the effective config
460
+ * *before* the mutation), plus whatever `localRaw` — the local file's own
461
+ * raw content, pre-`extends`-merge — already had explicitly. Everything
462
+ * else that only came along for the ride from `extends`/`DEFAULT_CONFIG` is
463
+ * left out, so it keeps being read from the base on the next load instead
464
+ * of being frozen as a local literal.
465
+ */
466
+ function pruneUnchangedInheritedFields(before, after, localRaw) {
467
+ if (!isPlainObject(after) || !isPlainObject(before))
468
+ return after;
469
+ const localRecord = isPlainObject(localRaw) ? localRaw : undefined;
470
+ const result = {};
471
+ for (const key of Object.keys(after)) {
472
+ const afterValue = after[key];
473
+ const beforeValue = before[key];
474
+ const localHasKey = localRecord ? Object.hasOwn(localRecord, key) : false;
475
+ if (isPlainObject(afterValue) && isPlainObject(beforeValue)) {
476
+ const pruned = pruneUnchangedInheritedFields(beforeValue, afterValue, localHasKey ? localRecord?.[key] : undefined);
477
+ const prunedHasContent = isPlainObject(pruned) ? Object.keys(pruned).length > 0 : pruned !== undefined;
478
+ if (prunedHasContent || localHasKey)
479
+ result[key] = pruned;
480
+ continue;
481
+ }
482
+ if (localHasKey || !isDeepStrictEqual(afterValue, beforeValue)) {
483
+ result[key] = afterValue;
484
+ }
485
+ }
486
+ return result;
487
+ }
488
+ /**
489
+ * What to persist for a `mutateConfig`/`mutateConfigWithPrecommit` write:
490
+ * the full effective `next` when the local file has no `extends` (unchanged
491
+ * pre-#945 behavior), otherwise only the changed-or-already-local fields
492
+ * (#945 finding above).
493
+ */
494
+ function configWriteBody(localRaw, current, next) {
495
+ const usesExtends = typeof localRaw?.extends === "string" && localRaw.extends.trim().length > 0;
496
+ if (!usesExtends)
497
+ return next;
498
+ return pruneUnchangedInheritedFields(current, next, localRaw);
499
+ }
232
500
  /**
233
501
  * Mutate config under one fail-closed lock spanning read, merge, validation,
234
502
  * ordinary backup, and atomic write.
@@ -241,7 +509,8 @@ export function mutateConfig(mutate, options) {
241
509
  if (text === undefined && options?.absentNoop) {
242
510
  return { config: { ...DEFAULT_CONFIG }, written: false };
243
511
  }
244
- const current = text === undefined ? { ...DEFAULT_CONFIG } : parseAndValidateConfigText(text, configPath);
512
+ const localRaw = text === undefined ? undefined : runConfigFilePipeline(text, configPath);
513
+ const current = localRaw === undefined ? { ...DEFAULT_CONFIG } : buildEffectiveConfig(localRaw, configPath);
245
514
  const mutated = mutate(current);
246
515
  if (mutated === current)
247
516
  return { config: current, written: false };
@@ -249,7 +518,7 @@ export function mutateConfig(mutate, options) {
249
518
  if (text !== undefined)
250
519
  backupExistingConfig(configPath);
251
520
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
252
- writeConfigAtomic(configPath, sanitizeConfigForWrite(next));
521
+ writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next)));
253
522
  return { config: next, written: true };
254
523
  });
255
524
  }
@@ -264,7 +533,8 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
264
533
  const release = acquireConfigLock();
265
534
  try {
266
535
  const text = readConfigText(configPath);
267
- const current = text === undefined ? { ...DEFAULT_CONFIG } : parseAndValidateConfigText(text, configPath);
536
+ const localRaw = text === undefined ? undefined : runConfigFilePipeline(text, configPath);
537
+ const current = localRaw === undefined ? { ...DEFAULT_CONFIG } : buildEffectiveConfig(localRaw, configPath);
268
538
  const mutated = mutate(current);
269
539
  const next = validateCompleteConfig({ ...mutated, configVersion: CURRENT_CONFIG_VERSION });
270
540
  if (text !== undefined)
@@ -273,7 +543,7 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
273
543
  if (mutated === current)
274
544
  return { config: current, written: false, precommit: precommitResult };
275
545
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
276
- writeConfigAtomic(configPath, sanitizeConfigForWrite(next));
546
+ writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next)));
277
547
  return { config: next, written: true, precommit: precommitResult };
278
548
  }
279
549
  finally {
@@ -383,7 +653,15 @@ export function resolveSecret(value, resolveFromStore) {
383
653
  if (!value.includes("$"))
384
654
  return value;
385
655
  return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
386
- return process.env[(braced ?? bare)] ?? "";
656
+ const name = (braced ?? bare);
657
+ const resolved = process.env[name];
658
+ if (!resolved) {
659
+ // #953: an unset/empty $VAR used to substitute silently, so a gateway
660
+ // that enforces auth on inference (e.g. Bifrost) failed every call with
661
+ // an opaque 401 instead of a diagnosable warning naming the variable.
662
+ warnOnce(`config:empty-env-var:${name}`, `Environment variable ${name} referenced by a $VAR apiKey is unset or empty; the request will be sent without a valid credential.`);
663
+ }
664
+ return resolved ?? "";
387
665
  });
388
666
  }
389
667
  /**
@@ -16,13 +16,60 @@ import path from "node:path";
16
16
  import { resolveSourceEntries } from "../indexer/search/search-source.js";
17
17
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
18
18
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "./asset/asset-create.js";
19
- import { assetPathForName } from "./asset/asset-placement.js";
19
+ import { assetPathForName, deriveCanonicalAssetName } from "./asset/asset-placement.js";
20
20
  import { displayRef, isFullRefInput, parseRefInput } from "./asset/resolve-ref.js";
21
21
  import { isWithin } from "./common.js";
22
22
  import { loadConfig } from "./config/config.js";
23
23
  import { NotFoundError, UsageError } from "./errors.js";
24
24
  import { resolveMutationTarget } from "./mutation-target.js";
25
+ import { sensitiveMarkerPath } from "./sensitive-marker-path.js";
25
26
  import { formatRefForMessage, withWriteTargetMutation } from "./write-source.js";
27
+ export { sensitiveMarkerPath } from "./sensitive-marker-path.js";
28
+ /**
29
+ * Walk each stash's env files and return one entry per `.env` file, using the
30
+ * env asset spec's canonical-name logic (e.g. `env/team/prod.env` →
31
+ * `env/team/prod`, `env/team/.env` → `env/team/default`). Moved here from
32
+ * `commands/env/env-cli.ts` (#950) so `commands/health` can reuse it to name
33
+ * which env asset supplies a credential's variable — pure move, `akm env
34
+ * list`'s behaviour is unchanged. `listKeysFn` stays injected (rather than a
35
+ * static import of `commands/env/env.ts`) so this core module never depends
36
+ * upward on the commands layer. `config` defaults to the real `loadConfig()`
37
+ * (unchanged default for `akm env list`); `commands/health` passes its own
38
+ * injected/resolved config so a test-supplied `loadConfig` seam is honoured
39
+ * instead of this always re-reading the real config/sources.
40
+ */
41
+ export function listEnvsRecursive(listKeysFn, config = loadConfig()) {
42
+ const result = [];
43
+ for (const source of resolveSourceEntries(undefined, config)) {
44
+ const root = path.join(source.path, "env");
45
+ if (!fs.existsSync(root))
46
+ continue;
47
+ const walk = (dir) => {
48
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ const full = path.join(dir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ walk(full);
52
+ continue;
53
+ }
54
+ if (!entry.isFile())
55
+ continue;
56
+ if (entry.name !== ".env" && !entry.name.endsWith(".env"))
57
+ continue;
58
+ const canonical = deriveCanonicalAssetName("env", root, full);
59
+ if (!canonical)
60
+ continue;
61
+ // Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
62
+ const markerPath = sensitiveMarkerPath(full, "env");
63
+ if (fs.existsSync(markerPath))
64
+ continue;
65
+ const { keys } = listKeysFn(full);
66
+ result.push({ ref: makeEnvRef(canonical, source, config), path: full, keys });
67
+ }
68
+ };
69
+ walk(root);
70
+ }
71
+ return result;
72
+ }
26
73
  /**
27
74
  * The `vault` asset type was removed in 0.9.0. The env/secret input path no
28
75
  * longer routes through the legacy stored-ref parser (which carries the removal
@@ -91,10 +138,17 @@ export function findEnvSource(origin, type, name) {
91
138
  }
92
139
  return named;
93
140
  }
94
- export function makeEnvRef(name, source) {
141
+ /**
142
+ * `config` defaults to the real `loadConfig()`, same as {@link displayDefaultBundle}.
143
+ * `listEnvsRecursive` threads its own (possibly injected) config through here so a
144
+ * caller-supplied config governs ref display the same way it governs which stashes
145
+ * get walked — otherwise this would silently fall back to reading the real config a
146
+ * second time even when the walk itself honoured an injected one.
147
+ */
148
+ export function makeEnvRef(name, source, config = loadConfig()) {
95
149
  // F4b output-spelling flip: `env/name` in the primary stash, `bundle//env/name`
96
150
  // for a slug-clean named source.
97
- return displayRef({ type: "env", name, bundleId: source?.registryId }, displayDefaultBundle(source));
151
+ return displayRef({ type: "env", name, bundleId: source?.registryId }, displayDefaultBundle(source, config));
98
152
  }
99
153
  /**
100
154
  * Resolve an env ref to an absolute `.env` path. Accepts the `env/<name>`
@@ -132,8 +186,7 @@ export function makeSecretRef(name, source) {
132
186
  // `bundle//secrets/name` for a slug-clean named source.
133
187
  return displayRef({ type: "secret", name, bundleId: source?.registryId }, displayDefaultBundle(source));
134
188
  }
135
- function displayDefaultBundle(source) {
136
- const config = loadConfig();
189
+ function displayDefaultBundle(source, config = loadConfig()) {
137
190
  if (config.defaultBundle || !source)
138
191
  return config.defaultBundle;
139
192
  const primary = resolveSourceEntries(undefined, config)[0];
@@ -77,7 +77,11 @@ const USAGE_HINTS = {
77
77
  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
78
  // P3b (docs/plans/specs/p3b-child-executor.md §4.3).
79
79
  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
+ };
81
+ /** Default hint for each TransientErrorCode. */
82
+ const TRANSIENT_HINTS = {
80
83
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
84
+ STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
81
85
  };
82
86
  /** Default hint for each NotFoundError code. */
83
87
  const NOT_FOUND_HINTS = {
@@ -89,6 +93,7 @@ const NOT_FOUND_HINTS = {
89
93
  // for a mistyped id, which points at the wrong thing entirely.
90
94
  PROPOSAL_NOT_FOUND: "Run `akm proposal list` to see pending proposals and their ids.",
91
95
  FILE_NOT_FOUND: "Check the path exists and is readable.",
96
+ IMPROVE_RUN_NOT_FOUND: "Run `akm improve` first, or `akm improve report --since 30d` to see recent run ids in `runIds`.",
92
97
  };
93
98
  /**
94
99
  * Base class for all akm-thrown, classified errors. Carries the `kind`
@@ -131,6 +136,31 @@ export class UsageError extends AkmError {
131
136
  return this._hint ?? USAGE_HINTS[this.code];
132
137
  }
133
138
  }
139
+ /**
140
+ * Raised when a condition is ordinary, retryable contention rather than a
141
+ * bad command line or a genuine failure — another akm process holds a lock
142
+ * or is writing state.db right now. Distinct from `UsageError` (#948
143
+ * addendum, dev-team field review 2026-09-09): schedulers classify exit 2 as
144
+ * "fix the command line", so contention needs its own exit code (75,
145
+ * sysexits EX_TEMPFAIL) a cron wrapper can branch on to retry instead of
146
+ * alerting.
147
+ */
148
+ export class TransientError extends AkmError {
149
+ kind = "transient";
150
+ code;
151
+ _hint;
152
+ constructor(msg, code, hint) {
153
+ super(msg);
154
+ this.name = "TransientError";
155
+ this.code = code;
156
+ this._hint = hint;
157
+ // Fixes `instanceof` checks under ES5 transpilation targets.
158
+ Object.setPrototypeOf(this, new.target.prototype);
159
+ }
160
+ hint() {
161
+ return this._hint ?? TRANSIENT_HINTS[this.code];
162
+ }
163
+ }
134
164
  /** Raised when a requested resource (asset, entry, file) is not found. */
135
165
  export class NotFoundError extends AkmError {
136
166
  kind = "not-found";