akm-cli 0.9.14 → 0.9.15-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/akm +54 -1
  4. package/dist/akm-migrate +34 -1
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  6. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  7. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  8. package/dist/assets/tasks/core/improve.yml +1 -1
  9. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  13. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  14. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  15. package/dist/cli/retired-commands.js +0 -1
  16. package/dist/cli/shared.js +9 -0
  17. package/dist/cli/unknown-flags.js +1 -0
  18. package/dist/cli.js +40 -3
  19. package/dist/commands/config-cli.js +85 -3
  20. package/dist/commands/env/env-cli.js +1 -42
  21. package/dist/commands/env/env.js +1 -1
  22. package/dist/commands/env/secret-cli.js +1 -2
  23. package/dist/commands/health/checks.js +357 -63
  24. package/dist/commands/health/engine-usage.js +45 -0
  25. package/dist/commands/health/improve-metrics.js +18 -0
  26. package/dist/commands/health/llm-usage.js +41 -1
  27. package/dist/commands/health/plugin-staleness.js +7 -3
  28. package/dist/commands/health/version-drift.js +93 -0
  29. package/dist/commands/health/windows.js +3 -1
  30. package/dist/commands/health.js +44 -9
  31. package/dist/commands/improve/consolidate/chunking.js +4 -2
  32. package/dist/commands/improve/improve-cli.js +99 -5
  33. package/dist/commands/improve/improve-report.js +154 -0
  34. package/dist/commands/improve/improve-result-file.js +45 -33
  35. package/dist/commands/improve/improve-strategies.js +133 -3
  36. package/dist/commands/improve/improve-usage-report.js +182 -0
  37. package/dist/commands/improve/improve.js +40 -3
  38. package/dist/commands/improve/locks.js +28 -78
  39. package/dist/commands/improve/planner.js +1 -0
  40. package/dist/commands/improve/preparation.js +9 -1
  41. package/dist/commands/improve/reflect.js +44 -4
  42. package/dist/commands/models-cli.js +50 -1
  43. package/dist/commands/proposal/repository.js +8 -3
  44. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  45. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  46. package/dist/commands/read/search-cli.js +38 -2
  47. package/dist/commands/read/show.js +103 -4
  48. package/dist/commands/sources/info.js +5 -1
  49. package/dist/commands/sources/installed-stashes.js +58 -16
  50. package/dist/commands/sources/self-update.js +2 -2
  51. package/dist/commands/sources/stash-cli.js +48 -0
  52. package/dist/commands/tasks/tasks-cli.js +49 -2
  53. package/dist/commands/workflow-cli.js +86 -12
  54. package/dist/core/asset/markdown-fragments.js +35 -0
  55. package/dist/core/config/config-schema.js +14 -0
  56. package/dist/core/config/config.js +302 -24
  57. package/dist/core/config/schema/embedding.js +41 -0
  58. package/dist/core/env-secret-ref.js +58 -5
  59. package/dist/core/errors.js +30 -0
  60. package/dist/core/file-lock.js +49 -15
  61. package/dist/core/improve-result.js +51 -0
  62. package/dist/core/loopback.js +17 -0
  63. package/dist/core/parent-watchdog.js +64 -0
  64. package/dist/core/paths.js +11 -0
  65. package/dist/core/run-lock.js +107 -0
  66. package/dist/core/sensitive-marker-path.js +19 -0
  67. package/dist/core/state-db.js +74 -14
  68. package/dist/indexer/index-rebuild-lock.js +73 -0
  69. package/dist/indexer/index-writer-lock.js +40 -1
  70. package/dist/indexer/index-written-assets.js +29 -1
  71. package/dist/indexer/indexer.js +93 -29
  72. package/dist/indexer/materialize-embeddings.js +564 -48
  73. package/dist/indexer/search/db-search.js +49 -2
  74. package/dist/indexer/search/search-source.js +23 -1
  75. package/dist/integrations/agent/engine-resolution.js +96 -6
  76. package/dist/integrations/agent/execution-definitions.js +6 -15
  77. package/dist/integrations/agent/execution-lowering.js +6 -1
  78. package/dist/integrations/agent/execution-preparation.js +1 -1
  79. package/dist/integrations/agent/model-map.js +123 -20
  80. package/dist/integrations/agent/prompts.js +40 -8
  81. package/dist/integrations/agent/runner-dispatch.js +9 -3
  82. package/dist/integrations/agent/runner.js +2 -0
  83. package/dist/llm/client.js +8 -3
  84. package/dist/llm/embedder.js +20 -8
  85. package/dist/llm/embedders/local.js +10 -2
  86. package/dist/llm/embedders/remote.js +497 -32
  87. package/dist/output/shapes/helpers.js +38 -2
  88. package/dist/output/shapes/models-list.js +16 -0
  89. package/dist/output/shapes/passthrough.js +2 -0
  90. package/dist/output/shapes.js +4 -0
  91. package/dist/output/text/command-format.js +29 -0
  92. package/dist/output/text/helpers.js +1 -1
  93. package/dist/output/text/improve-report.js +27 -0
  94. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  95. package/dist/output/text/show-format.js +4 -0
  96. package/dist/output/text.js +4 -0
  97. package/dist/scripts/akm-migrate-node.js +25146 -21759
  98. package/dist/scripts/akm-migrate.js +24271 -20885
  99. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  100. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  101. package/dist/storage/repositories/index-fts-repository.js +49 -6
  102. package/dist/storage/repositories/index-schema.js +16 -0
  103. package/dist/storage/repositories/index-vec-repository.js +30 -0
  104. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  105. package/dist/tasks/backends/cron.js +14 -7
  106. package/dist/tasks/run/run-native-task.js +23 -1
  107. package/dist/tasks/run/run-workflow-task.js +16 -0
  108. package/dist/workflows/exec/child-workflow.js +2 -2
  109. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  110. package/dist/workflows/exec/run-workflow.js +6 -5
  111. package/dist/workflows/runtime/runs.js +33 -5
  112. package/docs/migration/release-notes/0.9.15.md +133 -0
  113. package/docs/migration/release-notes/README.md +5 -0
  114. package/docs/reference/cli.md +271 -30
  115. package/docs/reference/configuration.md +234 -21
  116. package/docs/reference/data-and-telemetry.md +8 -0
  117. package/docs/reference/tasks.md +16 -1
  118. package/docs/reference/workflow-schema.md +5 -1
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +47 -0
@@ -11,7 +11,8 @@ import { getStringArg } from "../cli/parse-args.js";
11
11
  import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output, outputWithExitCode } from "../cli/shared.js";
