akm-cli 0.9.7 → 0.9.8-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 (130) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/data-dir-usage.js +165 -0
  5. package/dist/commands/health/improve-metrics.js +8 -34
  6. package/dist/commands/health/windows.js +0 -4
  7. package/dist/commands/health.js +18 -37
  8. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  9. package/dist/commands/improve/extract.js +36 -32
  10. package/dist/commands/improve/memory/memory-belief.js +15 -5
  11. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  12. package/dist/commands/improve/memory/memory-improve.js +7 -19
  13. package/dist/commands/improve/preparation.js +52 -36
  14. package/dist/commands/improve/reflect.js +13 -17
  15. package/dist/commands/lint/base-linter.js +42 -2
  16. package/dist/commands/lint/index.js +4 -5
  17. package/dist/commands/migrate/config-extra-params.js +61 -0
  18. package/dist/commands/migrate/dead-residue.js +113 -0
  19. package/dist/commands/migrate/stale-txn.js +49 -0
  20. package/dist/commands/migrate-cli.js +42 -1
  21. package/dist/commands/proposal/proposal.js +1 -21
  22. package/dist/commands/proposal/repository.js +0 -4
  23. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  24. package/dist/commands/read/curate.js +4 -18
  25. package/dist/commands/read/show.js +2 -1
  26. package/dist/commands/sources/installed-stashes.js +5 -1
  27. package/dist/commands/sources/self-update.js +38 -1
  28. package/dist/commands/sources/sources-cli.js +17 -1
  29. package/dist/commands/tasks/tasks.js +0 -8
  30. package/dist/commands/url-checker.js +20 -28
  31. package/dist/commands/workflow-cli.js +11 -61
  32. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  33. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  34. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  35. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  36. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  37. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  38. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  39. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  40. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  41. package/dist/core/adapter/adapters/shared.js +2 -0
  42. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  43. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  44. package/dist/core/adapter/validate-context.js +1 -3
  45. package/dist/core/asset/asset-placement.js +1 -3
  46. package/dist/core/asset/frontmatter.js +96 -0
  47. package/dist/core/common.js +66 -2
  48. package/dist/core/config/config-io.js +21 -8
  49. package/dist/core/config/config-walker.js +6 -10
  50. package/dist/core/config/config.js +11 -8
  51. package/dist/core/extra-params.js +17 -13
  52. package/dist/core/improve-result.js +1 -3
  53. package/dist/core/json-schema.js +9 -11
  54. package/dist/core/state/migrations.js +74 -2
  55. package/dist/core/state-db.js +2 -1
  56. package/dist/core/warn.js +15 -0
  57. package/dist/execution/executable-identity.js +1 -3
  58. package/dist/execution/guarded-source.js +1 -6
  59. package/dist/indexer/bundle-identity-guard.js +6 -1
  60. package/dist/indexer/db/graph-db.js +139 -154
  61. package/dist/indexer/ensure-index.js +11 -19
  62. package/dist/indexer/graph/graph-boost.js +23 -34
  63. package/dist/indexer/graph/graph-extraction.js +12 -2
  64. package/dist/indexer/indexer.js +43 -22
  65. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  66. package/dist/indexer/passes/dir-staleness.js +30 -9
  67. package/dist/indexer/passes/memory-inference.js +7 -2
  68. package/dist/indexer/scan/drain-dir.js +2 -4
  69. package/dist/indexer/search/db-search.js +3 -3
  70. package/dist/indexer/search/fts-query.js +10 -15
  71. package/dist/indexer/search/search-source.js +0 -13
  72. package/dist/indexer/usage/usage-events.js +9 -1
  73. package/dist/indexer/walk/walker.js +11 -6
  74. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  75. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  76. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  77. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  78. package/dist/integrations/harnesses/index.js +0 -4
  79. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  80. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  81. package/dist/integrations/lockfile.js +0 -14
  82. package/dist/integrations/session-logs/index.js +0 -81
  83. package/dist/llm/client.js +0 -12
  84. package/dist/llm/memory-infer.js +1 -3
  85. package/dist/llm/usage-telemetry.js +1 -3
  86. package/dist/registry/create-provider-registry.js +4 -0
  87. package/dist/registry/factory.js +4 -0
  88. package/dist/registry/resolve.js +23 -8
  89. package/dist/runtime.js +0 -12
  90. package/dist/scripts/akm-migrate-node.js +179 -248
  91. package/dist/scripts/akm-migrate.js +179 -248
  92. package/dist/setup/setup.js +6 -12
  93. package/dist/sources/providers/git-install.js +7 -2
  94. package/dist/sources/providers/tar-utils.js +1 -7
  95. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  96. package/dist/storage/managed-db.js +20 -7
  97. package/dist/storage/repositories/events-repository.js +0 -81
  98. package/dist/storage/repositories/index-entries-repository.js +24 -28
  99. package/dist/storage/repositories/index-fts-repository.js +24 -30
  100. package/dist/storage/repositories/index-meta-repository.js +6 -4
  101. package/dist/storage/repositories/index-schema.js +16 -1
  102. package/dist/storage/repositories/index-utility-repository.js +0 -57
  103. package/dist/storage/repositories/index-vec-repository.js +25 -27
  104. package/dist/storage/repositories/proposals-repository.js +4 -1
  105. package/dist/storage/repositories/task-history-repository.js +9 -3
  106. package/dist/tasks/backends/launchd.js +8 -18
  107. package/dist/tasks/resolve-akm-bin.js +15 -0
  108. package/dist/tasks/run/task-history.js +21 -31
  109. package/dist/tasks/scheduler-sync.js +1 -6
  110. package/dist/tasks/source/bounded-document.js +1 -14
  111. package/dist/tasks/source/task-to-v3.js +10 -2
  112. package/dist/workflows/exec/child-workflow.js +1 -1
  113. package/dist/workflows/exec/native-executor.js +2 -2
  114. package/dist/workflows/exec/step-work.js +5 -17
  115. package/dist/workflows/exec/worktree.js +40 -6
  116. package/dist/workflows/freeze/task-bindings.js +2 -4
  117. package/dist/workflows/ir/compile.js +3 -14
  118. package/dist/workflows/ir/schema-v4.js +4 -6
  119. package/dist/workflows/ir/schema.js +2 -5
  120. package/dist/workflows/parser.js +23 -26
  121. package/dist/workflows/source-files.js +8 -13
  122. package/dist/workflows/source-ir/schema.js +1 -19
  123. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  124. package/docs/migration/v0.9.1-to-v0.9.2.md +18 -4
  125. package/docs/reference/cli.md +3 -1
  126. package/docs/reference/data-and-telemetry.md +1 -0
  127. package/docs/reference/tasks.md +11 -0
  128. package/docs/reference/workflow-schema.md +24 -0
  129. package/package.json +2 -9
  130. package/dist/workflows/source-ir/compare.js +0 -17
