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.
- package/CHANGELOG.md +397 -0
- package/STABILITY.md +6 -3
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
- package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
- package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
- package/dist/assets/tasks/core/improve.yml +1 -1
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
- package/dist/cli/retired-commands.js +0 -1
- package/dist/cli/shared.js +9 -0
- package/dist/cli/unknown-flags.js +1 -0
- package/dist/cli.js +3 -2
- package/dist/commands/config-cli.js +85 -3
- package/dist/commands/env/env-cli.js +1 -42
- package/dist/commands/env/env.js +1 -1
- package/dist/commands/env/secret-cli.js +1 -2
- package/dist/commands/health/checks.js +357 -63
- package/dist/commands/health/engine-usage.js +45 -0
- package/dist/commands/health/improve-metrics.js +18 -0
- package/dist/commands/health/llm-usage.js +41 -1
- package/dist/commands/health/plugin-staleness.js +7 -3
- package/dist/commands/health/version-drift.js +93 -0
- package/dist/commands/health/windows.js +3 -1
- package/dist/commands/health.js +44 -9
- package/dist/commands/improve/consolidate/chunking.js +4 -2
- package/dist/commands/improve/improve-cli.js +99 -5
- package/dist/commands/improve/improve-report.js +154 -0
- package/dist/commands/improve/improve-result-file.js +45 -33
- package/dist/commands/improve/improve-strategies.js +133 -3
- package/dist/commands/improve/improve-usage-report.js +182 -0
- package/dist/commands/improve/improve.js +40 -3
- package/dist/commands/improve/locks.js +27 -78
- package/dist/commands/improve/planner.js +1 -0
- package/dist/commands/improve/preparation.js +9 -1
- package/dist/commands/improve/reflect.js +44 -4
- package/dist/commands/models-cli.js +50 -1
- package/dist/commands/proposal/repository.js +8 -3
- package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
- package/dist/commands/proposal/validators/proposal-validators.js +24 -0
- package/dist/commands/read/search-cli.js +38 -2
- package/dist/commands/read/show.js +103 -4
- package/dist/commands/sources/info.js +5 -1
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/stash-cli.js +31 -0
- package/dist/commands/tasks/tasks-cli.js +49 -2
- package/dist/commands/workflow-cli.js +86 -12
- package/dist/core/asset/markdown-fragments.js +35 -0
- package/dist/core/config/config-schema.js +14 -0
- package/dist/core/config/config.js +302 -24
- package/dist/core/env-secret-ref.js +58 -5
- package/dist/core/errors.js +30 -0
- package/dist/core/improve-result.js +51 -0
- package/dist/core/loopback.js +17 -0
- package/dist/core/paths.js +11 -0
- package/dist/core/run-lock.js +96 -0
- package/dist/core/sensitive-marker-path.js +19 -0
- package/dist/core/state-db.js +74 -14
- package/dist/indexer/index-rebuild-lock.js +73 -0
- package/dist/indexer/index-writer-lock.js +40 -1
- package/dist/indexer/index-written-assets.js +21 -1
- package/dist/indexer/indexer.js +18 -17
- package/dist/indexer/materialize-embeddings.js +282 -32
- package/dist/indexer/search/db-search.js +49 -2
- package/dist/integrations/agent/engine-resolution.js +96 -6
- package/dist/integrations/agent/execution-definitions.js +6 -15
- package/dist/integrations/agent/execution-lowering.js +6 -1
- package/dist/integrations/agent/execution-preparation.js +1 -1
- package/dist/integrations/agent/model-map.js +123 -20
- package/dist/integrations/agent/prompts.js +40 -8
- package/dist/integrations/agent/runner-dispatch.js +9 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/llm/client.js +8 -3
- package/dist/llm/embedder.js +20 -8
- package/dist/llm/embedders/local.js +10 -2
- package/dist/llm/embedders/remote.js +188 -21
- package/dist/output/shapes/helpers.js +38 -2
- package/dist/output/shapes/models-list.js +16 -0
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/shapes.js +4 -0
- package/dist/output/text/command-format.js +29 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/improve-report.js +27 -0
- package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
- package/dist/output/text/show-format.js +4 -0
- package/dist/output/text.js +4 -0
- package/dist/scripts/akm-migrate-node.js +24798 -21732
- package/dist/scripts/akm-migrate.js +23408 -20343
- package/dist/storage/repositories/improve-runs-repository.js +34 -0
- package/dist/storage/repositories/index-fts-repository.js +49 -6
- package/dist/storage/repositories/index-vec-repository.js +30 -0
- package/dist/storage/repositories/workflow-runs-repository.js +55 -18
- package/dist/tasks/backends/cron.js +14 -7
- package/dist/tasks/run/run-workflow-task.js +16 -0
- package/dist/workflows/exec/child-workflow.js +2 -2
- package/dist/workflows/exec/dispatch-redaction.js +21 -9
- package/dist/workflows/exec/run-workflow.js +6 -5
- package/dist/workflows/runtime/runs.js +33 -5
- package/docs/migration/release-notes/0.9.15.md +52 -0
- package/docs/migration/release-notes/README.md +4 -0
- package/docs/reference/cli.md +245 -29
- package/docs/reference/configuration.md +180 -19
- package/docs/reference/data-and-telemetry.md +8 -0
- package/docs/reference/tasks.md +16 -1
- package/docs/reference/workflow-schema.md +5 -1
- package/package.json +1 -1
- 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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
155
|
+
function runConfigFilePipeline(text, sourcePath) {
|
|
151
156
|
const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
|
|
152
157
|
const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
package/dist/core/errors.js
CHANGED
|
@@ -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";
|