12
12
  import { armAbortDeadline } from "../core/abort-deadline.js";
13
13
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
14
- import { NotFoundError, UsageError } from "../core/errors.js";
14
+ import { NotFoundError, TransientError, UsageError } from "../core/errors.js";
15
+ import { warn } from "../core/warn.js";
15
16
  import { akmIndex } from "../indexer/indexer.js";
16
17
  import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
17
18
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
@@ -30,10 +31,16 @@ const workflowStatusCommand = defineJsonCommand({
30
31
  "diagnostic text). Diagnostics only — step evidence stays deterministic and is unaffected.",
31
32
  default: false,
32
33
  },
34
+ "all-scopes": {
35
+ type: "boolean",
36
+ description: "When resolving a workflow ref (not a run id), search every scope instead of only the current one (#942).",
37
+ default: false,
38
+ },
33
39
  },
34
40
  async run({ args }) {
35
41
  const target = args.target;
36
42
  const includeUnits = args.units === true;
43
+ const allScopes = args["all-scopes"] === true;
37
44
  const resolvedRunId = await resolveWorkflowRunTarget(target);
38
45
  if (resolvedRunId !== undefined) {
39
46
  const result = await getWorkflowStatus(resolvedRunId, { includeUnits });
@@ -41,8 +48,9 @@ const workflowStatusCommand = defineJsonCommand({
41
48
  return;
42
49
  }
43
50
  let runs;
51
+ let scopeKey;
44
52
  try {
45
- ({ runs } = await listWorkflowRuns({ workflowRef: target }));
53
+ ({ runs, scopeKey } = await listWorkflowRuns({ workflowRef: target, allScopes }));
46
54
  }
47
55
  catch (error) {
48
56
  if (!target.includes(":") && !target.includes("/")) {
@@ -51,8 +59,17 @@ const workflowStatusCommand = defineJsonCommand({
51
59
  throw error;
52
60
  }
53
61
  const mostRecent = runs[0];
54
- if (!mostRecent)
62
+ if (!mostRecent) {
63
+ // #942: name the scope actually searched and point at `--all-scopes`
64
+ // rather than a bare "not found" — the ref-fallthrough lookup is
65
+ // scope-local by default, so "no runs" here means "none in THIS
66
+ // scope", not "none anywhere". Already searching every scope (or no
67
+ // real scope was filtered on) has nothing more specific to suggest.
68
+ if (!allScopes && scopeKey !== null) {
69
+ throw new NotFoundError(`No workflow runs found for ${target} in scope ${scopeKey}.`, "WORKFLOW_NOT_FOUND", `Run 'akm workflow status ${target} --all-scopes' to search every scope.`);
70
+ }
55
71
  throw new NotFoundError(`No workflow runs found for ${target}`, "WORKFLOW_NOT_FOUND");
72
+ }
56
73
  const result = await getWorkflowStatus(mostRecent.id, { includeUnits });
57
74
  output("workflow-status", result);
58
75
  },
@@ -70,12 +87,20 @@ const workflowListCommand = defineJsonCommand({
70
87
  description: "Also include child workflow runs (hidden by default, P3b)",
71
88
  default: false,
72
89
  },
90
+ "all-scopes": {
91
+ type: "boolean",
92
+ description: "Search every scope instead of only the current one (#942). The envelope's top-level `scopeKey` is " +
93
+ "`null` with this flag, otherwise the scope that was searched — so an empty `runs: []` is never " +
94
+ 'indistinguishable from "nothing anywhere".',
95
+ default: false,
96
+ },
73
97
  },
74
98
  async run({ args }) {
75
99
  const result = await listWorkflowRuns({
76
100
  workflowRef: args.ref,
77
101
  activeOnly: args.active,
78
102
  includeChildren: args.children,
103
+ allScopes: args["all-scopes"],
79
104
  });
80
105
  output("workflow-list", result);
81
106
  },
@@ -145,6 +170,20 @@ const workflowCreateCommand = defineJsonCommand({
145
170
  output("workflow-create", { ok: true, ...result });
146
171
  },
147
172
  });
173
+ /**
174
+ * `--skip-if-locked` (#948) eligibility: only these two named, retryable
175
+ * `TransientError` codes (#948 addendum — moved off UsageError, exit 75) turn
176
+ * a `workflow run` failure into a graceful skip — `RUN_LEASE_HELD` (another
177
+ * engine invocation is driving THIS run, `workflow-runs-repository.ts`'s
178
+ * single-driver lease) and `STATE_DB_CONTENDED` (an unrelated akm process is
179
+ * writing state.db right now, `core/state-db.ts`'s BEGIN IMMEDIATE retry
180
+ * exhaustion). Every other error — a bad flag, an unresolvable target —
181
+ * still fails loudly.
182
+ */
183
+ const WORKFLOW_RUN_SKIP_REASONS = {
184
+ RUN_LEASE_HELD: "lock-held",
185
+ STATE_DB_CONTENDED: "state-db-contended",
186
+ };
148
187
  const workflowRunCommand = defineJsonCommand({
149
188
  meta: {
150
189
  name: "run",
@@ -161,6 +200,13 @@ const workflowRunCommand = defineJsonCommand({
161
200
  "(never abandons it). A workflow ref only — passing a run id with --new is a usage error.",
162
201
  default: false,
163
202
  },
203
+ "skip-if-locked": {
204
+ type: "boolean",
205
+ description: "If another akm process already holds this run's engine lease, or state.db is busy with another " +
206
+ "writer, skip gracefully (exit 0) instead of failing (exit 75). Use for high-frequency scheduled runs " +
207
+ "so they don't pile up failures while a longer-running invocation is in progress.",
208
+ default: false,
209
+ },
164
210
  },
165
211
  async run({ args, rawArgs }) {
166
212
  const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
@@ -168,6 +214,7 @@ const workflowRunCommand = defineJsonCommand({
168
214
  const maxSteps = parseIntegerFlag(getStringArg(args, "max-steps"), "--max-steps", 1);
169
215
  const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0);
170
216
  const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
217
+ const skipIfLocked = args["skip-if-locked"];
171
218
  const controller = new AbortController();
172
219
  let signalExitCode;
173
220
  const interrupt = (signal) => {
@@ -185,14 +232,31 @@ const workflowRunCommand = defineJsonCommand({
185
232
  reason: `Workflow run timed out after ${timeoutMs}ms.`,
186
233
  });
187
234
  try {
188
- const result = await runWorkflowSteps({
189
- target: args.target,
190
- parameterFlags,
191
- ...(maxSteps !== undefined ? { maxSteps } : {}),
192
- ...(maxRetries !== undefined ? { maxRetries } : {}),
193
- newRun: args.new,
194
- signal: controller.signal,
195
- });
235
+ let result;
236
+ try {
237
+ result = await runWorkflowSteps({
238
+ target: args.target,
239
+ parameterFlags,
240
+ ...(maxSteps !== undefined ? { maxSteps } : {}),
241
+ ...(maxRetries !== undefined ? { maxRetries } : {}),
242
+ newRun: args.new,
243
+ signal: controller.signal,
244
+ });
245
+ }
246
+ catch (err) {
247
+ // #948: `--skip-if-locked` extends improve's "another run already
248
+ // holds this" skip semantics to `workflow run`. Only these two named,
249
+ // retryable TransientError codes are eligible (#948 addendum — moved
250
+ // off UsageError) — a bad flag or malformed input still fails loudly
251
+ // even with the flag set.
252
+ if (skipIfLocked && err instanceof TransientError && WORKFLOW_RUN_SKIP_REASONS[err.code]) {
253
+ const reason = WORKFLOW_RUN_SKIP_REASONS[err.code];
254
+ warn(`[workflow] ${err.message} skipping (--skip-if-locked)`);
255
+ output("workflow-run", { ok: true, target: args.target, skipped: { reason, message: err.message } });
256
+ return;
257
+ }
258
+ throw err;
259
+ }
196
260
  // The abort is observed between steps, so a deadline landing in the run's
197
261
  // final bookkeeping fires on a run that then finishes. Reporting that as
198
262
  // timed out would send an operator to resume a run with nothing left to
@@ -222,7 +286,17 @@ const WORKFLOW_RUN_VALUE_FLAGS = new Set([
222
286
  "shape",
223
287
  "output",
224
288
  ]);
225
- const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set(["quiet", "verbose", "help", "no-quiet", "no-verbose", "new", "no-new"]);
289
+ const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set([
290
+ "quiet",
291
+ "verbose",
292
+ "help",
293
+ "no-quiet",
294
+ "no-verbose",
295
+ "new",
296
+ "no-new",
297
+ "skip-if-locked",
298
+ "no-skip-if-locked",
299
+ ]);
226
300
  export function parseWorkflowParameterFlags(rawArgs, target) {
227
301
  const flags = [];
228
302
  let targetSeen = false;
@@ -12,6 +12,8 @@ import { createHash } from "node:crypto";
12
12
  import { markdownHeadingSlug, parseMarkdownToc } from "./markdown.js";
13
13
  export const MARKDOWN_FRAGMENT_MAX_CHARS = 1600;
14
14
  export const MARKDOWN_FRAGMENT_PREFIX = "akm-fragment-";
15
+ export const MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS = 3200;
16
+ export const MARKDOWN_FRAGMENT_SELECTED_LABEL = "[Selected matching fragment]";
15
17
  function hash(text) {
16
18
  return createHash("sha256").update(text).digest("hex");
17
19
  }
@@ -144,3 +146,36 @@ export function splitMarkdownFragments(body, maxChars = MARKDOWN_FRAGMENT_MAX_CH
144
146
  export function fragmentForSelector(body, selector) {
145
147
  return splitMarkdownFragments(body).find((fragment) => fragment.fragmentId === selector || fragment.headingSlug === selector);
146
148
  }
149
+ /**
150
+ * Assemble the document lead and selected fragment under one hard character
151
+ * budget. The selected match is always labelled and last. When both pieces do
152
+ * not fit, lead bytes are discarded before any selected-fragment bytes so the
153
+ * evidence that caused retrieval remains intact whenever the caller's budget
154
+ * can hold it.
155
+ */
156
+ export function buildMarkdownLeadContext(fragments, selectedOrdinal, maxChars = MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS) {
157
+ if (!Number.isSafeInteger(maxChars) || maxChars <= 0) {
158
+ throw new RangeError("Markdown fragment context maxChars must be a positive safe integer");
159
+ }
160
+ const selected = fragments[selectedOrdinal];
161
+ if (!selected)
162
+ throw new RangeError(`Markdown fragment ordinal ${selectedOrdinal} is out of range`);
163
+ const selectedBlock = `${MARKDOWN_FRAGMENT_SELECTED_LABEL}\n${selected.text}`;
164
+ if (selectedBlock.length > maxChars) {
165
+ return { content: selectedBlock.slice(0, maxChars), truncated: true };
166
+ }
167
+ const lead = fragments[0];
168
+ if (!lead || lead.ordinal === selected.ordinal)
169
+ return { content: selectedBlock, truncated: false };
170
+ const separator = "\n\n";
171
+ const availableLeadChars = maxChars - selectedBlock.length - separator.length;
172
+ if (availableLeadChars <= 0)
173
+ return { content: selectedBlock, truncated: true };
174
+ const leadText = lead.text.slice(0, availableLeadChars).trimEnd();
175
+ if (!leadText)
176
+ return { content: selectedBlock, truncated: true };
177
+ return {
178
+ content: `${leadText}${separator}${selectedBlock}`,
179
+ truncated: leadText.length < lead.text.length,
180
+ };
181
+ }
@@ -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
  /**