akm-cli 0.9.8-beta.2 → 0.9.8-beta.3

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ 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
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
+
7
49
  ## [0.9.8-beta.2] - 2026-09-02
8
50
 
9
51
  > **Adds state migration `026-proposals-strip-legacy-fragment-refs`.** The
@@ -42,7 +42,22 @@ const DOMINANT_SUBDIR_PERCENT_THRESHOLD = 50;
42
42
  * figures are a lower bound.
43
43
  */
44
44
  const MAX_WALK_ENTRIES = 100_000;
45
+ /**
46
+ * Below this the data dir is not worth an opinion. The advisory exists for
47
+ * disk blowups (74 GB in the incident); on a small directory a ratio is
48
+ * arithmetic noise, and akm's own housekeeping can dominate it outright.
49
+ */
50
+ const MIN_TOTAL_BYTES_TO_REPORT = 1_000_000_000;
45
51
  const LIVE_DB_FILES = ["state.db", "index.db", "logs.db"];
52
+ /**
53
+ * SQLite writes `-wal` and `-shm` beside each database. They are part of the
54
+ * live working set, not overhead sitting next to it, so they must count as
55
+ * live: on a fresh install the WAL is most of the data dir, and leaving it
56
+ * out of the denominator made an empty install report a ~126x ratio.
57
+ */
58
+ function liveDbBytesFor(name, sizes) {
59
+ return ((sizes.get(name)?.bytes ?? 0) + (sizes.get(`${name}-wal`)?.bytes ?? 0) + (sizes.get(`${name}-shm`)?.bytes ?? 0));
60
+ }
46
61
  /**
47
62
  * Recursively sum file sizes under `root` (stat-only, symlinks not
48
63
  * followed so a cyclic or huge-target symlink can't blow up the walk).
@@ -127,9 +142,11 @@ export function collectDataDirUsageAdvisory(dataDir) {
127
142
  .map(([name, size]) => ({ name, bytes: size.bytes, percent: (size.bytes / totalBytes) * 100 }))
128
143
  .sort((a, b) => b.bytes - a.bytes);
129
144
  const largest = subdirs[0];
130
- const liveDbBreakdown = Object.fromEntries(LIVE_DB_FILES.map((f) => [f, sizes.get(f)?.bytes ?? 0]));
145
+ const liveDbBreakdown = Object.fromEntries(LIVE_DB_FILES.map((f) => [f, liveDbBytesFor(f, sizes)]));
131
146
  const liveDbBytes = Object.values(liveDbBreakdown).reduce((a, b) => a + b, 0);
132
147
  const ratio = liveDbBytes > 0 ? totalBytes / liveDbBytes : undefined;
148
+ if (totalBytes < MIN_TOTAL_BYTES_TO_REPORT)
149
+ return undefined;
133
150
  const bloatWarn = ratio !== undefined && ratio > DATA_DIR_BLOAT_RATIO_THRESHOLD;
134
151
  const dominantWarn = largest !== undefined && largest.percent > DOMINANT_SUBDIR_PERCENT_THRESHOLD;
135
152
  if (!bloatWarn && !dominantWarn)
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
5
5
  import { resolveStashDir } from "../core/common.js";
6
+ import { resetConfigCache } from "../core/config/config.js";
6
7
  import { ConfigError } from "../core/errors.js";
7
8
  import { getConfigPath } from "../core/paths.js";
8
9
  import { applyConfigExtraParamsLift, findConfigExtraParamsLift } from "./migrate/config-extra-params.js";
@@ -94,6 +95,29 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
94
95
  // No configured bundle means there is no stash to scan — an empty domain,
95
96
  // not an error — so migrate still works before `akm bundle create`. Any
96
97
  // OTHER ConfigError propagates.
98
+ const configPath = getConfigPath();
99
+ const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
100
+ // The config lift runs BEFORE anything that loads config. A config still
101
+ // carrying legacy extraParams keys fails `loadConfig` closed, and the error
102
+ // it fails with names `akm migrate apply` as the remedy -- but both
103
+ // `resolveStashDir` below and the task migrator itself load config, so that
104
+ // remedy could never reach the lift that fixes it. Applying it first is what
105
+ // makes the advice true. `resetConfigCache` so every load below sees the
106
+ // rewritten file rather than the rejected one.
107
+ const configExtraParams = applyResidue
108
+ ? applyConfigExtraParamsLift(configPath)
109
+ : { pending: findConfigExtraParamsLift(configPath) };
110
+ if (applyResidue && configExtraParams.applied)
111
+ resetConfigCache();
112
+ // status and --dry-run cannot rewrite the file, so a pending lift still
113
+ // blocks every config load below. Report it as the blocker rather than
114
+ // letting the operator hit the same circular error again.
115
+ const pendingLift = applyResidue ? undefined : configExtraParams.pending;
116
+ if (pendingLift && pendingLift.lifted.length > 0) {
117
+ output(command, { status: "blocked", blockers: pendingLift.lifted, configExtraParams });
118
+ process.exitCode = EXIT_CODES.GENERAL;
119
+ return;
120
+ }
97
121
  let stashDir;
98
122
  try {
99
123
  stashDir = resolveStashDir();
@@ -102,8 +126,6 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
102
126
  if (!(error instanceof ConfigError) || error.code !== "STASH_DIR_NOT_FOUND")
103
127
  throw error;
104
128
  }
105
- const configPath = getConfigPath();
106
- const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
107
129
  const first = await callMigrateTool(genOneArgs, runTool);
108
130
  if (first.status !== EXIT_CODES.SUCCESS && first.status !== EXIT_CODES.GENERAL) {
109
131
  process.exitCode = first.status;
@@ -133,9 +155,6 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
133
155
  ? { recovered: await recoverStaleTxns(stashDir) }
134
156
  : { pending: findStaleTxnEntries(stashDir) },
135
157
  };
136
- const configExtraParams = applyResidue
137
- ? applyConfigExtraParamsLift(configPath)
138
- : { pending: findConfigExtraParamsLift(configPath) };
139
158
  output(command, { ...combined, ...stashSections, configExtraParams });
140
159
  if (combined.status === "blocked")
141
160
  process.exitCode = EXIT_CODES.GENERAL;
@@ -17,8 +17,10 @@
17
17
  * `getCachedDirState` is the pre-drain gate (#900): a directory whose walked
18
18
  * fingerprint still matches its row is skipped before any file is read.
19
19
  */
