akm-cli 0.9.8-beta.3 → 0.9.9-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +186 -132
  2. package/dist/commands/health/checks.js +40 -0
  3. package/dist/commands/health.js +57 -31
  4. package/dist/commands/migrate-cli.js +29 -189
  5. package/dist/commands/sources/add-cli.js +7 -0
  6. package/dist/commands/sources/installed-stashes.js +36 -8
  7. package/dist/commands/sources/self-update.js +104 -62
  8. package/dist/commands/sources/source-add.js +6 -5
  9. package/dist/commands/sources/sources-cli.js +7 -18
  10. package/dist/commands/tasks/tasks-cli.js +4 -3
  11. package/dist/commands/tasks/tasks.js +13 -6
  12. package/dist/core/adapter/adapter-ids.js +35 -0
  13. package/dist/core/adapter/adapters/index.js +29 -0
  14. package/dist/core/adapter/detect-adapter.js +91 -3
  15. package/dist/core/config/config.js +1 -1
  16. package/dist/core/config/schema/sources-bundles.js +23 -0
  17. package/dist/core/extra-params.js +1 -1
  18. package/dist/core/state/migrations.js +2 -4
  19. package/dist/core/state-db.js +31 -8
  20. package/dist/indexer/indexer.js +64 -1
  21. package/dist/scripts/akm-migrate-node.js +86534 -20434
  22. package/dist/scripts/akm-migrate.js +86415 -20280
  23. package/dist/tasks/backends/cron.js +21 -6
  24. package/dist/tasks/resolve-akm-bin.js +1 -1
  25. package/docs/README.md +1 -0
  26. package/docs/integration/bundling-akm.md +276 -0
  27. package/docs/migration/v0.9.0-troubleshooting.md +10 -14
  28. package/docs/migration/v0.9.1-to-v0.9.2.md +12 -16
  29. package/docs/reference/cli.md +59 -31
  30. package/docs/reference/tasks.md +4 -10
  31. package/package.json +2 -1
  32. package/dist/commands/migrate/config-extra-params.js +0 -61
  33. package/dist/commands/migrate/dead-residue.js +0 -113
  34. package/dist/commands/migrate/stale-txn.js +0 -49
