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
package/CHANGELOG.md CHANGED
@@ -4,115 +4,82 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [0.9.8-beta.3] - 2026-09-02
7
+ ## [0.9.9-beta.1] - 2026-09-02
8
8
 
9
- ### Fixed
10
-
11
- - **The incremental index no longer misses an edit whose timestamp did not move
12
- forward.** The per-directory freshness check summarised a directory as its
13
- file-name set plus the single newest mtime, which lost two kinds of change.
14
- An edit to any file other than the newest one landed below that maximum and
15
- was invisible even though its own mtime changed — so a restore, checkout, or
16
- archive extraction that stamped a plausible older date left stale content in
17
- the index. And because mtime is writable by ordinary tooling (`touch -r`,
18
- `rsync --times`, `cp -p`), an edit with a restored timestamp was invisible
19
- outright. The directory is now digested per file over
20
- `(basename, size, mtime, ctime)` at nanosecond resolution. It is the same one
21
- `stat` call per file, so the incremental fast path costs what it did before.
22
- Both gaps predate 0.9.8 and applied to every earlier release.
23
-
24
- Trade-off worth knowing: `ctime` also moves on metadata-only changes such as
25
- `chmod`, and after copying a tree, so those now cost one extra rescan. That
26
- direction is deliberate — extra work, never stale content. Existing indexes
27
- rescan once as the digest changes shape, then return to the fast path.
28
-
29
- - **`akm migrate apply` can now clear a legacy `extraParams` config.** A config
30
- still carrying a liftable key such as `extraParams.temperature` fails config
31
- load closed, and that error names `akm migrate apply` as the fix — but the
32
- migrate command resolved the stash directory and ran the task migrator, both
33
- of which load config, so it died on the very error it exists to clear. An
34
- operator hitting this had no reachable way forward. The config lift now runs
35
- before anything that loads config, and `akm migrate status` reports the
36
- pending lift as its blocker instead of re-raising the same error. A genuine
37
- conflict, where an `extraParams` key and its first-class field disagree, still
38
- hard-rejects and names both values rather than guessing.
39
-
40
- - **`akm health` no longer warns about disk usage on a fresh install.** The
41
- `data-dir-usage` advisory added earlier in 0.9.8 counted SQLite's `-wal` and
42
- `-shm` sidecars toward the data directory's total but not toward the live
43
- databases they belong to. On an untouched install the write-ahead log is most
44
- of the directory, so the very first `akm health` reported a ~126x ratio and
45
- exited `warn` with no user data present. Sidecars now count as part of their
46
- database, and the advisory stays quiet below 1 GB, where a ratio says nothing
47
- useful about disk pressure.
48
-
49
- ## [0.9.8-beta.2] - 2026-09-02
9
+ ### Changed
50
10
 
