akm-cli 0.9.11 → 0.9.13

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 (134) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/assets/workflows/workflow-template.md +4 -0
  8. package/dist/cli/shared.js +16 -4
  9. package/dist/cli.js +15 -13
  10. package/dist/commands/agent/agent-dispatch.js +8 -0
  11. package/dist/commands/command/execution-source-loader.js +25 -22
  12. package/dist/commands/command/portable-template.js +4 -26
  13. package/dist/commands/config-cli.js +10 -4
  14. package/dist/commands/env/env-binding.js +10 -3
  15. package/dist/commands/env/env-cli.js +7 -0
  16. package/dist/commands/env/secret-cli.js +15 -4
  17. package/dist/commands/health/checks.js +186 -71
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +2 -2
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/execution.js +1 -2
  22. package/dist/commands/improve/extract.js +82 -56
  23. package/dist/commands/improve/improve-strategies.js +26 -8
  24. package/dist/commands/improve/improve.js +14 -0
  25. package/dist/commands/improve/preparation.js +9 -6
  26. package/dist/commands/improve/reflect.js +61 -77
  27. package/dist/commands/lint/base-linter.js +10 -0
  28. package/dist/commands/lint/index.js +3 -1
  29. package/dist/commands/migrate-cli.js +6 -4
  30. package/dist/commands/proposal/drain-policies.js +22 -2
  31. package/dist/commands/proposal/drain.js +48 -6
  32. package/dist/commands/proposal/proposal-cli.js +1 -0
  33. package/dist/commands/proposal/repository.js +4 -4
  34. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  35. package/dist/commands/proposal/validators/proposals.js +10 -19
  36. package/dist/commands/read/show.js +42 -31
  37. package/dist/commands/registry-cli.js +4 -2
  38. package/dist/commands/sources/init.js +4 -8
  39. package/dist/commands/sources/self-update.js +2 -2
  40. package/dist/commands/sources/source-clone.js +5 -7
  41. package/dist/commands/sources/sources-cli.js +3 -5
  42. package/dist/commands/tasks/tasks-cli.js +4 -12
  43. package/dist/commands/tasks/tasks.js +38 -35
  44. package/dist/commands/workflow-cli.js +17 -15
  45. package/dist/core/activation-policy.js +31 -3
  46. package/dist/core/adapter/execution-source.js +39 -11
  47. package/dist/core/asset/stash-meta.js +7 -41
  48. package/dist/core/common.js +8 -17
  49. package/dist/core/config/config-schema.js +3 -23
  50. package/dist/core/config/config-walker.js +56 -6
  51. package/dist/core/config/config.js +42 -17
  52. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  53. package/dist/core/config/schema/embedding.js +2 -2
  54. package/dist/core/config/schema/engines.js +2 -2
  55. package/dist/core/config/schema/index-config.js +19 -21
  56. package/dist/core/config/schema/primitives.js +27 -10
  57. package/dist/core/config/schema/sources-bundles.js +1 -6
  58. package/dist/core/errors.js +4 -3
  59. package/dist/core/improve-types.js +17 -0
  60. package/dist/core/json-schema.js +1 -11
  61. package/dist/core/maintenance-barrier.js +17 -2
  62. package/dist/core/paths.js +12 -15
  63. package/dist/core/state/migrations.js +28 -0
  64. package/dist/core/state-db.js +28 -1
  65. package/dist/core/write-source.js +6 -6
  66. package/dist/indexer/bundle-identity-guard.js +3 -0
  67. package/dist/indexer/ensure-index.js +5 -0
  68. package/dist/indexer/indexer.js +11 -3
  69. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  70. package/dist/indexer/passes/metadata.js +16 -5
  71. package/dist/indexer/search/search-fields.js +1 -30
  72. package/dist/integrations/agent/engine-resolution.js +15 -1
  73. package/dist/integrations/agent/model-map.js +16 -10
  74. package/dist/integrations/agent/prompts.js +13 -6
  75. package/dist/integrations/lockfile.js +22 -7
  76. package/dist/llm/client.js +28 -8
  77. package/dist/llm/embedders/remote.js +3 -2
  78. package/dist/llm/index-passes.js +3 -2
  79. package/dist/output/shapes/passthrough.js +9 -3
  80. package/dist/output/shapes.js +50 -3
  81. package/dist/output/text/proposal-format.js +5 -0
  82. package/dist/output/text/workflow-format.js +8 -1
  83. package/dist/scripts/akm-migrate-node.js +1737 -1392
  84. package/dist/scripts/akm-migrate.js +1736 -1391
  85. package/dist/setup/setup.js +14 -21
  86. package/dist/sources/include.js +150 -20
  87. package/dist/sources/providers/git-install.js +14 -12
  88. package/dist/sources/providers/git-provider.js +3 -3
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  90. package/dist/sources/website-url.js +12 -4
  91. package/dist/storage/engines/sqlite-migrations.js +40 -10
  92. package/dist/storage/like-pattern.js +7 -0
  93. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  94. package/dist/storage/repositories/index-connection.js +27 -10
  95. package/dist/storage/repositories/index-entry-schema.js +19 -2
  96. package/dist/storage/repositories/index-schema.js +30 -9
  97. package/dist/storage/repositories/proposals-repository.js +2 -1
  98. package/dist/storage/repositories/task-history-repository.js +14 -7
  99. package/dist/storage/repositories/workflow-runs-repository.js +133 -11
  100. package/dist/storage/sqlite-read-snapshot.js +11 -9
  101. package/dist/tasks/backends/cron.js +34 -5
  102. package/dist/tasks/backends/launchd.js +23 -26
  103. package/dist/tasks/backends/schtasks.js +50 -3
  104. package/dist/tasks/frozen-script.js +2 -0
  105. package/dist/tasks/prepare/prepare.js +2 -7
  106. package/dist/tasks/prepare/script-capture.js +38 -6
  107. package/dist/tasks/schedule.js +154 -13
  108. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  109. package/dist/tasks/source/task-source-v4.js +0 -1
  110. package/dist/workflows/exec/child-workflow.js +2 -3
  111. package/dist/workflows/exec/exec-unit.js +3 -4
  112. package/dist/workflows/exec/run-workflow.js +20 -11
  113. package/dist/workflows/exec/step-work.js +76 -56
  114. package/dist/workflows/freeze/resolve-steps.js +19 -11
  115. package/dist/workflows/freeze/source-freeze.js +7 -0
  116. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  117. package/dist/workflows/freeze/targets/command.js +14 -2
  118. package/dist/workflows/ir/environment-v4.js +4 -2
  119. package/dist/workflows/ir/freeze-v4.js +2 -5
  120. package/dist/workflows/ir/plan-hash.js +0 -3
  121. package/dist/workflows/ir/schema-v4.js +14 -9
  122. package/dist/workflows/ir/schema.js +1 -3
  123. package/dist/workflows/parser.js +1 -1
  124. package/dist/workflows/resource-limits.js +35 -48
  125. package/dist/workflows/runtime/plan-classifier.js +89 -41
  126. package/dist/workflows/runtime/run-outputs.js +1 -21
  127. package/dist/workflows/runtime/runs.js +104 -154
  128. package/dist/workflows/source-files.js +28 -54
  129. package/dist/workflows/source-ir/program.js +2 -2
  130. package/dist/workflows/source-ir/semantics.js +5 -23
  131. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  132. package/docs/reference/cli.md +92 -17
  133. package/package.json +1 -1
  134. package/schemas/akm-config.json +5 -10