20
+ import { createHash } from "node:crypto";
20
21
  import fs from "node:fs";
21
22
  import path from "node:path";
23
+ import { compareCodePoints } from "../../core/common.js";
22
24
  import { getEntriesByDir } from "../../storage/repositories/index-entries-repository.js";
23
25
  import { getIndexDirState } from "../../storage/repositories/index-meta-repository.js";
24
26
  /**
@@ -44,9 +46,7 @@ export function getDirIndexState(db, dirPath, files, builtAtMs, indexVariant = "
44
46
  return { stale: false, reason: { kind: "unchanged" }, persistedRowCount: prevEntries.length };
45
47
  }
46
48
  const cachedState = getIndexDirState(db, dirPath);
47
- if (cachedState &&
48
- cachedState.fileSetHash === fingerprint.fileSetHash &&
49
- cachedState.fileMtimeMaxMs === fingerprint.fileMtimeMaxMs) {
49
+ if (cachedState && cachedState.fileSetHash === fingerprint.fileSetHash) {
50
50
  return {
51
51
  stale: false,
52
52
  reason: { kind: "cached-zero-row-state", detail: cachedState.reason },
@@ -68,11 +68,8 @@ export function getDirIndexState(db, dirPath, files, builtAtMs, indexVariant = "
68
68
  */