51
- > **Adds state migration `026-proposals-strip-legacy-fragment-refs`.** The
52
- > one-way caveat below applies to it as well: once this build opens
53
- > `state.db`, 0.9.8-beta.1 and earlier refuse it with `unknown migration ID
54
- > 026-proposals-strip-legacy-fragment-refs`.
55
-
56
- ### Added
57
-
58
- - **`akm health` reports data-dir disk usage** (#896). A `data-dir-usage`
59
- advisory sums the data directory with a stat-only walk and warns when it is
60
- more than the three live databases (state.db, index.db, logs.db) or when
61
- one top-level subdirectory holds more than half of it, naming that
62
- subdirectory with its size and share (for example `backups/ is 70G (94% of
63
- data dir)`). The walk stops after 100,000 entries and says so. Silent when
64
- nothing looks wrong.
11
+ - **`akm-migrate` is the one migration tool, and `akm upgrade` runs it**
12
+ (#895 follow-up, #901). The standalone `akm-migrate` executable now runs
13
+ every migration step in one plan the legacy config `extraParams` lift,
14
+ pending `state.db` migrations (historical-destructive ones included, with
15
+ the verified safety copy), task v2 → v3, task v3 → task source v4, and the
16
+ residue sweeps — as `akm-migrate status` / `apply [--dry-run]`; its
17
+ per-generation verbs (`task-v4-status`, `task-v4-apply`) are gone.
18
+ `akm migrate status|apply` is now a thin wrapper over that executable, and
19
+ every historical shape (`scripts/akm-migrate/`) lives outside the CLI
20
+ proper. `akm upgrade` runs `akm-migrate apply` **after** its install step
21
+ the migrator that shipped with whatever is now installed on every run,
22
+ install or no install, and reports the plan under `migration`; a blocked or
23
+ failed migration exits 1. A package-manager install that fails (EACCES on a
24
+ root-owned global directory) still runs the migrator and says so. An akm
25
+ installed as a dependency of another package is now detected
26
+ (`installMethod: "package-local"`) and never reinstalled — an
27
+ `npm install -g` there "succeeded" while the parent kept executing its own
28
+ copy — but its migrations still run. `akm upgrade --state-only` and the
29
+ `stateUpgrade` response field are removed; `akm migrate apply` is the
30
+ offline path. An ordinary managed open still refuses a historical-
31
+ destructive migration, and its message names these two commands.
32
+ A container that ships akm can put `akm upgrade` (or `akm migrate apply`)
33
+ in its entrypoint: on a current installation it is a no-op.
65
34
 
66
35
  ### Fixed
67
36
 
68
- - **`akm task sync` no longer spawns `npm root --global` on every call** (#901).
69
- The npm-global-root probe behind `resolveAkmInvocation` is memoized for the
70
- process, so a `task sync --rebind` cycle spawns npm at most once instead of
71
- twice, and an installation that loops it every minute stops accumulating an
72
- npm debug log per spawn.
73
- - **A blocked v2 task now says how to convert it** (#902, #899). The
74
- `argv-array-has-no-portable-shell-string` blocker printed by `akm migrate`
75
- and the `TASK_SCHEMA_VERSION_UNSUPPORTED` read error now state that manual
76
- conversion is required and name the rewrite (`command:` argv array
77
- `run:` string plus `shell:`). The full v2 v4 field mapping is documented in
78
- `docs/migration/v0.9.1-to-v0.9.2.md`.
79
- - **Legacy `#fragment` proposal rows are repaired instead of warned about
80
- forever** (#898). State migration 026 strips the retired export-fragment
81
- selector from `proposals.ref` in place so the rows parse again, and an
82
- unparseable proposal row now warns once per process instead of once per
83
- read (`akm health --report` read the table seven times).
84
-
85
- - **A no-op incremental `akm index` no longer costs minutes of CPU** (#900).
86
- Two causes: the per-directory freshness check ran two full scans of the
87
- `entries` table for every directory (O(directories × entries)), and every
88
- file was read, hashed, and parsed before the freshness check decided the
89
- directory was unchanged. The directory lookup now uses the existing
90
- `file_path` index, and a stat-based gate over each directory's walked file
91
- set skips unchanged directories before any file is read. On a synthetic
92
- 800-directory, 4,000-entry corpus a no-op pass fell from ~37 s to under 1 s
93
- of CPU with identical entries and search results. The persisted directory
94
- fingerprint now covers every walked file and `index_dir_state` gains a
95
- `row_count` column; an existing index.db drains each directory once more
96
- after upgrading, then takes the fast path.
97
-
98
- - **Task-migration snapshots are capped at the five most recent** (#897).
99
- `akm migrate apply` writes one snapshot directory per run under
100
- `backups/task-v3` and `backups/task-v4` and never pruned them; each apply
101
- now keeps the five newest and removes the rest, the same policy config
102
- backups already use. Nothing in the current code writes the legacy
103
- `backups/migrations`, `manual`, `releases`, or `operations` directories,
104
- so they are left alone; the new health advisory is what surfaces them.
105
-
106
- ## [0.9.8-beta.1] - 2026-09-01
37
+ - **`akm task sync` reports failed sources under `failures` on both the live
38
+ and `--dry-run` shapes** (#906). Live sync used to report them under
39
+ `failed` while `--dry-run` already used `failures`. No alias; both paths
40
+ now agree.
41
+ - **`akm task sync` no longer misdiagnoses a supercronic-managed container as
42
+ missing the `crontab` binary** (#910). `crontab -l` exiting non-zero with
43
+ no output, or with stderr saying "no crontab", is cron's own way of saying
44
+ "empty crontab" (BSD's `no crontab for <user>`, or a PATH shim like
45
+ OpenPalm's before any spool exists). Only a spawn that cannot find the
46
+ binary (ENOENT) is reported as missing; any other failure is reported as
47
+ what it said.
48
+ - **`akm task sync --rebind` no longer warns on every run of an image-baked
49
+ install** (#868 residue). The warning about binding scheduled tasks to a
50
+ mutable, unproven binary fires only when a rebind actually changes an
51
+ entry's bound invocation.
52
+ - **A mixed-layout bundle no longer auto-detects as a narrow tool-dir adapter
53
+ and silently drops the rest** (#908). A root that a tool-dir-shaped adapter
54
+ (`agent-skills`, `claude`, `opencode`) claims but that also carries ordinary
55
+ akm content detects as `akm`, the superset. `akm bundle list` now reports
56
+ each component's effective `adapter` and whether it was `detected`, and
57
+ indexing under an explicitly narrow adapter warns once with the count of
58
+ files and directories it skipped.
59
+ - **An unrecognised `components.*.adapter` is rejected** (#909) with
60
+ `INVALID_CONFIG_FILE` listing the accepted names instead of silently
61
+ falling back to `akm`. `akm bundle add --adapter <name>` overrides
62
+ auto-detection for a local directory; its `--help` lists the registry.
63
+
64
+ ## [0.9.8] - 2026-09-02
107
65
 
108
66
  A cleanup and stabilization release: deletion of machinery that policed the
109
67
  codebase's shape rather than its behaviour, and — because auditing for that
110
68
  machinery meant reading the code closely — a run of real defects it had been
111
- sitting on top of.
112
-
113
- > **Upgrading is one-way for `state.db`.** This release adds migration
114
- > `025-task-history-vocabulary-backfill`. Once any 0.9.8 command opens
115
- > `state.db`, the ledger contains an ID that 0.9.7 does not know, and 0.9.7
69
+ sitting on top of. Two security holes, two search-correctness bugs, a
70
+ locale-dependent hash, a deletion shield that failed open, and sixteen places
71
+ that answered a failure with a confident wrong answer instead of an error.
72
+
73
+ Then a second round, from verifying the release against a real 23,865-entry
74
+ environment: a no-op incremental index costing ~21 CPU-minutes, legacy proposal
75
+ rows that could not be repaired, an npm probe spawning on every scheduler tick,
76
+ a blocked task migration that named no remedy, and a data directory that could
77
+ reach 74 GB with nothing reporting it.
78
+
79
+ > **Upgrading is one-way for `state.db`.** This release adds two migrations,
80
+ > `025-task-history-vocabulary-backfill` and
81
+ > `026-proposals-strip-legacy-fragment-refs`. Once any 0.9.8 command opens
82
+ > `state.db`, its ledger contains IDs that 0.9.7 does not know, and 0.9.7
116
83
  > refuses to open it: `Refusing to open a database with a newer migration
117
84
  > ledger: unknown migration ID 025-task-history-vocabulary-backfill`.
118
85
  >
@@ -127,12 +94,22 @@ sitting on top of.
127
94
  >
128
95
  > ```sh
129
96
  > akm info --format json # confirm your data dir
130
- > sqlite3 "$DATA_DIR/state.db" "VACUUM INTO '''state.db.pre-0.9.8.bak'''"
97
+ > sqlite3 "$DATA_DIR/state.db" "VACUUM INTO 'state.db.pre-0.9.8.bak'"
131
98
  > ```
132
99
 
133
- Two security holes, two search-correctness bugs, a locale-dependent hash, a
134
- deletion shield that failed open, and sixteen places that answered a failure
135
- with a confident wrong answer instead of an error.
100
+ > **`index.db` rescans once.** The per-directory freshness fingerprint changed
101
+ > shape, so the first `akm index` after upgrading re-reads every directory and
102
+ > then returns to the fast path. Nothing is lost; the index is derived.
103
+
104
+ ### Added
105
+
106
+ - **`akm health` reports data-dir disk usage** (#896). A `data-dir-usage`
107
+ advisory sums the data directory with a stat-only walk and warns when it is
108
+ more than 3× the three live databases (state.db, index.db, logs.db) or when
109
+ one top-level subdirectory holds more than half of it, naming that
110
+ subdirectory with its size and share (for example `backups/ is 70G (94% of
111
+ data dir)`). The walk stops after 100,000 entries and says so. Silent when
112
+ nothing looks wrong.
136
113
 
137
114
  ### Changed
138
115
 
@@ -149,6 +126,39 @@ with a confident wrong answer instead of an error.
149
126
  ~60 lines of comment justifying it. `--format text` still renders the same
150
127
  summary through the same formatter; it is simply no longer the default.
151
128
 
129
+
130
+ - **Search no longer truncates long queries (#892).** `MAX_LEXICAL_QUERY_TOKENS
131
+ = 16` silently dropped every token past the sixteenth, and tokens are
132
+ collected in order, so the discarded half was the tail — for
133
+ natural-language input, usually where the discriminating words are. It also
134
+ fed ranking, so token-overlap scoring ran on the truncated set too. It was
135
+ unexplained in the code and in the commit that introduced it, and unreachable
136
+ from any flag, config key, or environment variable. Removed: the planner
137
+ handles 10,000 tokens in 9ms, so no performance cliff was being protected.
138
+
139
+ - **Content and memory bodies are no longer silently truncated.**
140
+ `MAX_CONTENT_CHARS` (100k, duplicated across 8 adapters) cut indexed content
141
+ so the tail of a long document was unsearchable; `MAX_BODY_CHARS` (4000) cut
142
+ the text sent for memory inference, so on a large-context engine the model
143
+ saw a fraction of the input while the derived memory looked complete. Both
144
+ removed.
145
+
146
+ - **GitHub Actions are pinned to commit SHAs (#768).** All 29 `uses:` steps
147
+ across every workflow, with the tag preserved in a trailing comment.
148
+
149
+ - **Gated CI runs on schedule, dispatch, and candidate tags only.** The
150
+ `detect-changes` job that selected suites by regex-matching a PR diff is
151
+ gone — its path patterns had gone stale and still named test files this
152
+ release moved or deleted, so it was silently under-selecting suites. Release
153
+ evidence is unchanged; the checklist always required an exact-SHA dispatch.
154
+
155
+ - **`akm-eval` in CI is now a determinism check only.** Its score gates are
156
+ removed. Measured before cutting: the baseline scored a perfect 1.0 against
157
+ a 0.75 gate, and seven of nine case types never ran — CI has no LLM and no
158
+ run history, so everything the eval exists to measure was skipped while the
159
+ job reported green. The harness itself is unchanged and remains a genuine
160
+ quality signal when run against a real bundle.
161
+
152
162
  ### Fixed
153
163
 
154
164
  - **Historical state migrations are reachable where akm cannot reinstall
@@ -264,39 +274,83 @@ with a confident wrong answer instead of an error.
264
274
  `bun run lint` failed locally while CI, which never has those files, stayed
265
275
  green.
266
276
 
267
- ### Changed
268
277
 
269
- - **Search no longer truncates long queries (#892).** `MAX_LEXICAL_QUERY_TOKENS
270
- = 16` silently dropped every token past the sixteenth, and tokens are
271
- collected in order, so the discarded half was the tail for
272
- natural-language input, usually where the discriminating words are. It also
273
- fed ranking, so token-overlap scoring ran on the truncated set too. It was
274
- unexplained in the code and in the commit that introduced it, and unreachable
275
- from any flag, config key, or environment variable. Removed: the planner
276
- handles 10,000 tokens in 9ms, so no performance cliff was being protected.
278
+ - **`akm task sync` no longer spawns `npm root --global` on every call** (#901).
279
+ The npm-global-root probe behind `resolveAkmInvocation` is memoized for the
280
+ process, so a `task sync --rebind` cycle spawns npm at most once instead of
281
+ twice, and an installation that loops it every minute stops accumulating an
282
+ npm debug log per spawn.
283
+ - **A blocked v2 task now says how to convert it** (#902, #899). The
284
+ `argv-array-has-no-portable-shell-string` blocker printed by `akm migrate`
285
+ and the `TASK_SCHEMA_VERSION_UNSUPPORTED` read error now state that manual
286
+ conversion is required and name the rewrite (`command:` argv array →
287
+ `run:` string plus `shell:`). The full v2 → v4 field mapping is documented in
288
+ `docs/migration/v0.9.1-to-v0.9.2.md`.
289
+ - **Legacy `#fragment` proposal rows are repaired instead of warned about
290
+ forever** (#898). State migration 026 strips the retired export-fragment
291
+ selector from `proposals.ref` in place so the rows parse again, and an
292
+ unparseable proposal row now warns once per process instead of once per
293
+ read (`akm health --report` read the table seven times).
277
294
 
278
- - **Content and memory bodies are no longer silently truncated.**
279
- `MAX_CONTENT_CHARS` (100k, duplicated across 8 adapters) cut indexed content
280
- so the tail of a long document was unsearchable; `MAX_BODY_CHARS` (4000) cut
281
- the text sent for memory inference, so on a large-context engine the model
282
- saw a fraction of the input while the derived memory looked complete. Both
283
- removed.
295
+ - **A no-op incremental `akm index` no longer costs minutes of CPU** (#900).
296
+ Two causes: the per-directory freshness check ran two full scans of the
297
+ `entries` table for every directory (O(directories × entries)), and every
298
+ file was read, hashed, and parsed before the freshness check decided the
299
+ directory was unchanged. The directory lookup now uses the existing
300
+ `file_path` index, and a stat-based gate over each directory's walked file
301
+ set skips unchanged directories before any file is read. On a synthetic
302
+ 800-directory, 4,000-entry corpus a no-op pass fell from ~37 s to under 1 s
303
+ of CPU with identical entries and search results. The persisted directory
304
+ fingerprint now covers every walked file and `index_dir_state` gains a
305
+ `row_count` column; an existing index.db drains each directory once more
306
+ after upgrading, then takes the fast path.
284
307
 
285
- - **GitHub Actions are pinned to commit SHAs (#768).** All 29 `uses:` steps
286
- across every workflow, with the tag preserved in a trailing comment.
308
+ - **Task-migration snapshots are capped at the five most recent** (#897).
309
+ `akm migrate apply` writes one snapshot directory per run under
310
+ `backups/task-v3` and `backups/task-v4` and never pruned them; each apply
311
+ now keeps the five newest and removes the rest, the same policy config
312
+ backups already use. Nothing in the current code writes the legacy
313
+ `backups/migrations`, `manual`, `releases`, or `operations` directories,
314
+ so they are left alone; the new health advisory is what surfaces them.
287
315
 
288
- - **Gated CI runs on schedule, dispatch, and candidate tags only.** The
289
- `detect-changes` job that selected suites by regex-matching a PR diff is
290
- gone — its path patterns had gone stale and still named test files this
291
- release moved or deleted, so it was silently under-selecting suites. Release
292
- evidence is unchanged; the checklist always required an exact-SHA dispatch.
293
316
 
294
- - **`akm-eval` in CI is now a determinism check only.** Its score gates are
295
- removed. Measured before cutting: the baseline scored a perfect 1.0 against
296
- a 0.75 gate, and seven of nine case types never ran CI has no LLM and no
297
- run history, so everything the eval exists to measure was skipped while the
298
- job reported green. The harness itself is unchanged and remains a genuine
299
- quality signal when run against a real bundle.
317
+ - **The incremental index no longer misses an edit whose timestamp did not move
318
+ forward.** The per-directory freshness check summarised a directory as its
319
+ file-name set plus the single newest mtime, which lost two kinds of change.
320
+ An edit to any file other than the newest one landed below that maximum and
321
+ was invisible even though its own mtime changed so a restore, checkout, or
322
+ archive extraction that stamped a plausible older date left stale content in
323
+ the index. And because mtime is writable by ordinary tooling (`touch -r`,
324
+ `rsync --times`, `cp -p`), an edit with a restored timestamp was invisible
325
+ outright. The directory is now digested per file over
326
+ `(basename, size, mtime, ctime)` at nanosecond resolution. It is the same one
327
+ `stat` call per file, so the incremental fast path costs what it did before.
328
+ Both gaps predate 0.9.8 and applied to every earlier release.
329
+
330
+ Trade-off worth knowing: `ctime` also moves on metadata-only changes such as
331
+ `chmod`, and after copying a tree, so those now cost one extra rescan. That
332
+ direction is deliberate — extra work, never stale content. Existing indexes
333
+ rescan once as the digest changes shape, then return to the fast path.
334
+
335
+ - **`akm migrate apply` can now clear a legacy `extraParams` config.** A config
336
+ still carrying a liftable key such as `extraParams.temperature` fails config
337
+ load closed, and that error names `akm migrate apply` as the fix — but the
338
+ migrate command resolved the stash directory and ran the task migrator, both
339
+ of which load config, so it died on the very error it exists to clear. An
340
+ operator hitting this had no reachable way forward. The config lift now runs
341
+ before anything that loads config, and `akm migrate status` reports the
342
+ pending lift as its blocker instead of re-raising the same error. A genuine
343
+ conflict, where an `extraParams` key and its first-class field disagree, still
344
+ hard-rejects and names both values rather than guessing.
345
+
346
+ - **`akm health` no longer warns about disk usage on a fresh install.** The
347
+ `data-dir-usage` advisory added earlier in 0.9.8 counted SQLite's `-wal` and
348
+ `-shm` sidecars toward the data directory's total but not toward the live
349
+ databases they belong to. On an untouched install the write-ahead log is most
350
+ of the directory, so the very first `akm health` reported a ~126x ratio and
351
+ exited `warn` with no user data present. Sidecars now count as part of their
352
+ database, and the advisory stays quiet below 1 GB, where a ratio says nothing
353
+ useful about disk pressure.
300
354
 
301
355
  ### Removed
302
356
 
@@ -4,6 +4,7 @@
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { loadConfig } from "../../core/config/config.js";
6
6
  import { ConfigError } from "../../core/errors.js";
7
+ import { listPendingStateMigrations } from "../../core/state-db.js";
7
8
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
8
9
  import { resolveEngine } from "../../integrations/agent/engine-resolution.js";
9
10
  import { executionEngineDefinitionsFromConfig } from "../../integrations/agent/execution-definitions.js";
@@ -441,6 +442,40 @@ export function runSelectedModelAliasesProbe(deps = {}) {
441
442
  evidence: { checked, missing },
442
443
  };
443
444
  }
445
+ /**
446
+ * Hard check: state.db's migration ledger has no pending entries.
447
+ *
448
+ * Read-only via `listPendingStateMigrations` (a preflight open, never a
449
+ * managed one), so running this check is always safe — even when the
450
+ * corresponding managed open would refuse outright because a pending
451
+ * migration is historical-destructive (see `beforeMigrationLocked` in
452
+ * `src/core/state/migrations.ts`). This is what lets `akm health` report that
453
+ * refusal as an ordinary `fail` check instead of crashing the whole command —
454
+ * and what replaces a bundler grepping akm's refusal error text
455
+ * to detect the same case.
456
+ */
457
+ export function runPendingStateMigrationsCheck(stateDbPath, deps = {}) {
458
+ const pending = (deps.listPendingStateMigrations ?? listPendingStateMigrations)(stateDbPath);
459
+ if (pending.length === 0) {
460
+ return {
461
+ name: "state-db-migrations",
462
+ kind: "deterministic",
463
+ status: "pass",
464
+ confidence: "high",
465
+ message: "state.db has no pending migrations.",
466
+ evidence: { path: stateDbPath, pending: [] },
467
+ };
468
+ }
469
+ const range = pending.length > 1 ? `${pending[0]} … ${pending[pending.length - 1]}` : pending[0];
470
+ return {
471
+ name: "state-db-migrations",
472
+ kind: "deterministic",
473
+ status: "fail",
474
+ confidence: "high",
475
+ message: `${pending.length} pending state.db migration(s) (${range}); run \`akm migrate apply\`.`,
476
+ evidence: { path: stateDbPath, pending },
477
+ };
478
+ }
444
479
  /**
445
480
  * The ordered health-check registry. ORDER IS LOAD-BEARING: `akmHealth`
446
481
  * iterates this array and appends to hardChecks/advisories in sequence, so the
@@ -477,6 +512,11 @@ export const HEALTH_CHECKS = [
477
512
  evidence: { path: ctx.stateDbPath, durationMs: ctx.probe.durationMs },
478
513
  }),
479
514
  },
515
+ {
516
+ name: "state-db-migrations",
517
+ channel: "hard",
518
+ run: (ctx) => runPendingStateMigrationsCheck(ctx.stateDbPath),
519
+ },
480
520
  {
481
521
  name: "task-history-read",
482
522
  channel: "hard",
@@ -10,14 +10,14 @@ import { readEvents } from "../core/events.js";
10
10
  import { openLogsDatabase } from "../core/logs-db.js";
11
11
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
12
12
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
13
- import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
13
+ import { listExistingTableNames, listPendingStateMigrations, openStateDatabase } from "../core/state-db.js";
14
14
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
15
15
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
16
16
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
17
17
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
18
18
  import { pkgVersion } from "../version.js";
19
19
  import { collectImproveAdvisories } from "./health/advisories.js";
20
- import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
20
+ import { HEALTH_CHECKS, runHealthEngineProbes, runPendingStateMigrationsCheck, } from "./health/checks.js";
21
21
  import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
22
22
  import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
23
23
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
@@ -414,37 +414,32 @@ function resolveWindowComparePhase(options, db, stateDbPath, now, logsDb) {
414
414
  return { windowResults, deltas };
415
415
  }
416
416
  /**
417
- * The health report for a state.db this process cannot read (#791).
417
+ * The health report for a state.db a managed open cannot reach at all: either
418
+ * the file is not readable (#791), or — the same shape, a different cause —
419
+ * it holds a pending migration the managed open refuses to apply without
420
+ * deliberate consent (`akm upgrade` / `akm migrate apply` are the
421
+ * only two callers allowed to admit a historical-destructive migration; see
422
+ * `beforeMigrationLocked` in `src/core/state/migrations.ts`).
418
423
  *
419
- * `akm health` is what an operator runs when other commands are misbehaving, so
420
- * it must survive the permission problem long enough to NAME it. Previously it
421
- * threw `ConfigError` (exit 78) on the state.db open, which meant the one
422
- * command able to explain a data-directory permission fault died before
423
- * reaching any of its advisories.
424
+ * `akm health` is what an operator (or a bundler's boot check) runs when
425
+ * something else is misbehaving, so it must survive either problem long
426
+ * enough to NAME it. Previously an unreadable file was already handled this
427
+ * way, but a pending migration was not: the managed open's refusal escaped as
428
+ * a thrown `ConfigError` (exit 78) and crashed the whole command before any
429
+ * check — including this one — could report anything.
424
430
  *
425
- * Reported as a hard-channel `fail` — the run genuinely could not assess the
426
- * install with the path, errno, mode/owner and running uid in the message. It
427
- * exits non-zero either way; the difference is that the operator is now told
428
- * WHY instead of being handed a bare "unable to open database file".
431
+ * Reported as a single hard-channel `fail` check — the run genuinely could
432
+ * not open state.db, so every check that depends on it is skipped rather than
433
+ * attempted and it still exits non-zero, just through health's normal
434
+ * `fail` path instead of a thrown config-error exit.
429
435
  */
430
- function unreadableStateDbReport(detail, options) {
436
+ function degradedStateDbReport(hardCheck, options) {
431
437
  return {
432
438
  schemaVersion: 3,
433
439
  ok: false,
434
440
  status: "fail",
435
441
  since: parseHealthSince(options.since),
436
- hardChecks: [
437
- {
438
- name: "state-db-readable",
439
- kind: "deterministic",
440
- status: "fail",
441
- confidence: "high",
442
- message: `state.db exists but is not readable: ${detail}. Every other health check is skipped because ` +
443
- "none of them can read it. Check the owner and mode of the data directory, or point " +
444
- "AKM_DATA_DIR / XDG_DATA_HOME at a location this user owns.",
445
- evidence: { detail },
446
- },
447
- ],
442
+ hardChecks: [hardCheck],
448
443
  advisories: [],
449
444
  metrics: {
450
445
  taskFailRate: 0,
@@ -459,6 +454,18 @@ function unreadableStateDbReport(detail, options) {
459
454
  improve: summarizeImproveCompleted([]),
460
455
  };
461
456
  }
457
+ function unreadableStateDbCheck(detail) {
458
+ return {
459
+ name: "state-db-readable",
460
+ kind: "deterministic",
461
+ status: "fail",
462
+ confidence: "high",
463
+ message: `state.db exists but is not readable: ${detail}. Every other health check is skipped because ` +
464
+ "none of them can read it. Check the owner and mode of the data directory, or point " +
465
+ "AKM_DATA_DIR / XDG_DATA_HOME at a location this user owns.",
466
+ evidence: { detail },
467
+ };
468
+ }
462
469
  export function akmHealth(options = {}) {
463
470
  validateAkmHealthOptions(options);
464
471
  const now = options.now ?? (() => Date.now());
@@ -466,11 +473,13 @@ export function akmHealth(options = {}) {
466
473
  const stateDbPath = options.stateDbPath ?? getStateDbPathInDataDir();
467
474
  const hardChecks = [];
468
475
  const advisories = [];
469
- // #791: an UNREADABLE state.db is the one failure `akm health` most needs to
470
- // be able to report, because it is the command an operator runs to find out
471
- // why everything else is behaving oddly. Dying here with exit 78 meant health
472
- // could not diagnose that state at all not even the checks that never touch
473
- // state.db got to run. Report it as a finding instead.
476
+ // #791: an UNREADABLE state.db, or one with a pending migration the
477
+ // managed open refuses to apply, are the two failures `akm health` most
478
+ // needs to be able to report, because this is the command an operator (or a
479
+ // bundler's boot check) runs to find out why everything else is behaving
480
+ // oddly. Dying here meant health could not diagnose that state at all — not
481
+ // even the checks that never touch state.db got to run. Report it as a
482
+ // finding instead.
474
483
  let db;
475
484
  try {
476
485
  db = openStateDatabase(stateDbPath);
@@ -478,7 +487,24 @@ export function akmHealth(options = {}) {
478
487
  catch (error) {
479
488
  const { access, code } = classifyPathAccess(stateDbPath);
480
489
  if (access === "inaccessible") {
481
- return unreadableStateDbReport(describeInaccessiblePath(stateDbPath, code), options);
490
+ return degradedStateDbReport(unreadableStateDbCheck(describeInaccessiblePath(stateDbPath, code)), options);
491
+ }
492
+ // The managed open's refusal of a pending historical-destructive
493
+ // migration is a plain `Error`, not a distinguishable error class — so
494
+ // confirm the cause via the read-only preflight (`listPendingStateMigrations`,
495
+ // which never applies anything) rather than pattern-matching the message.
496
+ // A ledger too broken to enumerate at all throws here too; in that case
497
+ // fall through to the generic config-error report below, since it is a
498
+ // genuinely different, rarer failure this check cannot explain.
499
+ let pendingMigrationsCheck;
500
+ try {
501
+ pendingMigrationsCheck = runPendingStateMigrationsCheck(stateDbPath, { listPendingStateMigrations });
502
+ }
503
+ catch {
504
+ pendingMigrationsCheck = undefined;
505
+ }
506
+ if (pendingMigrationsCheck?.status === "fail") {
507
+ return degradedStateDbReport(pendingMigrationsCheck, options);
482
508
  }
483
509
  throw new ConfigError(`Unable to open state.db: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
484
510
  }