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
package/dist/akm CHANGED
@@ -117,6 +117,10 @@ if (contextIndex !== -1) {
117
117
  if (contextValid) {
118
118
  process.env.AKM_LAUNCHER_NODE = process.execPath;
119
119
  process.env.AKM_LAUNCHER_PATH = fileURLToPath(import.meta.url);
120
+ // #956: lets the child (and its lock payloads) name the launcher pid
121
+ // alongside its own, and lets the child's parent-death watchdog tell a
122
+ // real launcher-managed run apart from a direct `bun src/cli.ts` run.
123
+ process.env.AKM_LAUNCHER_PID = String(process.pid);
120
124
 
121
125
  if (!process.versions.bun) {
122
126
  const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
@@ -145,7 +149,56 @@ if (contextValid) {
145
149
  const entry = useBun ? bunEntry : nodeEntry;
146
150
  const runtime = useBun ? "Bun" : "Node.js";
147
151
  const result = await new Promise((resolve) => {
148
- const child = spawn(command, [entry, ...process.argv.slice(2)], { stdio: "inherit", env: process.env });
152
+ const child = spawn(command, [entry, ...process.argv.slice(2)], {
153
+ stdio: "inherit",
154
+ env: process.env,
155
+ // #956: give the child its OWN process group on POSIX (`setsid()` —
156
+ // does not touch its inherited stdio; see `spawnsOwnProcessGroup` in
157
+ // src/core/subprocess.ts for the Windows caveat that rules this out
158
+ // there). Without this, a spawned child shares the launcher's
159
+ // process group by default, so a signal delivered to that GROUP (a
160
+ // terminal's Ctrl-C, or `kill -SIGINT -<pgid>`) reaches the child
161
+ // directly AND the launcher, which then also forwards it — a real
162
+ // second signal lands microseconds after the first. The child's
163
+ // `process.once(signal, ...)` handler has already unregistered
164
+ // itself for its first (direct) copy, so the second falls through
165
+ // to the runtime's default disposition (immediate termination) and
166
+ // the child's graceful shutdown (lock release, in-flight abort)
167
+ // never runs. Detaching the child's process group makes the
168
+ // launcher's forward below the SOLE delivery path to the child, so
169
+ // it is always exactly one signal.
170
+ detached: process.platform !== "win32",
171
+ });
172
+ // A `kill <launcher-pid>` (a scheduler timeout, a supervisor, an
173
+ // operator) used to end only this wrapper, orphaning the bun child —
174
+ // it kept running (and holding locks) for as long as its own work took.
175
+ // Forward the same signal so the child dies with its parent. Never
176
+ // forward once the child has already exited: forwarding to a
177
+ // dead/replaced pid would be at best a no-op and at worst a signal to
178
+ // an unrelated process that reused the pid.
179
+ let childExited = false;
180
+ child.once("exit", () => {
181
+ childExited = true;
182
+ });
183
+ const forwardSignal = (signal) => {
184
+ if (childExited) return;
185
+ try {
186
+ child.kill(signal);
187
+ } catch {
188
+ // Child exited in the race between the check above and here.
189
+ }
190
+ };
191
+ // `.once`, not `.on`: Node/Bun suppress a signal's default
192
+ // (process-terminating) disposition for as long as ANY listener stays
193
+ // registered for it. A persistent `.on` listener would still be
194
+ // registered when the `process.kill(process.pid, result.signal)`
195
+ // re-raise below runs at shutdown, swallowing it and leaving this
196
+ // launcher exiting 0 instead of reflecting the child's signal. `.once`
197
+ // consumes only the externally-delivered signal that triggers the
198
+ // forward, so the re-raise correctly falls through to the OS default.
199
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
200
+ process.once(signal, () => forwardSignal(signal));
201
+ }
149
202
  child.once("error", (error) => resolve({ error }));
150
203
  child.once("exit", (code, signal) => resolve({ code, signal }));
151
204
  });
package/dist/akm-migrate CHANGED
@@ -27,10 +27,43 @@ const bunEntry = fileURLToPath(new URL("./scripts/akm-migrate.js", import.meta.u
27
27
  const nodeEntry = fileURLToPath(new URL("./scripts/akm-migrate-node.js", import.meta.url));
28
28
 
29
29
  {
30
+ // #956: same shape as scripts/node-runtime/akm — the child names the
31
+ // launcher pid, and a `kill <launcher-pid>` forwards to the child instead
32
+ // of orphaning it.
33
+ const env = { ...process.env, AKM_LAUNCHER_PID: String(process.pid) };
30
34
  const command = process.versions.bun ? process.execPath : useBun ? "bun" : process.execPath;
31
35
  const entry = process.versions.bun || useBun ? bunEntry : nodeEntry;
32
36
  const result = await new Promise((resolve) => {
33
- const child = spawn(command, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
37
+ const child = spawn(command, [entry, ...process.argv.slice(2)], {
38
+ stdio: "inherit",
39
+ env,
40
+ // #956: own process group on POSIX so the launcher's forward below is
41
+ // the SOLE delivery path to the child instead of a redundant second
42
+ // copy landing on top of a group-wide broadcast the child already got
43
+ // directly — see the matching comment in scripts/node-runtime/akm.
44
+ detached: process.platform !== "win32",
45
+ });
46
+ let childExited = false;
47
+ child.once("exit", () => {
48
+ childExited = true;
49
+ });
50
+ const forwardSignal = (signal) => {
51
+ if (childExited) return;
52
+ try {
53
+ child.kill(signal);
54
+ } catch {
55
+ // Child exited in the race between the check above and here.
56
+ }
57
+ };
58
+ // `.once`, not `.on`: see the matching comment in
59
+ // scripts/node-runtime/akm — a persistent `.on` listener would still be
60
+ // registered when the `process.kill(process.pid, result.signal)`
61
+ // re-raise below runs, suppressing the OS default disposition and
62
+ // leaving this launcher exiting 0 instead of reflecting the child's
63
+ // signal.
64
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
65
+ process.once(signal, () => forwardSignal(signal));
66
+ }
34
67
  child.once("error", (error) => resolve({ error }));
35
68
  child.once("exit", (code, signal) => resolve({ code, signal }));
36
69
  });
@@ -0,0 +1 @@
1
+ Feedback describes what a reader found missing or wrong. It is a signal to investigate, not a fact to insert. Do not add claims, numbers, dates, paths, ports, or incidents that are not already present in the asset content. If feedback asks for information the asset lacks, add a clearly marked `TODO: verify …` placeholder or leave the section unchanged.
@@ -9,3 +9,5 @@ AKM_REFLECT_CONTENT_END
9
9
  The first begin marker and final end marker delimit the body; marker lines between them are literal content. Put the complete markdown body between those outer markers. Quotes, Markdown fences, and backslashes inside the body are literal content; do not JSON-escape them. Emit the body only, without YAML frontmatter, because AKM preserves and merges the source frontmatter itself.
10
10
 
11
11
  The frontmatter patch must be a one-line JSON object with exactly `description` and `when_to_use`. Keep a field `null` when it should not change. Supply a non-empty string only when adding or correcting that field; AKM merges those values through its existing sanitizer.
12
+
13
+ Never include the truncation marker (the literal text `{{TRUNCATION_MARKER}}`) or any other text from outside the fenced asset content shown to you, anywhere in the body.
@@ -1,3 +1,5 @@
1
1
  Respond only through the provider's native JSON schema. {{FIELD_RULE}}
2
2
 
3
3
  `content` must contain the complete improved markdown body only, without YAML frontmatter. `frontmatterPatch` must contain exactly `description` and `when_to_use`; set either field to `null` when it should not change, or to a non-empty string when adding or correcting it. AKM merges that narrow patch with the source frontmatter and preserves target identity itself. `confidence` is your honest self-rated quality confidence from 0 to 1. Do not add prose or Markdown fences around the JSON response.
4
+
5
+ Never include the truncation marker (the literal text `{{TRUNCATION_MARKER}}`) or any other text from outside the quoted asset content shown to you, anywhere in `content`.
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm improve
2
+ run: akm improve --require-engines
3
3
  description: Run improve pipeline nightly
4
4
  schedule: "0 2 * * *"
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm index
2
+ run: akm index --skip-if-locked
3
3
  description: Nightly incremental index refresh
4
4
  schedule: "0 4 * * *"
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm improve --strategy graph-refresh --skip-if-locked
2
+ run: akm improve --strategy graph-refresh --skip-if-locked --require-engines
3
3
  description: Full-corpus graph rebuild (weekly Sunday 3:10am)
4
4
  schedule: "10 3 * * 0"
@@ -1,5 +1,5 @@
1
1
  version: 4
2
- run: akm improve --strategy catchup --skip-if-locked
2
+ run: akm improve --strategy catchup --skip-if-locked --require-engines
3
3
  description: Manual recovery — consolidation + triage drain (run on demand via `akm task run akm-improve-catchup`)
4
4
  # Manual-recovery task: ships disabled (the retired registerDefaultTasks
5
5
  # marked it enableMode: "manual"). `akm task run` works while disabled;
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm improve --strategy consolidate --skip-if-locked
2
+ run: akm improve --strategy consolidate --skip-if-locked --require-engines
3
3
  description: Consolidation-only pass (every 4h at :20)
4
4
  schedule: "20 */4 * * *"
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm improve --strategy reflect-distill --skip-if-locked
2
+ run: akm improve --strategy reflect-distill --skip-if-locked --require-engines
3
3
  description: Frequent learning pass (hourly at :40) — reflect, distill, memory inference, judged triage
4
4
  schedule: "40 * * * *"
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm improve --strategy thorough --skip-if-locked
2
+ run: akm improve --strategy thorough --skip-if-locked --require-engines
3
3
  description: Full nightly quality sweep (daily 2:15am; suggested for server installs)
4
4
  schedule: "15 2 * * *"
@@ -65,7 +65,6 @@ const RETIRED_COMMAND_HINTS = {
65
65
  "task enable": "`akm task enable` was removed in 0.9 — set `enabled: true` in the task YAML, then `akm task sync`.",
66
66
  "task disable": "`akm task disable` was removed in 0.9 — set `enabled: false` in the task YAML, then `akm task sync`.",
67
67
  "task init": "`akm task init` was removed in 0.9 — `akm setup` seeds the default schedules.",
68
- "task list": "there is no `task list` — task files are indexed assets; use `akm search --type task`.",
69
68
  "task show": "there is no `task show` — task files are indexed assets; use `akm show <ref>`.",
70
69
  "task remove": "there is no `task remove` — delete the task YAML, then run `akm task sync` to unbind it.",
71
70
  // `improve canary` is NOT here: `akm improve` is a leaf command (a
@@ -31,6 +31,8 @@ export { parseAllFlagValues };
31
31
  * 2 usage error
32
32
  * 4 health warn (health command only)
33
33
  * 70 internal / unclassified (sysexits EX_SOFTWARE — akm threw unexpectedly)
34
+ * 75 transient (sysexits EX_TEMPFAIL — retry shortly; another akm process
35
+ * holds a lock or is writing state.db right now, not a bad command line)
34
36
  * 78 config error
35
37
  */
36
38
  export const EXIT_CODES = {
@@ -41,6 +43,11 @@ export const EXIT_CODES = {
41
43
  // sysexits.h EX_SOFTWARE. Distinct from GENERAL(1) so scripts can tell an
42
44
  // expected "not found" outcome from akm itself throwing an unexpected error.
43
45
  INTERNAL: 70,
46
+ // sysexits.h EX_TEMPFAIL (#948 addendum). Distinct from USAGE(2): a
47
+ // scheduler or cron wrapper classifies 2 as "fix the command line", but a
48
+ // TransientError means "try again in a few seconds" — a different retry
49
+ // contract callers can branch on.
50
+ TEMPFAIL: 75,
44
51
  CONFIG: 78,
45
52
  };
46
53
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -64,6 +71,8 @@ function classifyExitCode(error) {
64
71
  return EXIT_CODES.CONFIG;
65
72
  case "not-found":
66
73
  return EXIT_CODES.GENERAL;
74
+ case "transient":
75
+ return EXIT_CODES.TEMPFAIL;
67
76
  default:
68
77
  return assertNever(error.kind, "classifyExitCode");
69
78
  }
@@ -51,6 +51,7 @@ const SELF_DIAGNOSED_FLAGS = new Map(Object.entries({
51
51
  "proposal drain": ["profile"], // retired, points at --strategy
52
52
  search: ["source"], // renamed to --from
53
53
  curate: ["source"], // renamed to --from
54
+ "task list": ["source"], // renamed to --from (alias of `search --type task`, #951)
54
55
  remember: ["target"], // renamed to --bundle
55
56
  clone: ["target"], // renamed to --bundle
56
57
  improve: ["auto-accept", "target"], // retired in 0.9.0 / renamed to --bundle
package/dist/cli.js CHANGED
@@ -87,6 +87,8 @@ import { taskCommand } from "./commands/tasks/tasks-cli.js";
87
87
  import { workflowCommand } from "./commands/workflow-cli.js";
88
88
  import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
89
89
  import { UsageError } from "./core/errors.js";
90
+ import { launcherPidFromEnv } from "./core/file-lock.js";
91
+ import { startParentDeathWatchdog } from "./core/parent-watchdog.js";
90
92
  import { getConfigPath } from "./core/paths.js";
91
93
  import { DURATION_UNITS, parseDuration } from "./core/time.js";
92
94
  import { plainize } from "./core/tty.js";
@@ -293,8 +295,8 @@ const healthCommand = defineCommand({
293
295
  probe: {
294
296
  type: "boolean",
295
297
  default: true,
296
- description: "Probe default-llm-engine / configured-engines reachability (on by default).",
297
- negativeDescription: "Skip the reachability probes (for an offline or air-gapped host).",
298
+ description: "Probe default-llm-engine / configured-engines reachability and check for a newer akm release (on by default).",
299
+ negativeDescription: "Skip the reachability probes and the update check (for an offline or air-gapped host).",
298
300
  },
299
301
  },
300
302
  async run({ args }) {
@@ -554,6 +556,7 @@ export const main = defineCommand({
554
556
  " 2 usage error\n" +
555
557
  " 4 health warn (akm health only)\n" +
556
558
  " 70 internal / unclassified error\n" +
559
+ " 75 transient (retry shortly — another akm process holds a lock or is writing state.db)\n" +
557
560
  " 78 config error",
558
561
  },
559
562
  args: {
@@ -1077,5 +1080,39 @@ async function runCli() {
1077
1080
  // sets `AKM_STANDALONE_ENTRY=1` before importing this file. The test harness
1078
1081
  // sets neither, so importing cli.ts under Bun stays inert as before.
1079
1082
  if (import.meta.main || process.env.AKM_NODE_ENTRY === "1" || process.env.AKM_STANDALONE_ENTRY === "1") {
1080
- await runCli();
1083
+ // #956: a launcher that dies WITHOUT delivering a signal
1084
+ // (SIGKILL, an OOM kill, a supervisor that force-removes the process)
1085
+ // still reparents this process to init with nothing to catch — the field
1086
+ // evidence was 40 orphaned `bun …/dist/cli.js` processes in one day, some
1087
+ // from a curate hook killing its own launcher on timeout. Active for
1088
+ // EVERY command (not only `index`), since a hook-invoked `curate`/`search`
1089
+ // child is orphaned the same way. Inert when there is no launcher
1090
+ // (AKM_LAUNCHER_PID unset — a direct `bun src/cli.ts` run, or any test
1091
+ // harness that imports this module without setting it): only a real
1092
+ // launcher-managed run has a parent worth watching.
1093
+ const parentWatchdog = launcherPidFromEnv()
1094
+ ? startParentDeathWatchdog({
1095
+ initialPpid: process.ppid,
1096
+ onOrphaned: () => {
1097
+ // Self-delivers the same signal a real SIGTERM would be. `akm
1098
+ // index`'s own AbortController listens for it
1099
+ // (commands/sources/stash-cli.ts) and gets a graceful, in-process
1100
+ // shutdown. Every other command has no SIGTERM listener of its
1101
+ // own, so this self-signal terminates it directly via the
1102
+ // runtime's default disposition WITHOUT running `exit` handlers
1103
+ // (lock release included) — it stops the orphaned process, which
1104
+ // is the goal here, but any lock it held is left for the next
1105
+ // acquirer's dead-pid stale-reclaim (file-lock.ts) to clear, not
1106
+ // released in-process. No second, parallel abort path to keep in
1107
+ // sync either way.
1108
+ process.kill(process.pid, "SIGTERM");
1109
+ },
1110
+ })
1111
+ : undefined;
1112
+ try {
1113
+ await runCli();
1114
+ }
1115
+ finally {
1116
+ parentWatchdog?.stop();
1117
+ }
1081
1118
  }
@@ -12,10 +12,12 @@
12
12
  * settings use their canonical engine/strategy paths; retired aliases are not
13
13
  * rewritten at this boundary.
14
14
  */
15
+ import { isDeepStrictEqual } from "node:util";
15
16
  import { defineGroupCommand, defineJsonCommand, output } from "../cli/shared.js";
16
- import { resolveStashDir } from "../core/common.js";
17
- import { DEFAULT_CONFIG, loadConfig, mutateConfig } from "../core/config/config.js";
17
+ import { isRecord, resolveStashDir } from "../core/common.js";
18
+ import { DEFAULT_CONFIG, getConfigValueSource, loadConfig, mutateConfig, parseAndValidateConfigText, resolveConfigRefSource, } from "../core/config/config.js";
18
19
  import { configGet, configSet, configUnset, unknownKeyHint } from "../core/config/config-walker.js";
20
+ import { UsageError } from "../core/errors.js";
19
21
  import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "../core/paths.js";
20
22
  import { formatRegistryUrl } from "../core/registry-url.js";
21
23
  // ── Public API ──────────────────────────────────────────────────────────────
@@ -53,6 +55,55 @@ function isRegistryUrlPath(path) {
53
55
  return path[0] === "registries" && path.at(-1) === "url";
54
56
  }
55
57
  export { unknownKeyHint };
58
+ /**
59
+ * Flatten a (redacted) config object down to `{dottedPath: leafValue}`
60
+ * entries. A plain object descends; an array or scalar is a leaf — matching
61
+ * `deepMergeConfig`'s own "objects merge, arrays replace wholesale" rule, so
62
+ * an array difference reports as one row rather than per-index noise.
63
+ */
64
+ function flattenConfigLeaves(value, prefix, out) {
65
+ if (isRecord(value) && Object.keys(value).length > 0) {
66
+ for (const [key, child] of Object.entries(value))
67
+ flattenConfigLeaves(child, [...prefix, key], out);
68
+ return;
69
+ }
70
+ out.set(prefix.join("."), value);
71
+ }
72
+ /**
73
+ * Diff two (already redacted) effective config objects into sorted
74
+ * `{path, local, other}` rows for every leaf that differs — including a leaf
75
+ * present on only one side (the other's value reads `undefined`).
76
+ */
77
+ function diffConfigs(local, other) {
78
+ const localLeaves = new Map();
79
+ const otherLeaves = new Map();
80
+ flattenConfigLeaves(local, [], localLeaves);
81
+ flattenConfigLeaves(other, [], otherLeaves);
82
+ const paths = new Set([...localLeaves.keys(), ...otherLeaves.keys()]);
83
+ const rows = [];
84
+ for (const path of paths) {
85
+ const localValue = localLeaves.get(path);
86
+ const otherValue = otherLeaves.get(path);
87
+ if (!isDeepStrictEqual(localValue, otherValue))
88
+ rows.push({ path, local: localValue, other: otherValue });
89
+ }
90
+ rows.sort((a, b) => a.path.localeCompare(b.path));
91
+ return rows;
92
+ }
93
+ /**
94
+ * `akm config diff <path|bundle//path>`: the current effective config
95
+ * (this instance's `extends` already applied) against another config file or
96
+ * bundle-relative file (loaded through the same loader, its own `extends`
97
+ * honoured), secrets redacted on both sides before comparison so a differing
98
+ * secret never round-trips into the diff.
99
+ */
100
+ export function akmConfigDiff(ref) {
101
+ const current = loadConfig();
102
+ const { text, resolvedPath } = resolveConfigRefSource(ref, current, getConfigPath());
103
+ const other = parseAndValidateConfigText(text, resolvedPath);
104
+ const rows = diffConfigs(redactConfigValue(current), redactConfigValue(other));
105
+ return { rows };
106
+ }
56
107
  // ── `akm config` command surface ────────────────────────────────────────────
57
108
  // Extracted verbatim from src/cli.ts (WS6). The `main.subCommands.config` key
58
109
  // and every config subcommand's args/output shape are byte-identical. Leaf
@@ -105,9 +156,16 @@ export const configCommand = defineGroupCommand({
105
156
  meta: { name: "get", description: "Get a configuration value by key" },
106
157
  args: {
107
158
  key: { type: "positional", required: true, description: "Config key (for example: embedding, defaultBundle)" },
159
+ "show-source": {
160
+ type: "boolean",
161
+ description: 'Wrap the value as { value, source }, where source is "local", "extends:<ref>" (the nearest ' +
162
+ 'extends chain member that sets it), or "default".',
163
+ default: false,
164
+ },
108
165
  },
109
166
  run({ args }) {
110
- output("config", getConfigValue(loadConfig(), args.key));
167
+ const value = getConfigValue(loadConfig(), args.key);
168
+ output("config", args["show-source"] ? { value, source: getConfigValueSource(args.key) } : value);
111
169
  },
112
170
  }),
113
171
  set: defineJsonCommand({
@@ -152,6 +210,14 @@ export const configCommand = defineGroupCommand({
152
210
  },
153
211
  },
154
212
  run({ args }) {
213
+ // A key only an `extends` base supplies has nothing local to remove:
214
+ // `unsetConfigValue` would silently no-op and the value comes right
215
+ // back on the next load. Refuse before mutating rather than pretend
216
+ // it worked.
217
+ const source = getConfigValueSource(args.key);
218
+ if (source.startsWith("extends:")) {
219
+ throw new UsageError(`Config key "${args.key}" is inherited from ${source} and cannot be unset here.`, "TARGET_NOT_UPDATABLE", `Override it locally with "akm config set ${args.key} <value>", or edit the base file directly.`);
220
+ }
155
221
  const result = mutateConfig((current) => unsetConfigValue(current, args.key), { absentNoop: true });
156
222
  const updated = result.config;
157
223
  if (!args.silent) {
@@ -159,6 +225,22 @@ export const configCommand = defineGroupCommand({
159
225
  }
160
226
  },
161
227
  }),
228
+ diff: defineJsonCommand({
229
+ meta: {
230
+ name: "diff",
231
+ description: "Show effective-config differences against another config file or bundle-relative file, secrets redacted",
232
+ },
233
+ args: {
234
+ ref: {
235
+ type: "positional",
236
+ required: true,
237
+ description: "Other config: a file path or bundle//path (relative to the bundle's content root)",
238
+ },
239
+ },
240
+ run({ args }) {
241
+ output("config-diff", akmConfigDiff(args.ref));
242
+ },
243
+ }),
162
244
  },
163
245
  // The bare `akm config` invocation (and `akm config --list`) dumps the
164
246
  // current config. defineGroupCommand short-circuits this body when a
@@ -22,52 +22,11 @@ import path from "node:path";
22
22
  import { getParsedInvocation } from "../../cli/invocation.js";
23
23
  import { getStringArg } from "../../cli/parse-args.js";
24
24
  import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.js";
25
- import { deriveCanonicalAssetName } from "../../core/asset/asset-placement.js";
26
25
  import { writeFileAtomic } from "../../core/common.js";
27
- import { loadConfig } from "../../core/config/config.js";
28
- import { makeEnvRef, resolveEnvPath, resolveEnvWriteTarget, withEnvSecretWrite } from "../../core/env-secret-ref.js";
26
+ import { listEnvsRecursive, makeEnvRef, resolveEnvPath, resolveEnvWriteTarget, sensitiveMarkerPath, withEnvSecretWrite, } from "../../core/env-secret-ref.js";
29
27
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
30
- import { resolveSourceEntries } from "../../indexer/search/search-source.js";
31
28
  import { readStdin } from "../../runtime.js";
32
29
  import { buildChildEnv } from "./child-env.js";
33
- import { sensitiveMarkerPath } from "./marker-path.js";
34
- /**
35
- * Walk each stash's env files and return one entry per `.env` file, using the
36
- * env asset spec's canonical-name logic (e.g. `env/team/prod.env` →
37
- * `env/team/prod`, `env/team/.env` → `env/team/default`).
38
- */
39
- function listEnvsRecursive(listKeysFn) {
40
- const result = [];
41
- for (const source of resolveSourceEntries(undefined, loadConfig())) {
42
- const root = path.join(source.path, "env");
43
- if (!fs.existsSync(root))
44
- continue;
45
- const walk = (dir) => {
46
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47
- const full = path.join(dir, entry.name);
48
- if (entry.isDirectory()) {
49
- walk(full);
50
- continue;
51
- }
52
- if (!entry.isFile())
53
- continue;
54
- if (entry.name !== ".env" && !entry.name.endsWith(".env"))
55
- continue;
56
- const canonical = deriveCanonicalAssetName("env", root, full);
57
- if (!canonical)
58
- continue;
59
- // Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
60
- const markerPath = sensitiveMarkerPath(full, "env");
61
- if (fs.existsSync(markerPath))
62
- continue;
63
- const { keys } = listKeysFn(full);
64
- result.push({ ref: makeEnvRef(canonical, source), path: full, keys });
65
- }
66
- };
67
- walk(root);
68
- }
69
- return result;
70
- }
71
30
  const envListCommand = defineJsonCommand({
72
31
  meta: { name: "list", description: "List all env files across all bundles with their key names (no values)" },
73
32
  async run() {
@@ -49,7 +49,7 @@ import fs from "node:fs";
49
49
  import path from "node:path";
50
50
  import dotenv from "dotenv";
51
51
  import { scanEnvKeyNames, writeFileAtomic } from "../../core/common.js";
52
- import { sensitiveMarkerPath } from "./marker-path.js";
52
+ import { sensitiveMarkerPath } from "../../core/sensitive-marker-path.js";
53
53
  export { scanEnvKeyNames };
54
54
  /**
55
55
  * Read and return ONLY non-secret metadata: key names.
@@ -35,14 +35,13 @@ import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.
35
35
  import { decideDangerousEnvInjection } from "../../core/activation-policy.js";
36
36
  import { deriveCanonicalAssetName } from "../../core/asset/asset-placement.js";
37
37
  import { loadConfig } from "../../core/config/config.js";
38
- import { makeSecretRef, resolveSecretPath, resolveSecretWriteTarget, withEnvSecretWrite, } from "../../core/env-secret-ref.js";
38
+ import { makeSecretRef, resolveSecretPath, resolveSecretWriteTarget, sensitiveMarkerPath, withEnvSecretWrite, } from "../../core/env-secret-ref.js";
39
39
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
40
40
  import { appendEvent } from "../../core/events.js";
41
41
  import { warn } from "../../core/warn.js";
42
42
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
43
43
  import { readStdin } from "../../runtime.js";
44
44
  import { buildChildEnv } from "./child-env.js";
45
- import { sensitiveMarkerPath } from "./marker-path.js";
46
45
  function parseKeyListFlag(raw) {
47
46
  if (raw === undefined)
48
47
  return undefined;