@@ -6,6 +6,8 @@ import { isMap, isScalar, parseDocument, visit } from "yaml";
6
6
  import { cloneExecutionJson, cloneExecutionJsonObject, } from "../../execution/json.js";
7
7
  import { assertSnapshotKeys, snapshotStrictRecord } from "../../execution/record.js";
8
8
  import { cloneToolSelection, createAdapterRenderedExecutionSource, } from "../../execution/source.js";
9
+ import { UsageError } from "../errors.js";
10
+ import { warnOnce } from "../warn.js";
9
11
  function nextLine(text, start) {
10
12
  const lf = text.indexOf("\n", start);
11
13
  if (lf < 0)
@@ -13,16 +15,39 @@ function nextLine(text, start) {
13
15
  const end = lf > start && text[lf - 1] === "\r" ? lf - 1 : lf;
14
16
  return { line: text.slice(start, end), next: lf + 1 };
15
17
  }
16
- /** Strict execution-only frontmatter parser; indexing's tolerant parser is deliberately not reused. */
17
- export function parseExecutionMarkdown(raw) {
18
+ /**
19
+ * Strict execution-only frontmatter parser; indexing's tolerant parser is
20
+ * deliberately not reused.
21
+ *
22
+ * `filePath`, when given, names the offending asset in a thrown error so an
23
+ * operator (or CI) sees which file to fix instead of a bare message.
24
+ *
25
+ * A third party's markdown that genuinely cannot be parsed into a mapping
26
+ * (unterminated frontmatter, broken YAML syntax, a non-mapping document)
27
+ * throws {@link UsageError} \u2014 exit 2, an actionable usage problem, not exit
28
+ * 70 (`TypeError`'s "unclassified internal error" code, which is what a
29
+ * `throw new TypeError(...)` here used to surface as). Anchors, explicit
30
+ * tags, and non-string mapping keys are recognized-but-unsupported
31
+ * constructs rather than parse failures on their own \u2014 an anchor nobody
32
+ * aliases, or a tag `toJS` converts to a plain value, is harmless \u2014 so they
33
+ * warn once (naming the file) and fall through to conversion instead of
34
+ * being pre-emptively rejected by a second, stricter gate in front of it.
35
+ * `toJS({ maxAliasCount: 0 })` is the real, UNCHANGED safety bound against
36
+ * alias-expansion abuse \u2014 it disables alias RESOLUTION outright, so a value
37
+ * that actually references an anchor via `*name` still fails, now as a
38
+ * {@link UsageError} from this same conversion step rather than the removed
39
+ * duplicate pre-check.
40
+ */
41
+ export function parseExecutionMarkdown(raw, filePath) {
18
42
  if (typeof raw !== "string")
19
43
  throw new TypeError("execution source raw content must be a string");
44
+ const where = filePath ? ` (${filePath})` : "";
20
45
  const withoutBom = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
21
46
  const opening = nextLine(withoutBom, 0);
22
47
  if (opening.line !== "---")
23
48
  return { content: withoutBom, data: Object.freeze({}) };
24
49
  if (opening.next === withoutBom.length) {
25
- throw new TypeError("execution source has unterminated frontmatter");
50
+ throw new UsageError(`execution source${where} has unterminated frontmatter`);
26
51
  }
27
52
  let cursor = opening.next;
28
53
  let frontmatterEnd = -1;
@@ -40,18 +65,18 @@ export function parseExecutionMarkdown(raw) {
40
65
  cursor = current.next;
41
66
  }
42
67
  if (frontmatterEnd < 0 || bodyStart < 0) {
43
- throw new TypeError("execution source has unterminated frontmatter");
68
+ throw new UsageError(`execution source${where} has unterminated frontmatter`);
44
69
  }
45
70
  const yaml = withoutBom.slice(opening.next, frontmatterEnd);
46
71
  const document = parseDocument(yaml, { uniqueKeys: true });
47
72
  if (document.errors.length > 0) {
48
- throw new TypeError(`execution source has invalid YAML frontmatter: ${document.errors[0]?.message ?? "parse error"}`);
73
+ throw new UsageError(`execution source${where} has invalid YAML frontmatter: ${document.errors[0]?.message ?? "parse error"}`);
49
74
  }
50
75
  if (document.warnings.length > 0) {
51
- throw new TypeError(`execution source YAML frontmatter uses an unsupported tag or construct: ${document.warnings[0]?.message}`);
76
+ warnOnce(`execution-source:yaml-warning:${filePath ?? "<inline>"}`, `execution source${where} YAML frontmatter uses an unsupported tag or construct: ${document.warnings[0]?.message}. Using it as parsed.`);
52
77
  }
53
78
  if (!isMap(document.contents)) {
54
- throw new TypeError("execution source YAML frontmatter must be a mapping");
79
+ throw new UsageError(`execution source${where} YAML frontmatter must be a mapping`);
55
80
  }
56
81
  let unsupported;
57
82
  visit(document, {
@@ -69,14 +94,16 @@ export function parseExecutionMarkdown(raw) {
69
94
  unsupported ??= "non-string mapping keys";
70
95
  },
71
96
  });
72
- if (unsupported)
73
- throw new TypeError(`execution source YAML frontmatter does not support ${unsupported}`);
97
+ if (unsupported) {
98
+ warnOnce(`execution-source:unsupported:${filePath ?? "<inline>"}`, `execution source${where} YAML frontmatter uses ${unsupported}, which akm does not fully support; using the bounded conversion's result as-is.`);
99
+ }
74
100
  let root;
75
101
  try {
76
102
  root = document.toJS({ maxAliasCount: 0 });
77
103
  }
78
104
  catch (cause) {
79
- throw new TypeError("execution source YAML frontmatter could not be converted safely", { cause });
105
+ const detail = cause instanceof Error ? cause.message : String(cause);
106
+ throw new UsageError(`execution source${where} YAML frontmatter could not be converted safely: ${detail}`);
80
107
  }
81
108
  const data = cloneExecutionJsonObject(root, "execution source YAML frontmatter");
82
109
  return { content: withoutBom.slice(bodyStart), data: Object.freeze(data) };
@@ -279,7 +306,8 @@ export function renderMarkdownExecutionSource(input) {
279
306
  const kind = snapshots.input.kind;
280
307
  if (kind !== "command" && kind !== "persona")
281
308
  throw new TypeError("adapter execution source.kind is invalid");
282
- const parsed = parseExecutionMarkdown(raw);
309
+ const identityFile = snapshots.identity.file;
310
+ const parsed = parseExecutionMarkdown(raw, typeof identityFile === "string" ? identityFile : undefined);
283
311
  const hasDefaults = Object.hasOwn(snapshots.input, "defaults");
284
312
  const defaultsProjection = snapshots.input.defaults;
285
313
  const defaults = typeof defaultsProjection === "function" ? defaultsProjection(parsed.data) : defaultsProjection;
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import { isWithin } from "../common.js";
21
22
  import { UsageError } from "../errors.js";
22
23
  /** Root-relative directory holding a stash's meta docs. */
23
24
  export const META_DIR = ".meta";
@@ -113,7 +114,7 @@ export function readMetaFile(sourceRoot, name) {
113
114
  return null;
114
115
  let fd;
115
116
  try {
116
- fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
117
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY);
117
118
  const opened = fs.fstatSync(fd);
118
119
  if (!opened.isFile())
119
120
  throw metaPathEscape();
@@ -141,60 +142,25 @@ export function readMetaFile(sourceRoot, name) {
141
142
  }
142
143
  }
143
144
  function isSafeRegularMetaFile(sourceRoot, metaRoot, filePath) {
144
- const sourceRootReal = realPathOrNull(sourceRoot);
145
- if (!sourceRootReal)
146
- return false;
147
- const metaStat = lstatOrNull(metaRoot);
148
- if (!metaStat)
149
- return false;
150
- if (metaStat.isSymbolicLink())
151
- throw metaPathEscape();
152
- if (!metaStat.isDirectory())
153
- return false;
154
145
  const relative = path.relative(metaRoot, filePath);
155
146
  if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
156
147
  throw metaPathEscape();
157
148
  }
158
- let current = metaRoot;
159
- for (const segment of relative.split(path.sep)) {
160
- current = path.join(current, segment);
161
- const stat = lstatOrNull(current);
162
- if (!stat)
163
- return false;
164
- if (stat.isSymbolicLink())
165
- throw metaPathEscape();
166
- }
167
- const finalStat = lstatOrNull(filePath);
149
+ const finalStat = statOrNull(filePath);
168
150
  if (!finalStat?.isFile())
169
151
  return false;
170
- const metaRootReal = realPathOrNull(metaRoot);
171
- const fileReal = realPathOrNull(filePath);
172
- if (!metaRootReal || !fileReal)
173
- return false;
174
- if (!isWithin(sourceRootReal, metaRootReal) || !isWithin(metaRootReal, fileReal))
152
+ if (!isWithin(filePath, sourceRoot))
175
153
  throw metaPathEscape();
176
154
  return true;
177
155
  }
178
- function lstatOrNull(filePath) {
156
+ function statOrNull(filePath) {
179
157
  try {
180
- return fs.lstatSync(filePath);
158
+ return fs.statSync(filePath);
181
159
  }
182
160
  catch {
183
161
  return null;
184
162
  }
185
163
  }
186
- function realPathOrNull(filePath) {
187
- try {
188
- return fs.realpathSync(filePath);
189
- }
190
- catch {
191
- return null;
192
- }
193
- }
194
- function isWithin(root, candidate) {
195
- const relative = path.relative(root, candidate);
196
- return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
197
- }
198
164
  function metaPathEscape() {
199
- return new UsageError("Meta doc must be a regular file inside the stash .meta directory; symlinks are refused.", "PATH_ESCAPE_VIOLATION");
165
+ return new UsageError("Meta doc must resolve to a regular file inside the stash .meta directory, not one that escapes it.", "PATH_ESCAPE_VIOLATION");
200
166
  }
@@ -4,6 +4,7 @@
4
4
  import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { migrateLegacySourceShape } from "./config/legacy-source-shape-shim.js";
7
8
  import { ConfigError } from "./errors.js";
8
9
  import { getConfigPath, getDefaultStashDir, getRegistryCacheDir, getRegistryIndexCacheDir } from "./paths.js";
9
10
  // ── Constants ───────────────────────────────────────────────────────────────
@@ -302,23 +303,20 @@ function readStashDirFromConfig() {
302
303
  // in use. Parsing it raw here threw, the catch swallowed it, and every
303
304
  // caller silently fell back — operating on the wrong bundle or failing with
304
305
  // STASH_DIR_NOT_FOUND despite a perfectly good config.
305
- const raw = JSON.parse(stripJsonComments(text));
306
- if (typeof raw !== "object" || raw === null)
306
+ const parsed = JSON.parse(stripJsonComments(text));
307
+ if (typeof parsed !== "object" || parsed === null)
307
308
  return undefined;
309
+ const raw = migrateLegacySourceShape(parsed, configPath);
308
310
  // 0.9.0 config-shape cutover (spec §10.1): the primary stash is the
309
311
  // `defaultBundle`'s filesystem `path`. Read it directly (no config module
310
312
  // import) so the primary-stash location survives the stashDir → bundles
311
313
  // migration without a runtime rewire.
312
314
  const bundles = raw.bundles;
313
315
  const defaultBundle = raw.defaultBundle;
314
- if (bundles &&
315
- typeof bundles === "object" &&
316
- typeof defaultBundle === "string" &&
317
- bundles[defaultBundle] &&
318
- typeof bundles[defaultBundle] === "object" &&
319
- typeof bundles[defaultBundle].path === "string" &&
320
- bundles[defaultBundle].path.trim()) {
321
- const bundle = bundles[defaultBundle];
316
+ const selectedBundle = typeof defaultBundle === "string" ? bundles?.[defaultBundle] : undefined;
317
+ const selectedBundlePath = typeof selectedBundle?.path === "string" ? selectedBundle.path : undefined;
318
+ if (bundles && typeof bundles === "object" && typeof defaultBundle === "string" && selectedBundlePath?.trim()) {
319
+ const bundle = selectedBundle;
322
320
  const bundlePath = bundle.path.trim();
323
321
  if (bundle.components !== undefined) {
324
322
  if (typeof bundle.components !== "object" || bundle.components === null) {
@@ -343,13 +341,6 @@ function readStashDirFromConfig() {
343
341
  }
344
342
  return bundlePath;
345
343
  }
346
- // Retired pre-cutover shapes are not runtime inputs. Refuse them instead
347
- // of silently resolving old keys.
348
- for (const key of ["stashDir", "sources", "installed"]) {
349
- if (key in raw && raw[key] !== undefined) {
350
- throw new ConfigError(`${key} is not supported; configure the current bundles shape`, "INVALID_CONFIG_FILE");
351
- }
352
- }
353
344
  }
354
345
  catch (err) {
355
346
  // An unsupported-shape refusal must reach the caller; genuine missing/invalid
@@ -41,6 +41,7 @@
41
41
  * enforced at save time via `superRefine` on the top-level schema.
42
42
  */
43
43
  import { z } from "zod";
44
+ import { warnOnce } from "../warn.js";
44
45
  import { BUILTIN_IMPROVE_STRATEGY_NAMES, IMPROVE_PROCESS_ENGINE_CAPABILITIES } from "./engine-semantics.js";
45
46
  import { EmbeddingConnectionConfigSchema } from "./schema/embedding.js";
46
47
  import { EnginesSchema } from "./schema/engines.js";
@@ -121,25 +122,11 @@ const RETIRED_SOURCE_SHAPE_KEY_MESSAGES = {
121
122
  };
122
123
  export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) => {
123
124
  const raw = config;
124
- for (const key of ["profiles", "llm", "agent", "features", "stashes", "modelAliases"]) {
125
+ for (const key of ["profiles", "llm", "agent", "features", "stashes", "modelAliases", "bindings", "writable"]) {
125
126
  if (key in raw) {
126
- ctx.addIssue({
127
- code: z.ZodIssueCode.custom,
128
- path: [key],
129
- message: `${key} is retired in 0.9; configure engines and improve.strategies instead`,
130
- });
127
+ warnOnce(`config:retired-key:${key}`, `Config key "${key}" is retired in 0.9 and is ignored; configure engines/improve.strategies/bundles.<id> instead.`);
131
128
  }
132
129
  }
133
- // `bindings` (spec §10.1) is Tier B — never emitted, never accepted. The
134
- // top-level schema is `.passthrough()`, so without this it would round-trip
135
- // silently; reject it loudly so a stray/hand-written bindings block is caught.
136
- if ("bindings" in raw) {
137
- ctx.addIssue({
138
- code: z.ZodIssueCode.custom,
139
- path: ["bindings"],
140
- message: "bindings is not supported in 0.9.0 (Tier B); it is neither emitted nor accepted",
141
- });
142
- }
143
130
  // Only the current source shape enters the runtime. There is no config
144
131
  // compatibility path; `bundles` + `defaultBundle` fully supersede these keys.
145
132
  for (const key of ["stashDir", "sources", "installed"]) {
@@ -151,13 +138,6 @@ export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) =>
151
138
  });
152
139
  }
153
140
  }
154
- if ("writable" in raw) {
155
- ctx.addIssue({
156
- code: z.ZodIssueCode.custom,
157
- path: ["writable"],
158
- message: "top-level writable is not supported; configure bundles.<id>.writable instead",
159
- });
160
- }
161
141
  // `defaultBundle`, when present, must name a configured bundle.
162
142
  if (config.defaultBundle !== undefined) {
163
143
  if (config.bundles === undefined) {
@@ -23,8 +23,11 @@
23
23
  import { z } from "zod";
24
24
  import { isRecord } from "../common.js";
25
25
  import { UsageError } from "../errors.js";
26
+ import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../registry-url.js";
27
+ import { warnOnce } from "../warn.js";
26
28
  import { AkmConfigBaseSchema, EngineConfigSchema, listTopLevelConfigKeys } from "./config-schema.js";
27
29
  import { deepMergeConfig } from "./deep-merge.js";
30
+ import { isApiKeyReference } from "./schema/primitives.js";
28
31
  /**
29
32
  * Parse a dotted path into segments. Empty segments are rejected. Bracket
30
33
  * notation (e.g. `sources[0]`) is NOT supported — arrays are set as JSON.
@@ -175,11 +178,13 @@ export function configSet(config, dotted, raw) {
175
178
  }
176
179
  // #454: apiKey paths are not persistable. Throw at set time.
177
180
  rejectApiKeyPath(path, dotted);
181
+ rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted);
178
182
  const schema = resolveSchemaAt(path, config, raw);
179
183
  const symbolicApiKey = (path[0] === "engines" && path[2] === "apiKey") ||
180
184
  (path[0] === "embedding" && path.length === 2 && path[1] === "apiKey");
181
- if (!schema && !symbolicApiKey) {
182
- throw new UsageError(`Unknown config key: ${dotted}`, "INVALID_FLAG_VALUE", unknownKeyHint(dotted));
185
+ const isUnknownKey = !schema && !symbolicApiKey;
186
+ if (isUnknownKey) {
187
+ warnOnce(`config-set:unknown-key:${dotted}`, `"${dotted}" is not a known config key; storing it anyway. Run \`akm config get ${dotted}\` to confirm it round-trips as expected.`);
183
188
  }
184
189
  const judgmentObjectPath = isTriageJudgmentPath(path);
185
190
  const value = judgmentObjectPath && raw === "null"
@@ -188,7 +193,11 @@ export function configSet(config, dotted, raw) {
188
193
  ? parseObjectPatch(raw, dotted)
189
194
  : symbolicApiKey
190
195
  ? raw
191
- : coerceForSchema(schema, raw, dotted);
196
+ : isUnknownKey
197
+ ? bestEffortJsonValue(raw)
198
+ : coerceForSchema(schema, raw, dotted);
199
+ if (path[0] === "registries")
200
+ rejectRegistryCredentialValue(value, dotted);
192
201
  const existing = path.reduce((value, key) => {
193
202
  if (value && typeof value === "object")
194
203
  return value[key];
@@ -206,10 +215,15 @@ export function configSet(config, dotted, raw) {
206
215
  const parsed = path[0] === "engines" && path.length === 2
207
216
  ? EngineConfigSchema.safeParse(value)
208
217
  : symbolicApiKey
209
- ? /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(raw)
218
+ ? isApiKeyReference(raw)
219
+ ? { success: true, data: value }
220
+ : {
221
+ success: false,
222
+ error: { issues: [{ path: [], message: "apiKey must be $VAR, ${VAR}, or secret://<name>" }] },
223
+ }
224
+ : isUnknownKey
210
225
  ? { success: true, data: value }
211
- : { success: false, error: { issues: [{ path: [], message: `apiKey must be $VAR or \${VAR}` }] } }
212
- : schema.safeParse(candidate);
226
+ : schema.safeParse(candidate);
213
227
  if (!parsed.success) {
214
228
  const lines = parsed.error.issues
215
229
  .map((i) => {
@@ -273,6 +287,42 @@ function rejectApiKeyPath(path, dotted) {
273
287
  throw new UsageError(`apiKey cannot be persisted in config; export ${recipe} instead. (key: ${dotted})`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " +
274
288
  "Use the corresponding environment variable. AKM reads it at request time.");
275
289
  }
290
+ function rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted) {
291
+ const isWholeEngineSet = path[0] === "engines" && path.length === 2;
292
+ const isWholeEmbeddingSet = path.length === 1 && path[0] === "embedding";
293
+ if (!isWholeEngineSet && !isWholeEmbeddingSet)
294
+ return;
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(raw);
298
+ }
299
+ catch {
300
+ return; // Malformed JSON — the caller's own parse/coercion reports this.
301
+ }
302
+ if (!isRecord(parsed) || typeof parsed.apiKey !== "string")
303
+ return;
304
+ if (isApiKeyReference(parsed.apiKey))
305
+ return;
306
+ throw new UsageError(`apiKey cannot be persisted in config; export ${recipeForApiKey([...path, "apiKey"], `${dotted}.apiKey`)} instead. (key: ${dotted}.apiKey)`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " +
307
+ "Use the corresponding environment variable. AKM reads it at request time.");
308
+ }
309
+ function rejectRegistryCredentialValue(value, dotted) {
310
+ const entries = Array.isArray(value) ? value : [value];
311
+ for (const entry of entries) {
312
+ const url = isRecord(entry) && typeof entry.url === "string" ? entry.url : undefined;
313
+ if (url && hasRegistryUrlCredentials(url)) {
314
+ throw new UsageError(`${REGISTRY_CREDENTIALS_UNSUPPORTED} (key: ${dotted})`, "INVALID_FLAG_VALUE", REGISTRY_CREDENTIALS_UNSUPPORTED);
315
+ }
316
+ }
317
+ }
318
+ function bestEffortJsonValue(raw) {
319
+ try {
320
+ return JSON.parse(raw);
321
+ }
322
+ catch {
323
+ return raw;
324
+ }
325
+ }
276
326
  function parseObjectPatch(raw, key) {
277
327
  try {
278
328
  const value = JSON.parse(raw);
@@ -5,14 +5,17 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { ConfigError } from "../errors.js";
7
7
  import { liftLegacyEngineExtraParams } from "../extra-params.js";
8
+ import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
8
9
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
9
10
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
10
11
  import { bundlesToSourceEntries } from "./config-sources.js";
11
12
  import { upgradeConfigVersion } from "./config-version-shim.js";
12
13
  import { deepMergeConfig } from "./deep-merge.js";
14
+ import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
15
+ import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
13
16
  export { stripJsonComments } from "./config-io.js";
14
17
  import { getConfigPath } from "../paths.js";
15
- import { warn } from "../warn.js";
18
+ import { warn, warnOnce } from "../warn.js";
16
19
  // Canonical harness-id source of truth (#565) — runtime value re-export.
17
20
  export { VALID_HARNESS_IDS } from "./config-types.js";
18
21
  // ── Feedback failure-mode constants (F-3 / #384) ────────────────────────────
@@ -145,7 +148,8 @@ export function acquireConfigReadFence() {
145
148
  * is validated.
146
149
  */
147
150
  export function parseAndValidateConfigText(text, sourcePath) {
148
- const parsedRaw = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
151
+ const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
152
+ const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
149
153
  // #852 (following #815): a config still using legacy `extraParams` keys —
150
154
  // e.g. `reasoning_effort`, a documented 0.9.1 workaround — needs to be
151
155
  // rewritten onto the first-class engine field they now shadow. This used
@@ -155,7 +159,7 @@ export function parseAndValidateConfigText(text, sourcePath) {
155
159
  // config that has not been migrated yet fails closed here instead of
156
160
  // silently drifting from what's on disk.
157
161
  const where = sourcePath ? ` at ${sourcePath}` : "";
158
- const { lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
162
+ const { config: liftedConfig, lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
159
163
  if (conflicts.length > 0) {
160
164
  const lines = conflicts
161
165
  .map((c) => ` - engines.${c.engine}.extraParams.${c.key} (${JSON.stringify(c.extraParamsValue)}) conflicts with engines.${c.engine}.${c.field} (${JSON.stringify(c.fieldValue)})`)
@@ -163,9 +167,9 @@ export function parseAndValidateConfigText(text, sourcePath) {
163
167
  throw new ConfigError(`Invalid config${where}: extraParams and the first-class field disagree:\n${lines}\n\nEach extraParams key above has a first-class equivalent and akm will not guess which value you meant — remove the extraParams entry once the field carries the value you want.`, "INVALID_CONFIG_FILE");
164
168
  }
165
169
  if (lifted.length > 0) {
166
- throw new ConfigError(`Config${where} uses deprecated extraParams keys with first-class equivalents:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file, or move the values onto the first-class fields yourself.`, "INVALID_CONFIG_FILE");
170
+ warnOnce(`config:extra-params-lift${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses deprecated extraParams keys with first-class equivalents — auto-lifted in memory:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file and silence this warning.`);
167
171
  }
168
- const parsed = AkmConfigSchema.safeParse(parsedRaw);
172
+ const parsed = AkmConfigSchema.safeParse(liftedConfig);
169
173
  if (!parsed.success) {
170
174
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
171
175
  throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
@@ -296,7 +300,7 @@ export function sanitizeConfigForWrite(config) {
296
300
  const stripped = [];
297
301
  if (config.embedding?.apiKey !== undefined) {
298
302
  const apiKey = config.embedding.apiKey;
299
- if (isEnvReference(apiKey)) {
303
+ if (isApiKeyReference(apiKey)) {
300
304
  // Preserve reference verbatim — not a secret.
301
305
  sanitized.embedding = { ...config.embedding };
302
306
  }
@@ -313,7 +317,7 @@ export function sanitizeConfigForWrite(config) {
313
317
  if (config.engines) {
314
318
  const engines = {};
315
319
  for (const [name, engine] of Object.entries(config.engines)) {
316
- if (engine.kind !== "llm" || engine.apiKey === undefined || isEnvReference(engine.apiKey)) {
320
+ if (engine.kind !== "llm" || engine.apiKey === undefined || isApiKeyReference(engine.apiKey)) {
317
321
  engines[name] = { ...engine };
318
322
  continue;
319
323
  }
@@ -327,21 +331,30 @@ export function sanitizeConfigForWrite(config) {
327
331
  if (stripped.length > 0) {
328
332
  warn(`Config sanitizer dropped API key(s) before writing to disk:\n - ${stripped.join("\n - ")}\n\nakm does not persist API keys to config.json. Set the listed environment variables to provide them at runtime, or use \`\${VAR}\` references in your config to defer lookup. See docs/reference/data-and-telemetry.md.`);
329
333
  }
334
+ if (config.registries) {
335
+ const droppedRegistries = [];
336
+ const registries = config.registries.filter((entry) => {
337
+ if (!hasRegistryUrlCredentials(entry.url))
338
+ return true;
339
+ droppedRegistries.push(formatRegistryLabel(entry));
340
+ return false;
341
+ });
342
+ if (droppedRegistries.length > 0) {
343
+ sanitized.registries = registries;
344
+ warn(`Config sanitizer dropped registry entr${droppedRegistries.length === 1 ? "y" : "ies"} with URL credentials before writing to disk:\n - ${droppedRegistries.join("\n - ")}\n\nRegistry URLs must be credential-free; configure a credential-free HTTPS endpoint.`);
345
+ }
346
+ }
330
347
  return sanitized;
331
348
  }
332
- /** Matches the only 0.9 symbolic secret forms: `${VAR}` or `$VAR`. */
333
- function isEnvReference(value) {
334
- return /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
335
- }
336
349
  export function updateConfig(partial) {
337
350
  return mutateConfig((current) => deepMergeConfig(current, partial)).config;
338
351
  }
339
- // ── Helpers ─────────────────────────────────────────────────────────────────
340
352
  /**
341
- * Resolve a single secret value by expanding `${VAR}` / `$VAR` references
342
- * against `process.env`. Use this at apiKey /
343
- * authorization-header consumption sites (LLM client, embedder, agent SDK
344
- * runner) — NOT on the load path. Non-string inputs pass through unchanged.
353
+ * Resolve a single secret value: expand `${VAR}` / `$VAR` against
354
+ * `process.env`, or look up `secret://<name>` via `resolveFromStore`. Use this
355
+ * at apiKey / authorization-header consumption sites (LLM client, embedder,
356
+ * agent SDK runner) — NOT on the load path. Non-string inputs pass through
357
+ * unchanged.
345
358
  *
346
359
  * Returns the input unchanged when no substitution markers are present, so
347
360
  * literal API key strings (already-resolved secrets) are zero-cost.
@@ -349,12 +362,24 @@ export function updateConfig(partial) {
349
362
  * Other config string values (URLs, endpoints, model names, prompts) are
350
363
  * preserved verbatim on read — only fields explicitly routed through this
351
364
  * helper are expanded.
365
+ *
366
+ * A `secret://<name>` value that fails to resolve throws `ConfigError`
367
+ * (naming the ref, never the secret) rather than silently sending an unusable
368
+ * credential.
352
369
  */
353
- export function resolveSecret(value) {
370
+ export function resolveSecret(value, resolveFromStore) {
354
371
  if (value === undefined)
355
372
  return undefined;
356
373
  if (typeof value !== "string")
357
374
  return value;
375
+ const storeRef = SECRET_STORE_REFERENCE_PATTERN.exec(value)?.[1];
376
+ if (storeRef !== undefined) {
377
+ const resolved = resolveFromStore?.(storeRef) ?? null;
378
+ if (resolved === null) {
379
+ throw new ConfigError(`Secret store reference "${value}" did not resolve to a stored value.`, "SECRET_REFERENCE_UNRESOLVED");
380
+ }
381
+ return resolved;
382
+ }
358
383
  if (!value.includes("$"))
359
384
  return value;
360
385
  return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
@@ -0,0 +1,79 @@
1
+ import { isBundleSlug } from "../asset/asset-ref.js";
2
+ import { warnOnce } from "../warn.js";
3
+ function isPlainRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ const DEFAULT_WRITABLE_BY_TYPE = {
7
+ filesystem: true,
8
+ git: false,
9
+ website: false,
10
+ npm: false,
11
+ };
12
+ function bundleFromLegacySource(entry, index) {
13
+ if (!isPlainRecord(entry))
14
+ return undefined;
15
+ const type = typeof entry.type === "string" ? entry.type : undefined;
16
+ const bundle = {};
17
+ switch (type) {
18
+ case "filesystem":
19
+ if (typeof entry.path !== "string" || !entry.path)
20
+ return undefined;
21
+ bundle.path = entry.path;
22
+ break;
23
+ case "git":
24
+ if (typeof entry.url !== "string" || !entry.url)
25
+ return undefined;
26
+ bundle.git = entry.url;
27
+ break;
28
+ case "website":
29
+ if (typeof entry.url !== "string" || !entry.url)
30
+ return undefined;
31
+ bundle.website = { url: entry.url };
32
+ break;
33
+ case "npm": {
34
+ const spec = typeof entry.url === "string" && entry.url ? entry.url : entry.path;
35
+ if (typeof spec !== "string" || !spec)
36
+ return undefined;
37
+ bundle.npm = spec;
38
+ break;
39
+ }
40
+ default:
41
+ return undefined;
42
+ }
43
+ const writable = typeof entry.writable === "boolean" ? entry.writable : DEFAULT_WRITABLE_BY_TYPE[type ?? ""];
44
+ if (writable !== undefined)
45
+ bundle.writable = writable;
46
+ if (typeof entry.enabled === "boolean")
47
+ bundle.enabled = entry.enabled;
48
+ const name = typeof entry.name === "string" ? entry.name : undefined;
49
+ const key = name && isBundleSlug(name) ? name : `source-${index + 1}`;
50
+ return [key, bundle];
51
+ }
52
+ export function migrateLegacySourceShape(raw, sourcePath) {
53
+ const hasStashDir = typeof raw.stashDir === "string" && raw.stashDir.trim().length > 0;
54
+ const hasSources = Array.isArray(raw.sources) && raw.sources.length > 0;
55
+ const hasInstalled = "installed" in raw && raw.installed !== undefined;
56
+ if (!hasStashDir && !hasSources && !hasInstalled)
57
+ return raw;
58
+ const { stashDir: _stashDir, sources: _sources, installed: _installed, ...rest } = raw;
59
+ const bundles = isPlainRecord(rest.bundles) ? { ...rest.bundles } : {};
60
+ let defaultBundle = typeof rest.defaultBundle === "string" ? rest.defaultBundle : undefined;
61
+ if (hasStashDir) {
62
+ bundles.stash = { path: raw.stashDir, writable: true };
63
+ defaultBundle ??= "stash";
64
+ }
65
+ if (hasSources) {
66
+ raw.sources.forEach((entry, index) => {
67
+ const converted = bundleFromLegacySource(entry, index);
68
+ if (!converted)
69
+ return;
70
+ const [key, bundle] = converted;
71
+ bundles[key] = bundle;
72
+ defaultBundle ??= key;
73
+ });
74
+ }
75
+ const droppedKeys = [hasStashDir && "stashDir", hasSources && "sources", hasInstalled && "installed"].filter(Boolean);
76
+ const where = sourcePath ? ` at ${sourcePath}` : "";
77
+ warnOnce(`legacy-source-shape${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired ${droppedKeys.join("/")} shape — auto-migrated in memory to \`bundles\`/\`defaultBundle\`. Run \`akm migrate apply\` to rewrite the config file and silence this warning.`);
78
+ return { ...rest, bundles, ...(defaultBundle !== undefined ? { defaultBundle } : {}) };
79
+ }
@@ -6,7 +6,7 @@
6
6
  * `config-schema.ts` monolith — no behavior change.
7
7
  */
8
8
  import { z } from "zod";
9
- import { ENV_REFERENCE_PATTERN, positiveInt } from "./primitives.js";
9
+ import { positiveInt, symbolicOrWarnApiKey } from "./primitives.js";
10
10
  const EmbeddingOllamaOptionsSchema = z
11
11
  .object({
12
12
  num_ctx: positiveInt.optional(),
@@ -26,7 +26,7 @@ export const EmbeddingConnectionConfigSchema = z
26
26
  provider: z.string().optional(),
27
27
  endpoint: z.string().optional(),
28
28
  model: z.string().optional(),
29
- apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
29
+ apiKey: symbolicOrWarnApiKey("embedding.apiKey").optional(),
30
30
  // Bounded to the index schema's own vec-table guard (1–4096,
31
31
  // storage/repositories/index-schema.ts) so an out-of-range dimension
32
32
  // fails at config validation with a clear message instead of crashing
@@ -15,7 +15,7 @@ import { z } from "zod";
15
15
  // a config-schema ↔ config-types type cycle that collapses inference.
16
16
  import { HARNESS_AGENT_DISPATCH_IDS, VALID_HARNESS_IDS } from "../../../integrations/harnesses/ids.js";
17
17
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
18
- import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, nonEmptyString, positiveInt, } from "./primitives.js";
18
+ import { chatCompletionsEndpoint, ExtraParamsSchema, engineName, nonEmptyString, positiveInt, symbolicOrWarnApiKey, } from "./primitives.js";
19
19
  /**
20
20
  * Engine-config timeouts share the workflow ceiling.
21
21
  *
@@ -67,7 +67,7 @@ const LlmEngineSchema = z
67
67
  provider: z.string().optional(),
68
68
  endpoint: chatCompletionsEndpoint,
69
69
  model: nonEmptyString,
70
- apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
70
+ apiKey: symbolicOrWarnApiKey("engines.<name>.apiKey").optional(),
71
71
  // #905: file-backed alternative to `apiKey` for hosts that refuse
72
72
  // secrets in the process environment. A plain filesystem path (`~`
73
73
  // expanded, read at dispatch) — see resolveLlmEngineUse/