package/CHANGELOG.md CHANGED
@@ -4,6 +4,316 @@ 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.2] - 2026-09-02
8
+
9
+ > **Adds state migration `026-proposals-strip-legacy-fragment-refs`.** The
10
+ > one-way caveat below applies to it as well: once this build opens
11
+ > `state.db`, 0.9.8-beta.1 and earlier refuse it with `unknown migration ID
12
+ > 026-proposals-strip-legacy-fragment-refs`.
13
+
14
+ ### Added
15
+
16
+ - **`akm health` reports data-dir disk usage** (#896). A `data-dir-usage`
17
+ advisory sums the data directory with a stat-only walk and warns when it is
18
+ more than 3× the three live databases (state.db, index.db, logs.db) or when
19
+ one top-level subdirectory holds more than half of it, naming that
20
+ subdirectory with its size and share (for example `backups/ is 70G (94% of
21
+ data dir)`). The walk stops after 100,000 entries and says so. Silent when
22
+ nothing looks wrong.
23
+
24
+ ### Fixed
25
+
26
+ - **`akm task sync` no longer spawns `npm root --global` on every call** (#901).
27
+ The npm-global-root probe behind `resolveAkmInvocation` is memoized for the
28
+ process, so a `task sync --rebind` cycle spawns npm at most once instead of
29
+ twice, and an installation that loops it every minute stops accumulating an
30
+ npm debug log per spawn.
31
+ - **A blocked v2 task now says how to convert it** (#902, #899). The
32
+ `argv-array-has-no-portable-shell-string` blocker printed by `akm migrate`
33
+ and the `TASK_SCHEMA_VERSION_UNSUPPORTED` read error now state that manual
34
+ conversion is required and name the rewrite (`command:` argv array →
35
+ `run:` string plus `shell:`). The full v2 → v4 field mapping is documented in
36
+ `docs/migration/v0.9.1-to-v0.9.2.md`.
37
+ - **Legacy `#fragment` proposal rows are repaired instead of warned about
38
+ forever** (#898). State migration 026 strips the retired export-fragment
39
+ selector from `proposals.ref` in place so the rows parse again, and an
40
+ unparseable proposal row now warns once per process instead of once per
41
+ read (`akm health --report` read the table seven times).
42
+
43
+ - **A no-op incremental `akm index` no longer costs minutes of CPU** (#900).
44
+ Two causes: the per-directory freshness check ran two full scans of the
45
+ `entries` table for every directory (O(directories × entries)), and every
46
+ file was read, hashed, and parsed before the freshness check decided the
47
+ directory was unchanged. The directory lookup now uses the existing
48
+ `file_path` index, and a stat-based gate over each directory's walked file
49
+ set skips unchanged directories before any file is read. On a synthetic
50
+ 800-directory, 4,000-entry corpus a no-op pass fell from ~37 s to under 1 s
51
+ of CPU with identical entries and search results. The persisted directory
52
+ fingerprint now covers every walked file and `index_dir_state` gains a
53
+ `row_count` column; an existing index.db drains each directory once more
54
+ after upgrading, then takes the fast path.
55
+
56
+ - **Task-migration snapshots are capped at the five most recent** (#897).
57
+ `akm migrate apply` writes one snapshot directory per run under
58
+ `backups/task-v3` and `backups/task-v4` and never pruned them; each apply
59
+ now keeps the five newest and removes the rest, the same policy config
60
+ backups already use. Nothing in the current code writes the legacy
61
+ `backups/migrations`, `manual`, `releases`, or `operations` directories,
62
+ so they are left alone; the new health advisory is what surfaces them.
63
+
64
+ ## [0.9.8-beta.1] - 2026-09-01
65
+
66
+ A cleanup and stabilization release: deletion of machinery that policed the
67
+ codebase's shape rather than its behaviour, and — because auditing for that
68
+ machinery meant reading the code closely — a run of real defects it had been
69
+ sitting on top of.
70
+
71
+ > **Upgrading is one-way for `state.db`.** This release adds migration
72
+ > `025-task-history-vocabulary-backfill`. Once any 0.9.8 command opens
73
+ > `state.db`, the ledger contains an ID that 0.9.7 does not know, and 0.9.7
74
+ > refuses to open it: `Refusing to open a database with a newer migration
75
+ > ledger: unknown migration ID 025-task-history-vocabulary-backfill`.
76
+ >
77
+ > The refusal is deliberate — an older binary must not write a database whose
78
+ > schema it cannot reason about — but the practical effect is that
79
+ > **downgrading to 0.9.7 requires restoring a `state.db` backup.** Commands
80
+ > that only read the derived index (`akm info`, `akm search`) keep working on
81
+ > 0.9.7; everything that touches `state.db` (`akm health`, `akm task`,
82
+ > `akm improve`, proposals) does not.
83
+ >
84
+ > Snapshot `state.db` before upgrading if you may need to go back:
85
+ >
86
+ > ```sh
87
+ > akm info --format json # confirm your data dir
88
+ > sqlite3 "$DATA_DIR/state.db" "VACUUM INTO '''state.db.pre-0.9.8.bak'''"
89
+ > ```
90
+
91
+ Two security holes, two search-correctness bugs, a locale-dependent hash, a
92
+ deletion shield that failed open, and sixteen places that answered a failure
93
+ with a confident wrong answer instead of an error.
94
+
95
+ ### Changed
96
+
97
+ - **`akm workflow plan` returns JSON by default**, like every other command
98
+ (#903). It was the one verb whose unmarked default was a human summary. That
99
+ exception cost a bespoke branch which could not reliably distinguish "no
100
+ format named" from "`--format json` named globally before the subcommand" —
101
+ citty parses each command level against its own argv, so the leaf read
102
+ `undefined` in both cases. Working around that meant reading the invocation
103
+ singleton, folding in a persisted `output.format`, and finally leaving a
104
+ resolved `"json"` on the text branch because it was indistinguishable from
105
+ "nothing configured" — which meant an explicit `--format json` silently did
106
+ nothing for anyone whose config already resolved to json. Deleted, along with
107
+ ~60 lines of comment justifying it. `--format text` still renders the same
108
+ summary through the same formatter; it is simply no longer the default.
109
+
110
+ ### Fixed
111
+
112
+ - **Historical state migrations are reachable where akm cannot reinstall
113
+ itself** (#895). A migration flagged `historical-destructive` is refused
114
+ during an ordinary open — it needs a verified safety copy, taken under the
115
+ migration writer lock, so an unattended `akm index` can never quietly drop
116
+ operator state. That guard is correct and unchanged. Its *remedy* was not:
117
+ the only code path that admitted the migration ran as a post-install step of
118
+ `akm upgrade`, behind an npm install. Where akm is installed globally by a
119
+ container image and the runtime user is unprivileged, that install fails
120
+ `EACCES` and throws long before the migration is reached, so `akm index
121
+ --full` was blocked with no supported way out — and the two obvious
122
+ workarounds are both wrong (upgrading as root installs a *newer* akm than the
123
+ image ships, which `config-version-shim` then fails closed against; deleting
124
+ `state.db` destroys task history, proposals, and lessons metadata, none of
125
+ which is derived). New `akm upgrade --state-only` applies pending state
126
+ migrations and installs nothing. Nothing about the migration needed the
127
+ network, root, or a new binary; coupling it to one was the bug. The safety
128
+ copy is still taken — this changes who may request the migration, not what it
129
+ does.
130
+
131
+ - **Two security holes closed.** A failed `git ls-remote` made
132
+ `verifyClonedRevision` a no-op, silently skipping the R-011 post-clone
133
+ revision-integrity check — the guard against a compromised mirror — so any
134
+ network blip disabled it without a word. And `scanExtractedFiles`, the
135
+ post-extraction TOCTOU path-traversal rescan, returned silently when a
136
+ directory could not be read: it passed clean in exactly the race it exists
137
+ to catch. Both now fail loudly.
138
+
139
+ - **`akm curate --type` ignored the type filter.** A set `--type` bypassed
140
+ `selectCuratedStashHits` entirely and did a raw slice of the hits as
141
+ received, which neither filtered by type nor ranked by score — so
142
+ `--type command` could return a skill, in arbitrary order. It now narrows
143
+ the candidate pool and runs the full curation pipeline over it. The test
144
+ that should have caught this passed by accident, because the off-type hit
145
+ happened to sit past the result limit.
146
+
147
+ - **Curate silently dropped relevant results.** An undocumented floor excluded
148
+ any hit scoring below `max(0.35, leader * 0.7)`. Measured against a real
149
+ 3,265-memory bundle, one query went from one result to four once it was
150
+ removed — the suppressed hits were the ones actually matching. Ordering is
151
+ unchanged: results were already sorted before the floor ran, so it could
152
+ only ever hide the tail.
153
+
154
+ - **A bundle-audit hash depended on the machine's locale.** `canonicalJson`
155
+ in `installed-stashes.ts` sorted keys with `localeCompare` — ICU-dependent —
156
+ and fed the result to `sha256Hex`, so the same object could hash differently
157
+ on two machines. Now codepoint order, matching the other implementations.
158
+
159
+ - **A deletion shield failed open.** `isHotCapturedMemory` returned false when
160
+ it could not read or parse a memory, marking exactly the memories it failed
161
+ to inspect as fair game for consolidate to merge or delete. It now fails
162
+ closed.
163
+
164
+ - **Sixteen places returned a confident wrong answer instead of an error.**
165
+ The pattern (#791) recurred across search, graph, indexing, and scheduling:
166
+ a corrupt or locked index reported "no matches"; a failed inline rebuild
167
+ surfaced as "Index is empty. Run 'akm index'" at exit 0; `akm show` reported
168
+ "0 related files" for an unreadable index; a DB failure in
169
+ `listProposalsReadOnly` returned `[]`, so dedup could not tell "no prior
170
+ proposal" from "database unreadable" and could re-mint an already-rejected
171
+ one; a permission fault on the bundle directory told the user to run
172
+ `akm bundle create`; and `launchd`'s `list()` returned `[]` when the
173
+ LaunchAgents directory could not be read, so task reconciliation concluded
174
+ there was nothing to manage. In each case the failure now surfaces.
175
+
176
+ - **Drifted copies of shared helpers.** `asNonEmptyString` trimmed in two
177
+ places and not in four others, so a whitespace-only session id decoded to
178
+ `" "` in some subsystems and `undefined` in others. `isPlainObject` had two
179
+ incompatible definitions under one name; the loose form would accept a
180
+ `Date` or class instance as a plain record. Both consolidated, along with
181
+ `toPosix` (15 copies, whose "avoid an import cycle" justification was false
182
+ in every case), `isRecord` (11), `compareCodePoints` (8), and five more
183
+ families.
184
+
185
+ - **Pruning a memory no longer leaves dangling belief edges (#885).**
186
+ `writeContradictEdge` — the hardened, test-covered `contradictedBy` writer —
187
+ had no production caller; its docstring named three that did not exist. The
188
+ live pass used a private near-copy that had drifted on exactly the two
189
+ behaviours the original was hardened for: it read the key with
190
+ `Array.isArray` only, so a SCALAR edge (live data the indexer accepts and
191
+ lint never flags) read as "no edges" and was overwritten out of existence;
192
+ and it set `beliefState: "contradicted"` unconditionally, promoting an
193
+ `archived` memory back up the ranking. The tests for both behaviours were
194
+ guarding dead code. The copy is gone.
195
+
196
+ `persistBeliefStateTransition` is deliberately NOT routed through the shared
197
+ primitive: it is a state-transition writer that replaces the edge list
198
+ wholesale and can clear it, which an append-only, never-weaken primitive
199
+ cannot express.
200
+
201
+ - **`git worktree` operations no longer fail on a busy machine (#891).** The
202
+ module's internal `GIT_TIMEOUT_MS` was a flat 30s with no margin, so a
203
+ healthy `git worktree remove` on a loaded host returned
204
+ `{ removed: false, error: "timed out after 30000ms" }`. Anyone running a
205
+ workflow with `isolation: worktree` alongside other git activity could hit
206
+ spurious create failures, or have a clean worktree wrongly retained as
207
+ unremovable. Raised to 120s, matching the existing `GIT_PUSH_TIMEOUT_MS`
208
+ precedent. Note `scripts/test-integration.sh` had already raised *bun's*
209
+ per-test timeout for this reason, but that never touched the production
210
+ subprocess timeout, which fires first.
211
+
212
+ - **`akm health`'s dead-link check no longer reports an unearned all-clear
213
+ (#892).** `checkDeadUrls` capped at 20 URLs across the whole bundle, plus an
214
+ undocumented `slice(0, 3)` per entry, then the caller logged "URL check
215
+ complete (0 dead)". A bundle with thousands of links got a clean bill of
216
+ health after twenty were examined. Both caps removed; every URL is checked
217
+ and a failed request surfaces instead of being swallowed.
218
+
219
+ - **`akm lint --fix` no longer breaks on gitignored drafts (#887).** The doc
220
+ linter walked `docs/` with no gitignore awareness, so unpublished drafts
221
+ under `docs/.pending/` were held to the published-docs contract —
222
+ `bun run lint` failed locally while CI, which never has those files, stayed
223
+ green.
224
+
225
+ ### Changed
226
+
227
+ - **Search no longer truncates long queries (#892).** `MAX_LEXICAL_QUERY_TOKENS
228
+ = 16` silently dropped every token past the sixteenth, and tokens are
229
+ collected in order, so the discarded half was the tail — for
230
+ natural-language input, usually where the discriminating words are. It also
231
+ fed ranking, so token-overlap scoring ran on the truncated set too. It was
232
+ unexplained in the code and in the commit that introduced it, and unreachable
233
+ from any flag, config key, or environment variable. Removed: the planner
234
+ handles 10,000 tokens in 9ms, so no performance cliff was being protected.
235
+
236
+ - **Content and memory bodies are no longer silently truncated.**
237
+ `MAX_CONTENT_CHARS` (100k, duplicated across 8 adapters) cut indexed content
238
+ so the tail of a long document was unsearchable; `MAX_BODY_CHARS` (4000) cut
239
+ the text sent for memory inference, so on a large-context engine the model
240
+ saw a fraction of the input while the derived memory looked complete. Both
241
+ removed.
242
+
243
+ - **GitHub Actions are pinned to commit SHAs (#768).** All 29 `uses:` steps
244
+ across every workflow, with the tag preserved in a trailing comment.
245
+
246
+ - **Gated CI runs on schedule, dispatch, and candidate tags only.** The
247
+ `detect-changes` job that selected suites by regex-matching a PR diff is
248
+ gone — its path patterns had gone stale and still named test files this
249
+ release moved or deleted, so it was silently under-selecting suites. Release
250
+ evidence is unchanged; the checklist always required an exact-SHA dispatch.
251
+
252
+ - **`akm-eval` in CI is now a determinism check only.** Its score gates are
253
+ removed. Measured before cutting: the baseline scored a perfect 1.0 against
254
+ a 0.75 gate, and seven of nine case types never ran — CI has no LLM and no
255
+ run history, so everything the eval exists to measure was skipped while the
256
+ job reported green. The harness itself is unchanged and remains a genuine
257
+ quality signal when run against a real bundle.
258
+
259
+ ### Removed
260
+
261
+ - **`akm health --clean-dead-residue` and every other compensating shim.**
262
+ A special-purpose flag on `health` that deleted files existed because
263
+ migrations had not finished their own job. Removing a superseded layout IS
264
+ migration, so `akm migrate status` now reports it and `akm migrate apply`
265
+ removes it. Four more of the same class went the same way: the D8
266
+ `task_history` vocabulary is rewritten once by a state migration instead of
267
+ re-decided at three read sites; the legacy `extraParams` config lift happens
268
+ once in `migrate apply` instead of silently on every load forever (an
269
+ unmigrated config now fails closed, naming the command to run); retired
270
+ `type:slug` xrefs are rewritten by `lint --fix`; and stale transaction
271
+ journals are recovered by `migrate apply` rather than reported by an
272
+ advisory that pointed at documentation while the recovery function it needed
273
+ sat with zero callers.
274
+
275
+ - **Dead code with no consumers.** An empty `{}` type threaded through two
276
+ functions as a parameter neither read, with a `{}` placeholder at the call
277
+ site; unused helpers, imports, and parameters throughout.
278
+
279
+ - **14 architecture ratchets, 15 golden/snapshot suites, 12 characterization
280
+ suites.** Function-size and import-cycle ratchets that failed on refactors
281
+ harming nothing; byte-for-byte snapshots "fixed" by regenerating; tests that
282
+ pinned "what the code does today" by definition.
283
+ - **11 of 14 lint scripts (-3,988 lines).** `lint-tests-isolation` alone was
284
+ 717 lines standing in front of `src/core/paths.ts`, which already throws
285
+ `TEST_ISOLATION_MISSING` at runtime. Also removed: the `gen-config-schema
286
+ --check` gate (`build` regenerates the schema anyway, so what ships is always
287
+ current), and `lint-devto-posts` (322 lines that were wired to nothing and
288
+ duplicated checks `devto-cli` performs itself). The three kept catch
289
+ user-visible problems: secrets must route through the resolver, no dead refs
290
+ in shipped assets, no docs teaching commands that do not exist.
291
+ - **Both `MIN_TESTS` floors** — arbitrary numbers whose only job was to fail
292
+ the suite when test count dropped.
293
+ - **`release-workflow-syntax.yml`** — a workflow that ran actionlint over the
294
+ other workflows. A broken workflow file already fails at GitHub.
295
+ - **Dead `$STASH/.akm` residue is now reportable (#889).** `akm health` gains a
296
+ read-only `stash-dead-residue` advisory naming each stale path and its size;
297
+ deletion is gated behind an explicit `akm health --clean-dead-residue`. On a
298
+ real bundle 82% of `.akm` (135 MB) was pre-0.9.0 leftovers no code reads.
299
+
300
+ ### Testing
301
+
302
+ - Test isolation grandfather list drained 58 -> 2 (#785); the two retained
303
+ entries are the helper-definitions file and a meta-test of the guard itself.
304
+ - The test tree now has a stated unit/integration rule and ~250 files were
305
+ reclassified by evidence — real DB, network, or process spawn — rather than
306
+ by filename (#786).
307
+ - 31 status-only `not.toBe(0)` assertions replaced with exact exit codes and
308
+ machine-readable error codes, every value observed rather than inferred
309
+ (#787).
310
+ - Near-duplicate clusters drained (#788); real-server timing flakes removed or
311
+ made deterministic (#789).
312
+ - Kept deliberately: `registry-network-boundary.test.ts`, which enforces that
313
+ only `pinned-transport.ts` may import `node:http`/`node:https` — the control
314
+ that stops a raw request bypassing address-pinned fetching. No lint script
315
+ covers that boundary.
316
+
7
317
  ## [0.9.7] - 2026-08-31
8
318
 
9
319
  ### Added
@@ -48,28 +48,9 @@
48
48
  import fs from "node:fs";
49
49
  import path from "node:path";
50
50
  import dotenv from "dotenv";
51
- import { writeFileAtomic } from "../../core/common.js";
51
+ import { scanEnvKeyNames, writeFileAtomic } from "../../core/common.js";
52
52
  import { sensitiveMarkerPath } from "./marker-path.js";
53
- /** Matches a KEY=value assignment line, capturing only the key. */
54
- const ASSIGN_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
55
- /** Scan lines and return KEY names in file order, without duplicates. */
56
- export function scanEnvKeyNames(text) {
57
- const keys = [];
58
- const seen = new Set();
59
- for (const line of text.split(/\r?\n/)) {
60
- const m = line.match(ASSIGN_RE);
61
- if (!m)
62
- continue;
63
- const key = m[1];
64
- if (!key)
65
- continue;
66
- if (seen.has(key))
67
- continue;
68
- seen.add(key);
69
- keys.push(key);
70
- }
71
- return keys;
72
- }
53
+ export { scanEnvKeyNames };
73
54
  /**
74
55
  * Read and return ONLY non-secret metadata: key names.
75
56
  *
@@ -706,29 +706,4 @@ export const HEALTH_CHECKS = [
706
706
  };
707
707
  },
708
708
  },
709
- {
710
- // Item 4: stale durable-transaction journals under $DATA/txn (stranded
711
- // recovery state seen twice in a real 0.9 migration) had zero health
712
- // visibility. `count`/`unreadable` already exclude journals younger than
713
- // the sweeper's grace period (a currently-running operation), so any
714
- // nonzero count here is a real leftover.
715
- name: "stale-txn-journals",
716
- channel: "advisory",
717
- run: (ctx) => {
718
- const s = ctx.staleTxnJournals;
719
- const warn = s.count > 0;
720
- const unreadablePart = s.unreadable > 0 ? `, ${s.unreadable} unreadable` : "";
721
- const agePart = s.oldestAgeMs !== null ? `, oldest ${Math.round(s.oldestAgeMs / 60000)}m old` : "";
722
- return {
723
- name: "stale-txn-journals",
724
- kind: "deterministic",
725
- status: warn ? "warn" : "pass",
726
- confidence: "high",
727
- message: warn
728
- ? `${s.count} stale transaction journal(s) found under ${s.dir}${unreadablePart}${agePart} — see docs/migration/v0.9.0-troubleshooting.md for journal reconciliation steps.`
729
- : "No stale transaction journals found.",
730
- evidence: { dir: s.dir, count: s.count, unreadable: s.unreadable, oldestAgeMs: s.oldestAgeMs },
731
- };
732
- },
733
- },
734
709
  ];
@@ -0,0 +1,165 @@
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
+ * `data-dir-usage` advisory for `akm health` (#896).
6
+ *
7
+ * A real environment's `$XDG_DATA_HOME/akm` grew to 74 GB with none of the
8
+ * ~20 health checks saying a word about disk. 70 of the 74 GB turned out to
9
+ * be `backups/` (unpruned migration snapshots, see #897); the live working
10
+ * set (state.db + index.db + logs.db) was ~4.2 GB. Naming the largest
11
+ * top-level contributor and its share of the total is most of the value —
12
+ * `backups/ is 70G (94% of data dir)` is self-diagnosing where "akm health
13
+ * says nothing" is not.
14
+ *
15
+ * Best-effort and read-only: a plain recursive `fs` stat walk over the data
16
+ * dir, no `du` shell-out. Silent (returns `undefined`) whenever nothing
17
+ * looks wrong, matching the stash-exposure/type-directory-check house
18
+ * pattern — this is not a "always show a pass line" check.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ /**
23
+ * Warn when the data dir's total size is more than this many times the
24
+ * combined size of the three live databases (state.db + index.db +
25
+ * logs.db). Chosen so a healthy install (backups roughly comparable to the
26
+ * live working set) stays quiet, while an order-of-magnitude blowup like
27
+ * the 74 GB/4.2 GB (~17x) incident trips it.
28
+ */
29
+ const DATA_DIR_BLOAT_RATIO_THRESHOLD = 3;
30
+ /**
31
+ * Warn when a single top-level subdirectory accounts for more than this
32
+ * percentage of the data dir's total size — the "one thing ate the disk"
33
+ * signal (94% for `backups/` in the incident).
34
+ */
35
+ const DOMINANT_SUBDIR_PERCENT_THRESHOLD = 50;
36
+ /**
37
+ * Cap on the number of filesystem entries the recursive size walk will
38
+ * `stat`. A 70 GB tree of a few thousand backup copies is cheap to walk
39
+ * (stat-only), but a data dir polluted with hundreds of thousands of small
40
+ * files (task logs, npm logs) must not make `akm health` slow. Past this
41
+ * cap the walk stops descending further and the advisory says its size
42
+ * figures are a lower bound.
43
+ */
44
+ const MAX_WALK_ENTRIES = 100_000;
45
+ const LIVE_DB_FILES = ["state.db", "index.db", "logs.db"];
46
+ /**
47
+ * Recursively sum file sizes under `root` (stat-only, symlinks not
48
+ * followed so a cyclic or huge-target symlink can't blow up the walk).
49
+ * `budget` is a shared mutable counter across the whole tree so the
50
+ * `MAX_WALK_ENTRIES` cap applies to the walk as a whole, not per-branch.
51
+ */
52
+ function sizeOfPath(root, budget) {
53
+ let stat;
54
+ try {
55
+ stat = fs.lstatSync(root);
56
+ }
57
+ catch {
58
+ return { bytes: 0, truncated: false };
59
+ }
60
+ if (stat.isSymbolicLink())
61
+ return { bytes: 0, truncated: false };
62
+ if (!stat.isDirectory())
63
+ return { bytes: stat.size, truncated: false };
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(root, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ return { bytes: 0, truncated: false };
70
+ }
71
+ let bytes = 0;
72
+ let truncated = false;
73
+ for (const entry of entries) {
74
+ if (budget.remaining <= 0) {
75
+ truncated = true;
76
+ break;
77
+ }
78
+ budget.remaining--;
79
+ const sub = sizeOfPath(path.join(root, entry.name), budget);
80
+ bytes += sub.bytes;
81
+ if (sub.truncated)
82
+ truncated = true;
83
+ }
84
+ return { bytes, truncated };
85
+ }
86
+ /** `1610612736` -> `"1.5G"`. Values under 10 in a unit keep one decimal; 10+ round to an integer. */
87
+ function formatBytes(bytes) {
88
+ const units = ["B", "K", "M", "G", "T"];
89
+ let value = bytes;
90
+ let unit = 0;
91
+ while (value >= 1024 && unit < units.length - 1) {
92
+ value /= 1024;
93
+ unit++;
94
+ }
95
+ const rendered = unit === 0 ? String(Math.round(value)) : value < 10 ? value.toFixed(1) : String(Math.round(value));
96
+ return `${rendered}${units[unit]}`;
97
+ }
98
+ /**
99
+ * Build the `data-dir-usage` advisory, or `undefined` when the data dir is
100
+ * missing/empty/unreadable or its size looks unremarkable (neither
101
+ * threshold trips). `dataDir` is the caller-resolved `getDataDir()` path —
102
+ * this module never resolves paths or reads env itself.
103
+ */
104
+ export function collectDataDirUsageAdvisory(dataDir) {
105
+ let topEntries;
106
+ try {
107
+ topEntries = fs.readdirSync(dataDir, { withFileTypes: true });
108
+ }
109
+ catch {
110
+ return undefined; // no data dir yet — nothing to report.
111
+ }
112
+ const budget = { remaining: MAX_WALK_ENTRIES };
113
+ let totalBytes = 0;
114
+ let truncated = false;
115
+ const sizes = new Map();
116
+ for (const entry of topEntries) {
117
+ const size = sizeOfPath(path.join(dataDir, entry.name), budget);
118
+ totalBytes += size.bytes;
119
+ if (size.truncated)
120
+ truncated = true;
121
+ sizes.set(entry.name, { bytes: size.bytes, isDirectory: entry.isDirectory() });
122
+ }
123
+ if (totalBytes === 0)
124
+ return undefined;
125
+ const subdirs = [...sizes]
126
+ .filter(([, size]) => size.isDirectory)
127
+ .map(([name, size]) => ({ name, bytes: size.bytes, percent: (size.bytes / totalBytes) * 100 }))
128
+ .sort((a, b) => b.bytes - a.bytes);
129
+ const largest = subdirs[0];
130
+ const liveDbBreakdown = Object.fromEntries(LIVE_DB_FILES.map((f) => [f, sizes.get(f)?.bytes ?? 0]));
131
+ const liveDbBytes = Object.values(liveDbBreakdown).reduce((a, b) => a + b, 0);
132
+ const ratio = liveDbBytes > 0 ? totalBytes / liveDbBytes : undefined;
133
+ const bloatWarn = ratio !== undefined && ratio > DATA_DIR_BLOAT_RATIO_THRESHOLD;
134
+ const dominantWarn = largest !== undefined && largest.percent > DOMINANT_SUBDIR_PERCENT_THRESHOLD;
135
+ if (!bloatWarn && !dominantWarn)
136
+ return undefined;
137
+ const parts = [`data dir is ${formatBytes(totalBytes)} at ${dataDir}`];
138
+ if (largest) {
139
+ parts.push(`${largest.name}/ is ${formatBytes(largest.bytes)} (${Math.round(largest.percent)}% of data dir)`);
140
+ }
141
+ if (ratio !== undefined) {
142
+ parts.push(`live databases (${LIVE_DB_FILES.join("+")}) total ${formatBytes(liveDbBytes)}, ~${ratio.toFixed(1)}x smaller`);
143
+ }
144
+ if (truncated) {
145
+ parts.push(`size figures are a lower bound — the walk stopped after ${MAX_WALK_ENTRIES} entries`);
146
+ }
147
+ const message = `${parts.join("; ")}.`;
148
+ return {
149
+ name: "data-dir-usage",
150
+ kind: "deterministic",
151
+ status: "warn",
152
+ confidence: "medium",
153
+ message,
154
+ evidence: {
155
+ dataDir,
156
+ totalBytes,
157
+ liveDbBytes,
158
+ liveDbBreakdown,
159
+ largestSubdir: largest,
160
+ ratio,
161
+ walkBounded: truncated,
162
+ maxWalkEntries: MAX_WALK_ENTRIES,
163
+ },
164
+ };
165
+ }
@@ -33,42 +33,16 @@ export function taskFailureDetail(row) {
33
33
  }
34
34
  }
35
35
  /**
36
- * D8 read-boundary predicate (spec docs/plans/specs/p1b-model-extraction.md
37
- * §5.3) for `akm health`'s `agentFailureRate`: true for a `task_history` row
38
- * that represents a prepared command (agent/LLM) result, across both
39
- * vocabularies. Mirrors src/tasks/run/task-history.ts's
40
- * `taskHistoryRowToResult` read mapping:
41
- * - NEW rows mark themselves with metadata `targetVocab: 2` and store
42
- * `target_kind: "command"` for the agent/LLM arm.
43
- * - LEGACY rows (no marker, written before P1b's F-2 re-code) stored
44
- * `target_kind: "prompt"` for the same arm — and `"command"` for the
45
- * UNRELATED native shell/script arm, which must NOT be counted here.
46
- * So an unmarked `"command"` row is a legacy shell/script run, not an
47
- * agent/LLM one; a marked `"command"` row (or an unmarked `"prompt"` row) is.
36
+ * `akm health`'s `agentFailureRate` predicate: true for a `task_history` row
37
+ * that represents a prepared command (agent/LLM) result. `target_kind` is
38
+ * read in the current (post-D8) vocabulary the
39
+ * `025-task-history-vocabulary-backfill` state migration rewrites every
40
+ * legacy-vocabulary row (which stored the agent/LLM arm as `"prompt"` and
41
+ * the native shell/script arm as `"command"`) before this ever runs against
42
+ * it, so a `"command"` row here is unambiguously the agent/LLM arm.
48
43
  */
49
44
  export function isAgentTaskHistoryRow(row) {
50
- // Check target_kind BEFORE decoding metadata: some rows in the wild
51
- // (e.g. improve-pipeline task_history rows, target_kind "improve") carry
52
- // metadata_json that predates the metadataVersion:2 shape entirely, and
53
- // decodeTaskHistoryMetadata throws on that — exactly like the pre-fix
54
- // `target_kind === "prompt"` filter, which never called it for a row this
55
- // function isn't going to count anyway. Only decode for the two target
56
- // kinds this predicate can return true for.
57
- if (row.target_kind !== "command" && row.target_kind !== "prompt")
58
- return false;
59
- // An undecodable metadata_json is by definition unmarked: pre-P1b rows can
60
- // carry shapes decodeTaskHistoryMetadata rejects, and `akm health` must
61
- // classify them as legacy rather than throw (round-3 review advisory).
62
- let marked = false;
63
- try {
64
- marked = decodeTaskHistoryMetadata(row.metadata_json).targetVocab === 2;
65
- }
66
- catch {
67
- marked = false;
68
- }
69
- if (row.target_kind === "command")
70
- return marked;
71
- return !marked;
45
+ return row.target_kind === "command";
72
46
  }
73
47
  function createUnknownImproveMetrics() {
74
48
  return {
@@ -148,10 +148,6 @@ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Da
148
148
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
149
149
  const activeRows = taskRows.filter((row) => row.status === "active" && row.completed_at === null);
150
150
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
151
- // D8 (spec §5.3): a marked "command" row or a legacy (unmarked) "prompt"
152
- // row is the agent/LLM arm; an unmarked "command" row is the legacy
153
- // native shell/script arm and must not be counted here (see
154
- // isAgentTaskHistoryRow's header comment for the full mapping).
155
151
  const agentRows = taskRows.filter((row) => isAgentTaskHistoryRow(row));
156
152
  const agentFailures = agentRows.filter((row) => {
157
153
  const detail = taskFailureDetail(row);