@@ -31,7 +31,7 @@ export async function akmAdd(input) {
31
31
  try {
32
32
  const parsed = parseRegistryRef(ref);
33
33
  if (parsed.source === "local") {
34
- return addLocalSource(ref, parsed.sourcePath, stashDir, input.name);
34
+ return addLocalSource(ref, parsed.sourcePath, stashDir, input.name, input.adapter);
35
35
  }
36
36
  }
37
37
  catch {
@@ -40,22 +40,23 @@ export async function akmAdd(input) {
40
40
  return addRegistryStash(ref, stashDir, input.writable);
41
41
  }
42
42
  /** Add a local directory as a filesystem bundle. */
43
- async function addLocalSource(ref, sourcePath, stashDir, explicitName) {
43
+ async function addLocalSource(ref, sourcePath, stashDir, explicitName, explicitAdapter) {
44
44
  const stashRoot = detectStashRoot(sourcePath);
45
45
  const resolvedPath = path.resolve(stashRoot);
46
+ const adapter = explicitAdapter ?? detectAdapterId(resolvedPath);
46
47
  let bundleKey = explicitName ?? toReadableId(resolvedPath);
47
48
  mutateConfig((config) => {
48
49
  const existing = bundleKeyForPath(config, resolvedPath);
49
50
  if (existing) {
50
51
  bundleKey = existing;
51
52
  const current = config.bundles?.[existing];
52
- if (current?.components)
53
+ if (current?.components && explicitAdapter === undefined)
53
54
  return config;
54
55
  const bundles = { ...(config.bundles ?? {}) };
55
56
  bundles[existing] = {
56
57
  ...current,
57
58
  path: resolvedPath,
58
- components: { main: { root: ".", adapter: detectAdapterId(resolvedPath) } },
59
+ components: { main: { root: ".", adapter } },
59
60
  };
60
61
  return { ...config, bundles };
61
62
  }
@@ -63,7 +64,7 @@ async function addLocalSource(ref, sourcePath, stashDir, explicitName) {
63
64
  bundleKey = nextBundleKey(bundles, explicitName, resolvedPath);
64
65
  bundles[bundleKey] = {
65
66
  path: resolvedPath,
66
- components: { main: { root: ".", adapter: detectAdapterId(resolvedPath) } },
67
+ components: { main: { root: ".", adapter } },
67
68
  };
68
69
  return { ...config, bundles };
69
70
  });
@@ -27,13 +27,13 @@
27
27
  */
28
28
  import { defineCommand } from "citty";
29
29
  import { getParsedInvocation } from "../../cli/invocation.js";
30
- import { defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
30
+ import { defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
31
31
  import { loadConfig } from "../../core/config/config.js";
32
32
  import { UsageError } from "../../core/errors.js";
33
33
  import { appendEvent } from "../../core/events.js";
34
34
  import { resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
35
35
  import { pkgVersion } from "../../version.js";
36
- import { checkForUpdate, performUpgrade, upgradeStateOnly } from "./self-update.js";
36
+ import { checkForUpdate, performUpgrade } from "./self-update.js";
37
37
  import { akmClone } from "./source-clone.js";
38
38
  export const upgradeCommand = defineJsonCommand({
39
39
  meta: { name: "upgrade", description: "Upgrade akm to the latest release" },
@@ -45,24 +45,8 @@ export const upgradeCommand = defineJsonCommand({
45
45
  description: "Skip the post-upgrade index rebuild",
46
46
  default: false,
47
47
  },
48
- "state-only": {
49
- type: "boolean",
50
- description: "Apply pending state.db migrations without installing a new akm",
51
- default: false,
52
- },
53
48
  },
54
49
  async run({ args }) {
55
- // Applying a historical destructive state migration used to be reachable
56
- // ONLY as a post-install step of a real upgrade, so an install akm cannot
57
- // rewrite -- a global npm install owned by root, an image that ships the
58
- // CLI -- had no route to it at all: the npm step fails EACCES and throws
59
- // long before the migration runs (#895). The migration is a local,
60
- // offline, already-verified operation; it does not need the network or a
61
- // new binary, and coupling it to one was the bug.
62
- if (args["state-only"]) {
63
- output("upgrade", upgradeStateOnly(pkgVersion));
64
- return;
65
- }
66
50
  const check = await checkForUpdate(pkgVersion);
67
51
  if (args.check) {
68
52
  output("upgrade", check);
@@ -71,6 +55,11 @@ export const upgradeCommand = defineJsonCommand({
71
55
  const skipPostUpgrade = args["skip-post-upgrade"];
72
56
  const result = await performUpgrade(check, { force: args.force, skipPostUpgrade });
73
57
  output("upgrade", result);
58
+ // The install may have succeeded, but an upgrade whose migration is
59
+ // blocked or could not run is not done: exit like `akm migrate apply` does.
60
+ if (result.migration?.status === "blocked" || result.migration?.status === "failed") {
61
+ process.exitCode = EXIT_CODES.GENERAL;
62
+ }
74
63
  },
75
64
  });
76
65
  // `sync` body, standalone so the git-commit/push logic stays in one place.
@@ -349,10 +349,11 @@ const tasksSyncCommand = defineJsonCommand({
349
349
  const result = await akmTasksSync({}, args.bundle, { rebind });
350
350
  output("task-sync", result);
351
351
  // #867: sync degrades — sources that failed to parse/prepare are
352
- // excluded from reconciliation and reported in `result.failed` rather
352
+ // excluded from reconciliation and reported in `result.failures` rather
353
353
  // than poisoning the whole sync, but their presence must still fail
354
- // the command's exit code so the breakage stays visible.
355
- if (result.failed.length > 0)
354
+ // the command's exit code so the breakage stays visible. (#906: this key
355
+ // matches the `--dry-run` preview's `failures` field — no separate name.)
356
+ if (result.failures.length > 0)
356
357
  process.exitCode = EXIT_CODES.GENERAL;
357
358
  },
358
359
  });
@@ -391,7 +391,7 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
391
391
  const expectedSignature = sched.expectedSignature?.bind(sched);
392
392
  const needsRuntime = preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined);
393
393
  const prepared = needsRuntime
394
- ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, options.rebind === true, "reconcile native scheduler bindings", warnings)
394
+ ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, options.rebind === true, "reconcile native scheduler bindings", warnings, allEntries.map((entry) => entry.binding))
395
395
  : undefined;
396
396
  const plan = finalizeSchedulerSyncPlan({
397
397
  ...common,
@@ -416,7 +416,7 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
416
416
  unchanged: [...plan.unchanged],
417
417
  skipped: [],
418
418
  backend: sched.name,
419
- failed: plan.failures.map((failure) => ({ ...failure })),
419
+ failures: plan.failures.map((failure) => ({ ...failure })),
420
420
  ...(warnings.length > 0 ? { warnings } : {}),
421
421
  };
422
422
  }
@@ -841,16 +841,16 @@ async function prepareTaskAddSchedulerTransaction(input) {
841
841
  publishOperationIndex: removals.length,
842
842
  });
843
843
  }
844
- function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warnings) {
844
+ function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warnings, installedBindings = []) {
845
845
  if (deps.backend && !deps.schedulerRuntime)
846
846
  return base ? { options: base } : {};
847
847
  if (deps.schedulerRuntime) {
848
848
  const runtime = deps.schedulerRuntime();
849
- warnIneligibleRebind(runtime, explicitRebind, warnings);
849
+ warnIneligibleRebind(runtime, explicitRebind, warnings, installedBindings);
850
850
  return { options: { ...base, binding: runtime.binding, contextPath: runtime.contextPath } };
851
851
  }
852
852
  const invocation = resolveAndValidateSchedulerInvocation(explicitRebind, operation);
853
- warnIneligibleRebind(invocation, explicitRebind, warnings);
853
+ warnIneligibleRebind(invocation, explicitRebind, warnings, installedBindings);
854
854
  const descriptor = schedulerContextDescriptor();
855
855
  const contextPath = schedulerContextPath(descriptor);
856
856
  return {
@@ -870,9 +870,16 @@ function resolveAndValidateSchedulerInvocation(explicitRebind, operation) {
870
870
  }
871
871
  return { binding: invocation.argv, contextPath: "", eligible: invocation.eligible, kind: invocation.kind };
872
872
  }
873
- function warnIneligibleRebind(runtime, explicitRebind, warnings) {
873
+ function warnIneligibleRebind(runtime, explicitRebind, warnings, installedBindings) {
874
874
  if (!explicitRebind || runtime.eligible !== false || warnings.length > 0)
875
875
  return;
876
+ // #868 residue: a `--rebind` that binds every currently-installed
877
+ // entry to the SAME invocation it already carries changes nothing — this
878
+ // is the steady state of an image-baked install re-running `task sync
879
+ // --rebind` on a timer. Only warn when the rebind actually moves an entry
880
+ // to a different invocation.
881
+ if (installedBindings.length > 0 && installedBindings.every((bound) => sameArgv(bound, runtime.binding)))
882
+ return;
876
883
  warnings.push(`--rebind bound scheduled tasks to an ineligible ${runtime.kind ?? "unknown"} invocation (${runtime.binding.join(" ")}); scheduled runs will invoke a mutable, unproven binary. Install akm via \`npm install --global akm-cli\` or a standalone release, then re-run \`akm task sync --rebind\`.`);
877
884
  }
878
885
  function groupInstalledBindings(entries, invocation) {
@@ -0,0 +1,35 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Dependency-free adapter-id table (#909) — mirrors
6
+ * `src/integrations/harnesses/ids.ts`'s split from its own heavy barrel.
7
+ *
8
+ * `core/config/schema/sources-bundles.ts` needs one small, DATA-shaped fact
9
+ * about the adapter registry: the canonical ordered id list, to validate
10
+ * `components.*.adapter` and to reject a typo instead of silently falling
11
+ * back to `akm` (#909). Importing `./registry.ts` (`BUILTIN_ADAPTERS`) for
12
+ * that would pull in all 11 concrete adapters and, transitively, the indexer
13
+ * modules they delegate to (`indexer/passes/metadata`, `core/asset/*`, …) —
14
+ * weight a config-schema module has no reason to carry just to validate one
15
+ * enum. This table is the canonical, dependency-free MIRROR of the id list;
16
+ * `./adapters/index.ts`'s `BUILTIN_ADAPTERS` construction asserts its ids
17
+ * match this table (order included) at module-load time, so the two can
18
+ * never silently drift without a loud failure.
19
+ */
20
+ /** Canonical, ordered list of valid adapter ids (matches `BUILTIN_ADAPTERS` order). */
21
+ export const ADAPTER_ID_TABLE = [
22
+ "website-snapshot",
23
+ "agent-skills",
24
+ "claude",
25
+ "opencode",
26
+ "dotenv",
27
+ "akm-workflow",
28
+ "akm-task",
29
+ "llm-wiki",
30
+ "akm",
31
+ "okf",
32
+ "generic-files",
33
+ ];
34
+ /** The dependency-free counterpart of `./registry.ts`'s `getAdapters().map(a => a.id)`. */
35
+ export const VALID_ADAPTER_IDS = ADAPTER_ID_TABLE;
@@ -1,6 +1,21 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
+ /**
5
+ * Built-in `BundleAdapter` barrel + the static, frozen `BUILTIN_ADAPTERS` list
6
+ * (normative §12.6) — akm 0.9.0 chunk-2 (WI-A) + the format-family work item
7
+ * (#46).
8
+ *
9
+ * `BUILTIN_ADAPTERS` is the ordered built-in adapter set the registry
10
+ * (`../registry`) exposes via `getAdapters()` / `adapterForId()`. It is a
11
+ * plain frozen array populated at MODULE LOAD — there is no mutable
12
+ * registration step and no load-order dependency, so no production call site
13
+ * (`installations.ts#detectAdapterId`, `provider-utils.ts#detectStashRoot`)
14
+ * depends on anyone first calling a registration function (normative §12.6 —
15
+ * "static frozen `BUILTIN_ADAPTERS` map"). The earlier mutable
16
+ * `registerAdapter` singleton that this replaced is retired.
17
+ */
18
+ import { ADAPTER_ID_TABLE } from "../adapter-ids.js";
4
19
  import { agentSkillsAdapter } from "./agent-skills-adapter.js";
5
20
  import { akmAdapter } from "./akm-adapter.js";
6
21
  import { akmTaskAdapter } from "./akm-task-adapter.js";
@@ -69,3 +84,17 @@ export const BUILTIN_ADAPTERS = Object.freeze([
69
84
  // Explicit-config fallback (never auto-selected) — last.
70
85
  genericFilesAdapter,
71
86
  ]);
87
+ // Construction-time drift guard (#909): ../adapter-ids.ts's ADAPTER_ID_TABLE
88
+ // is config's dependency-free mirror of this list, kept out of core/config's
89
+ // import graph so config doesn't have to import this (heavier) barrel. Assert
90
+ // they match so the mirror can never silently drift — a new/reordered/renamed
91
+ // adapter that forgets to update adapter-ids.ts fails loudly here instead of
92
+ // quietly breaking config's `components.*.adapter` validation.
93
+ {
94
+ const actualIds = BUILTIN_ADAPTERS.map((a) => a.id);
95
+ const expectedIds = [...ADAPTER_ID_TABLE];
96
+ if (actualIds.length !== expectedIds.length || actualIds.some((id, i) => id !== expectedIds[i])) {
97
+ throw new Error(`adapter-ids.ts ADAPTER_ID_TABLE (${expectedIds.join(", ")}) does not match ` +
98
+ `BUILTIN_ADAPTERS (${actualIds.join(", ")}) — update adapter-ids.ts to match.`);
99
+ }
100
+ }
@@ -1,13 +1,101 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
- import { getAdapters } from "./registry.js";
5
- /** Select the first built-in adapter whose ordered root probe claims `root`. */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { adapterForId, getAdapters } from "./registry.js";
7
+ /**
8
+ * Tool-dir-shaped adapters (#908): their install-time probe recognizes only a
9
+ * BOUNDED slice of the root — `claude`/`opencode`'s own `commands`/`agents`/
10
+ * `skills` tool dirs, or `agent-skills`' own `<name>/SKILL.md` packages. A
11
+ * bundle that ALSO carries ordinary akm content (`knowledge/`, `workflows/`,
12
+ * a stray `content/` folder of Markdown, …) alongside one of these layouts had
13
+ * that content SILENTLY dropped: the narrow adapter won the ordered probe and
14
+ * indexed only its own three-dir slice, with no warning that anything else
15
+ * was there (issue #908 — 73 documents disappeared from one real bundle).
16
+ *
17
+ * `okf` / `llm-wiki` / `dotenv` / `website-snapshot` / `akm-workflow` /
18
+ * `akm-task` are deliberately NOT in this set: each carries its own tight,
19
+ * disjoint marker (a root index document, `schema.md`+`pages/`, an env/secrets-only
20
+ * layout, `manifest.json`, a workflow/task-shaped top-level file) that is not
21
+ * at risk of firing merely because a FEW directory names happen to overlap
22
+ * with akm's own stash subdirs — narrowing the fix to the three families the
23
+ * issue is actually about keeps this from touching adapters it was never
24
+ * about.
25
+ */
26
+ const SHADOWABLE_ADAPTER_IDS = new Set(["agent-skills", "claude", "opencode"]);
27
+ /**
28
+ * True when `root` — already claimed by `winnerId` (one of
29
+ * {@link SHADOWABLE_ADAPTER_IDS}) — ALSO carries a top-level directory the
30
+ * `akm` adapter's own probe recognizes as its workspace shape (spec §1.2) and
31
+ * that `winnerId` does not own (its `directoryList()`, or — for `agent-skills`,
32
+ * which owns no fixed directory names — a root-level `<name>/SKILL.md`
33
+ * package) but which holds at least one real file. Cheap by design: only
34
+ * shallow `readdirSync` calls, one level into each candidate top-level dir —
35
+ * no recursive walk, no file content read, no git spawn — so this never adds
36
+ * meaningful cost to the install-time probe it augments.
37
+ */
38
+ function hasExtraAkmContent(root, winnerId) {
39
+ const akm = adapterForId("akm");
40
+ if (akm?.looksLikeRoot?.(root) !== true)
41
+ return false;
42
+ const winner = adapterForId(winnerId);
43
+ const stubComponent = { id: "detect", adapter: winnerId, root, writable: false };
44
+ const ownedDirs = new Set(winner?.directoryList?.(stubComponent) ?? []);
45
+ let entries;
46
+ try {
47
+ entries = fs.readdirSync(root, { withFileTypes: true });
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ for (const entry of entries) {
53
+ if (!entry.isDirectory() || entry.name.startsWith("."))
54
+ continue;
55
+ if (ownedDirs.has(entry.name))
56
+ continue;
57
+ if (winnerId === "agent-skills") {
58
+ // A root-level `<name>/SKILL.md` package IS agent-skills' own recognized
59
+ // surface even though it has no fixed directoryList — not "extra" content.
60
+ try {
61
+ if (fs.statSync(path.join(root, entry.name, "SKILL.md")).isFile())
62
+ continue;
63
+ }
64
+ catch {
65
+ // Not a skill package — fall through to the candidate-file check below.
66
+ }
67
+ }
68
+ let children;
69
+ try {
70
+ children = fs.readdirSync(path.join(root, entry.name), { withFileTypes: true });
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (children.some((child) => child.isFile() && !child.name.startsWith(".")))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Select the first built-in adapter whose ordered root probe claims `root`.
82
+ *
83
+ * A mixed-layout bundle (#908) that both a tool-dir-shaped adapter AND the
84
+ * `akm` adapter would claim detects as `akm` — the superset, which still
85
+ * indexes the narrower layout's own files correctly (`agent-skills`'
86
+ * `<name>/SKILL.md` packages, `claude`/`opencode`'s `commands`/`agents`/
87
+ * `skills`) while also picking up whatever else is in the bundle. A bundle
88
+ * that carries ONLY the narrow layout (no extra content) is unaffected.
89
+ */
6
90
  export function detectAdapterId(root, fallback = "akm") {
7
91
  for (const adapter of getAdapters()) {
8
92
  try {
9
- if (adapter.looksLikeRoot?.(root) === true)
93
+ if (adapter.looksLikeRoot?.(root) === true) {
94
+ if (SHADOWABLE_ADAPTER_IDS.has(adapter.id) && hasExtraAkmContent(root, adapter.id)) {
95
+ return "akm";
96
+ }
10
97
  return adapter.id;
98
+ }
11
99
  }
12
100
  catch {
13
101
  // An unreadable or racing probe does not claim the bundle.
@@ -151,7 +151,7 @@ export function parseAndValidateConfigText(text, sourcePath) {
151
151
  // rewritten onto the first-class engine field they now shadow. This used
152
152
  // to happen silently, in memory, on every load; that ran forever and never
153
153
  // converged. The lift itself is now `akm migrate apply`'s job (see
154
- // src/commands/migrate/config-extra-params.ts) and persists to disk, so a
154
+ // scripts/akm-migrate/migrate/config-extra-params.ts) and persists to disk, so a
155
155
  // config that has not been migrated yet fails closed here instead of
156
156
  // silently drifting from what's on disk.
157
157
  const where = sourcePath ? ` at ${sourcePath}` : "";
@@ -7,9 +7,15 @@
7
7
  * change.
8
8
  */
9
9
  import { z } from "zod";
10
+ // Dependency-free mirror of the adapter registry's id list (#909) — see
11
+ // adapter-ids.ts's header for why config imports this leaf rather than
12
+ // `core/adapter/registry.ts` (which would pull in all 11 concrete adapters
13
+ // and, transitively, the indexer modules they delegate to).
14
+ import { VALID_ADAPTER_IDS } from "../../adapter/adapter-ids.js";
10
15
  import { isBundleSlug } from "../../asset/asset-ref.js";
11
16
  import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../../registry-url.js";
12
17
  import { httpUrl, nonEmptyString, positiveInt } from "./primitives.js";
18
+ const VALID_ADAPTER_IDS_SET = new Set(VALID_ADAPTER_IDS);
13
19
  // ── Sources / registries / installed ────────────────────────────────────────
14
20
  const SourceConfigEntryOptionsSchema = z.record(z.unknown());
15
21
  export const SourceConfigEntrySchema = z
@@ -165,6 +171,23 @@ export const BundleConfigEntrySchema = z
165
171
  message: "writable: true is only supported on path and git bundle sources",
166
172
  });
167
173
  }
174
+ // #909: an unrecognized `components.*.adapter` used to silently fall back
175
+ // to `akm` at detect-time with no error and no disclosure — a typo
176
+ // (`agent_skills`, `akm-native`, …) then silently changed which files got
177
+ // indexed. Reject it here instead, the same treatment other enum-valued
178
+ // config fields get, listing the accepted values (derived from the
179
+ // adapter registry, never hardcoded — see adapter-ids.ts).
180
+ if (componentEntry !== undefined) {
181
+ const [componentName, componentValue] = componentEntry;
182
+ const adapterValue = componentValue.adapter;
183
+ if (adapterValue !== undefined && !VALID_ADAPTER_IDS_SET.has(adapterValue)) {
184
+ ctx.addIssue({
185
+ code: z.ZodIssueCode.custom,
186
+ path: ["components", componentName, "adapter"],
187
+ message: `unrecognized adapter "${adapterValue}"; expected one of: ${VALID_ADAPTER_IDS.join(", ")}`,
188
+ });
189
+ }
190
+ }
168
191
  });
169
192
  /**
170
193
  * `bundles` map. Keys are workspace bundle slugs (spec §11.1 / D-R5 charset).
@@ -125,7 +125,7 @@ export function formatExtraParamsIssue(label, issue) {
125
125
  * (#852, following #815).
126
126
  *
127
127
  * Pure: never touches the filesystem. Two callers use this differently:
128
- * `akm migrate apply` (src/commands/migrate/config-extra-params.ts) uses the
128
+ * `akm migrate apply` (scripts/akm-migrate/migrate/config-extra-params.ts) uses the
129
129
  * returned `config` to persist the rewrite to disk, once; `parseAndValidateConfigText`
130
130
  * (src/core/config/config.ts) calls this only to detect whether a lift is
131
131
  * needed and discards `config` — an unmigrated config fails closed there
@@ -1187,8 +1187,7 @@ export function runMigrations(db, options) {
1187
1187
  }
1188
1188
  if (!options.allowHistoricalDestructiveStateUpgrade) {
1189
1189
  throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
1190
- "Run `akm upgrade --force` to snapshot it before migration 001, " +
1191
- "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
1190
+ "Run `akm upgrade` (or `akm migrate apply`) to snapshot it before migration 001 and apply it deliberately.");
1192
1191
  }
1193
1192
  const ledger = assertMigrationLedger(lockedDb, STATE_MIGRATIONS);
1194
1193
  if (ledger.migrationIds.length !== 0) {
@@ -1218,8 +1217,7 @@ export function runMigrations(db, options) {
1218
1217
  assertMigrationLedger(lockedDb, STATE_MIGRATIONS);
1219
1218
  if (!options?.allowHistoricalDestructiveStateUpgrade) {
1220
1219
  throw new Error(`Refusing to apply historical destructive state migration ${migration.id} during an ordinary managed open. ` +
1221
- "Run `akm upgrade --force` to create a sibling state.db safety copy and apply it deliberately, " +
1222
- "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
1220
+ "Run `akm upgrade` (or `akm migrate apply`) to create a sibling state.db safety copy and apply it deliberately.");
1223
1221
  }
1224
1222
  if (!options.beforeHistoricalDestructiveMigration) {
1225
1223
  throw new Error(`Historical destructive state migration ${migration.id} requires a verified safety-copy hook.`);
@@ -407,8 +407,8 @@ export function openStateDatabase(dbPath, options) {
407
407
  existingUnversionedDatabase = ledger.migrationIds.length === 0;
408
408
  if (existingUnversionedDatabase && !options?.allowHistoricalDestructiveStateUpgrade) {
409
409
  throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
410
- "Run `akm upgrade --force` to create a verified snapshot before migration 001, " +
411
- "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
410
+ "Run `akm upgrade` (or `akm migrate apply`) to create a verified snapshot before migration 001 " +
411
+ "and apply it deliberately.");
412
412
  }
413
413
  }
414
414
  finally {
@@ -499,13 +499,36 @@ export function openStateDatabase(dbPath, options) {
499
499
  }
500
500
  }
501
501
  /**
502
- * Narrow state-schema step owned by `akm upgrade` after executable replacement.
503
- * Missing/current databases are no-ops. A pre-018 exact ledger is snapshotted
504
- * beside state.db and verified before the immutable released migration runs.
502
+ * Read-only: the state migration IDs the running akm would apply to `dbPath`,
503
+ * in ledger order. Empty when the database is missing or current. Throws on a
504
+ * ledger this akm cannot own (newer, or not an exact ordered prefix) -- the
505
+ * same refusal a managed open makes.
505
506
  */
506
- export function upgradeHistoricalStateDatabase(dbPath = getStateDbPath()) {
507
+ export function listPendingStateMigrations(dbPath = getStateDbPath()) {
507
508
  if (!fs.existsSync(dbPath))
508
- return { upgraded: false };
509
+ return [];
510
+ const preflight = openDatabase(dbPath, { readonly: true });
511
+ try {
512
+ preflight.exec("PRAGMA busy_timeout = 30000");
513
+ const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
514
+ return STATE_MIGRATIONS.slice(ledger.migrationIds.length).map((migration) => migration.id);
515
+ }
516
+ finally {
517
+ preflight.close();
518
+ }
519
+ }
520
+ /**
521
+ * Apply every pending state migration, historical-destructive ones included:
522
+ * the one step `akm upgrade` and `akm migrate apply` share, and the only
523
+ * caller that may admit migration 018 (an ordinary managed open refuses it by
524
+ * design). Missing/current databases are no-ops. A pre-018 exact ledger, or an
525
+ * unversioned database, is snapshotted beside state.db and verified before the
526
+ * immutable released migration runs.
527
+ */
528
+ export function upgradeHistoricalStateDatabase(dbPath = getStateDbPath()) {
529
+ const pending = listPendingStateMigrations(dbPath);
530
+ if (pending.length === 0)
531
+ return { upgraded: false, applied: [] };
509
532
  let safetyCopyPath;
510
533
  try {
511
534
  const db = openStateDatabase(dbPath, {
@@ -521,7 +544,7 @@ export function upgradeHistoricalStateDatabase(dbPath = getStateDbPath()) {
521
544
  const recovery = safetyCopyPath ? ` Verified safety copy: ${safetyCopyPath}.` : "";
522
545
  throw new Error(`${detail}${recovery}`);
523
546
  }
524
- return safetyCopyPath ? { upgraded: true, safetyCopyPath } : { upgraded: false };
547
+ return safetyCopyPath ? { upgraded: true, applied: pending, safetyCopyPath } : { upgraded: true, applied: pending };
525
548
  }
526
549
  /**
527
550
  * Run `fn` against state.db, owning the handle unless one is borrowed. The loan
@@ -13,7 +13,7 @@ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-acces
13
13
  import { getDbPath } from "../core/paths.js";
14
14
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
15
15
  import { withStateDb } from "../core/state-db.js";
16
- import { isVerbose, warn, warnVerbose } from "../core/warn.js";
16
+ import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
17
  import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
18
  import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
19
  import { resolveIndexPassExecution } from "../llm/index-passes.js";
@@ -750,6 +750,67 @@ function sourceSnapshotRemovals(db, currentStashDir, bundleId, currentDirs, allI
750
750
  reason: { kind: "not-in-source-snapshot" },
751
751
  }));
752
752
  }
753
+ /**
754
+ * Warn ONCE per process (#908) when the chosen adapter for a component
755
+ * entirely skips a top-level directory that holds files the `akm` adapter —
756
+ * the format-neutral superset — would have indexed. `detectAdapterId` now
757
+ * corrects this for AUTO-DETECTION (a mixed layout detects as `akm`); this
758
+ * covers the case detection cannot see, an EXPLICITLY configured narrow
759
+ * adapter (`components.<name>.adapter: "agent-skills"`, say) sitting next to
760
+ * ordinary akm content. One line for the whole process — not one per bundle,
761
+ * not one per directory — naming the count and the directories is enough to
762
+ * point an operator at the fix.
763
+ */
764
+ function warnIfAdapterSkipsAkmContent(component, files, adapter) {
765
+ if (adapter.id === "akm")
766
+ return;
767
+ const akm = adapterForId("akm");
768
+ if (!akm)
769
+ return;
770
+ const byTopDir = new Map();
771
+ for (const file of files) {
772
+ const top = file.ancestorDirs[0];
773
+ if (!top)
774
+ continue; // a root-level file is not a "skipped directory" concern
775
+ const group = byTopDir.get(top);
776
+ if (group)
777
+ group.push(file);
778
+ else
779
+ byTopDir.set(top, [file]);
780
+ }
781
+ const akmComponent = { ...component, adapter: "akm" };
782
+ let skippedCount = 0;
783
+ const skippedDirs = [];
784
+ for (const [dir, dirFiles] of byTopDir) {
785
+ const chosenRecognizesAny = dirFiles.some((file) => {
786
+ try {
787
+ return adapter.recognize(component, file) !== null;
788
+ }
789
+ catch {
790
+ return false;
791
+ }
792
+ });
793
+ if (chosenRecognizesAny)
794
+ continue; // the chosen adapter owns this dir; nothing skipped
795
+ const akmCandidates = dirFiles.filter((file) => {
796
+ try {
797
+ return akm.recognize(akmComponent, file) !== null;
798
+ }
799
+ catch {
800
+ return false;
801
+ }
802
+ });
803
+ if (akmCandidates.length === 0)
804
+ continue; // akm would drop it too — not a shadowing case
805
+ skippedCount += akmCandidates.length;
806
+ skippedDirs.push(dir);
807
+ }
808
+ if (skippedCount === 0)
809
+ return;
810
+ skippedDirs.sort();
811
+ warnOnce("adapter-skip-akm-content", `${adapter.id} adapter skipped ${skippedCount} file${skippedCount === 1 ? "" : "s"} in ` +
812
+ `${skippedDirs.map((dir) => `${dir}/`).join(", ")} — set components.<name>.adapter to "akm" to index them`);
813
+ }
753
814
  function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs) {
754
815
  const componentBySource = buildComponentBySource(allSourceEntries);
755
816
  const handoffDirs = new Set();
@@ -775,6 +836,8 @@ function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMiss
775
836
  });
776
837
  const dirGroups = groupFileContextsByDir(walked.files);
777
838
  const adapter = adapterForId(component.adapter);
839
+ if (adapter)
840
+ warnIfAdapterSkipsAkmContent(component, walked.files, adapter);
778
841
  return {
779
842
  currentStashDir,
780
843
  component,