69
69
  export function getCachedDirState(db, dirPath, files, builtAtMs, priorDirsChanged, indexVariant, fingerprint) {
70
70
  const cached = getIndexDirState(db, dirPath);
71
- if (!cached ||
72
- cached.fileSetHash !== fingerprint.fileSetHash ||
73
- cached.fileMtimeMaxMs !== fingerprint.fileMtimeMaxMs) {
71
+ if (!cached || cached.fileSetHash !== fingerprint.fileSetHash)
74
72
  return undefined;
75
- }
76
73
  if (cached.rowCount !== undefined && cached.rowCount > 0) {
77
74
  return { stale: false, reason: { kind: "unchanged-precheck" }, persistedRowCount: cached.rowCount };
78
75
  }
@@ -89,21 +86,42 @@ export function canUseIncrementalSkip(state, priorDirsChanged) {
89
86
  state.reason.detail === "deduped-zero-row");
90
87
  }
91
88
  export function computeDirFingerprint(_dirPath, files, indexVariant = "") {
92
- const normalizedFiles = [...new Set(files.map((file) => path.basename(file)))].sort();
89
+ // One `statSync` per file the same call this function has always made — but
90
+ // every field it returns that can witness a change is kept, per file, instead
91
+ // of being collapsed into a single max.
92
+ //
93
+ // `Math.max` over mtimes discarded everything except the newest file, so an
94
+ // edit to any other file landed below the max and was invisible; and mtime
95
+ // alone is writable by ordinary tooling (`touch -r`, `rsync --times`,
96
+ // `cp -p`, archive extraction), so a restored timestamp hid an edit outright.
97
+ // Size catches any length-changing edit; ctime catches the rest, because
98
+ // utimes(2) cannot hold the inode's change time back.
99
+ //
100
+ // This is still a heuristic: ctime also moves on metadata-only changes
101
+ // (chmod/chown) and after copying a tree, which costs an unnecessary rescan.
102
+ // That direction is safe — extra work, never stale content.
103
+ const entries = [];
93
104
  let fileMtimeMaxMs = 0;
94
- for (const file of files) {
105
+ for (const file of [...new Set(files)].sort(compareCodePoints)) {
106
+ const name = path.basename(file);
95
107
  try {
96
- fileMtimeMaxMs = Math.max(fileMtimeMaxMs, fs.statSync(file).mtimeMs);
108
+ // `bigint: true` is the same syscall but reports nanoseconds. Millisecond
109
+ // floats would let an edit made inside the same millisecond as the last
110
+ // run's stat land on an identical digest.
111
+ const stat = fs.statSync(file, { bigint: true });
112
+ fileMtimeMaxMs = Math.max(fileMtimeMaxMs, Number(stat.mtimeMs));
113
+ entries.push(`${name}\0${stat.size}\0${stat.mtimeNs}\0${stat.ctimeNs}`);
97
114
  }
98
115
  catch {
99
- fileMtimeMaxMs = Number.POSITIVE_INFINITY;
100
- break;
116
+ // Unreadable or vanished: record it as such so the digest differs from
117
+ // any run where the file could be read, forcing a rescan.
118
+ entries.push(`${name}\0unreadable`);
101
119
  }
102
120
  }
103
- return {
104
- fileSetHash: [indexVariant, ...normalizedFiles].join("\0"),
105
- fileMtimeMaxMs,
106
- };
121
+ const digest = createHash("sha256")
122
+ .update([indexVariant, ...entries].join("\n"), "utf8")
123
+ .digest("hex");
124
+ return { fileSetHash: digest, fileMtimeMaxMs };
107
125
  }
108
126
  function getDirStaleReason(_dirPath, currentFiles, previousEntries, builtAtMs) {
109
127
  const prevFileNames = new Set(previousEntries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.8-beta.2",
3
+ "version": "0.9.8-beta.3",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [