akm-cli 0.9.8-beta.2 → 0.9.8

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,73 +4,25 @@ 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
7
+ ## [0.9.8] - 2026-09-02
65
8
 
66
9
  A cleanup and stabilization release: deletion of machinery that policed the
67
10
  codebase's shape rather than its behaviour, and — because auditing for that
68
11
  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
12
+ sitting on top of. Two security holes, two search-correctness bugs, a
13
+ locale-dependent hash, a deletion shield that failed open, and sixteen places
14
+ that answered a failure with a confident wrong answer instead of an error.
15
+
16
+ Then a second round, from verifying the release against a real 23,865-entry
17
+ environment: a no-op incremental index costing ~21 CPU-minutes, legacy proposal
18
+ rows that could not be repaired, an npm probe spawning on every scheduler tick,
19
+ a blocked task migration that named no remedy, and a data directory that could
20
+ reach 74 GB with nothing reporting it.
21
+
22
+ > **Upgrading is one-way for `state.db`.** This release adds two migrations,
23
+ > `025-task-history-vocabulary-backfill` and
24
+ > `026-proposals-strip-legacy-fragment-refs`. Once any 0.9.8 command opens
25
+ > `state.db`, its ledger contains IDs that 0.9.7 does not know, and 0.9.7
74
26
  > refuses to open it: `Refusing to open a database with a newer migration
75
27
  > ledger: unknown migration ID 025-task-history-vocabulary-backfill`.
76
28
  >
@@ -85,12 +37,22 @@ sitting on top of.
85
37
  >
86
38
  > ```sh
87
39
  > akm info --format json # confirm your data dir
88
- > sqlite3 "$DATA_DIR/state.db" "VACUUM INTO '''state.db.pre-0.9.8.bak'''"
40
+ > sqlite3 "$DATA_DIR/state.db" "VACUUM INTO 'state.db.pre-0.9.8.bak'"
89
41
  > ```
90
42
 
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.
43
+ > **`index.db` rescans once.** The per-directory freshness fingerprint changed
44
+ > shape, so the first `akm index` after upgrading re-reads every directory and
45
+ > then returns to the fast path. Nothing is lost; the index is derived.
46
+
47
+ ### Added
48
+
49
+ - **`akm health` reports data-dir disk usage** (#896). A `data-dir-usage`
50
+ advisory sums the data directory with a stat-only walk and warns when it is
51
+ more than 3× the three live databases (state.db, index.db, logs.db) or when
52
+ one top-level subdirectory holds more than half of it, naming that
53
+ subdirectory with its size and share (for example `backups/ is 70G (94% of
54
+ data dir)`). The walk stops after 100,000 entries and says so. Silent when
55
+ nothing looks wrong.
94
56
 
95
57
  ### Changed
96
58
 
@@ -107,6 +69,39 @@ with a confident wrong answer instead of an error.
107
69
  ~60 lines of comment justifying it. `--format text` still renders the same
108
70
  summary through the same formatter; it is simply no longer the default.
109
71
 
72
+
73
+ - **Search no longer truncates long queries (#892).** `MAX_LEXICAL_QUERY_TOKENS
74
+ = 16` silently dropped every token past the sixteenth, and tokens are
75
+ collected in order, so the discarded half was the tail — for
76
+ natural-language input, usually where the discriminating words are. It also
77
+ fed ranking, so token-overlap scoring ran on the truncated set too. It was
78
+ unexplained in the code and in the commit that introduced it, and unreachable
79
+ from any flag, config key, or environment variable. Removed: the planner
80
+ handles 10,000 tokens in 9ms, so no performance cliff was being protected.
81
+
82
+ - **Content and memory bodies are no longer silently truncated.**
83
+ `MAX_CONTENT_CHARS` (100k, duplicated across 8 adapters) cut indexed content
84
+ so the tail of a long document was unsearchable; `MAX_BODY_CHARS` (4000) cut
85
+ the text sent for memory inference, so on a large-context engine the model
86
+ saw a fraction of the input while the derived memory looked complete. Both
87
+ removed.
88
+
89
+ - **GitHub Actions are pinned to commit SHAs (#768).** All 29 `uses:` steps
90
+ across every workflow, with the tag preserved in a trailing comment.
91
+
92
+ - **Gated CI runs on schedule, dispatch, and candidate tags only.** The
93
+ `detect-changes` job that selected suites by regex-matching a PR diff is
94
+ gone — its path patterns had gone stale and still named test files this
95
+ release moved or deleted, so it was silently under-selecting suites. Release
96
+ evidence is unchanged; the checklist always required an exact-SHA dispatch.
97
+
98
+ - **`akm-eval` in CI is now a determinism check only.** Its score gates are
99
+ removed. Measured before cutting: the baseline scored a perfect 1.0 against
100
+ a 0.75 gate, and seven of nine case types never ran — CI has no LLM and no
101
+ run history, so everything the eval exists to measure was skipped while the
102
+ job reported green. The harness itself is unchanged and remains a genuine
103
+ quality signal when run against a real bundle.
104
+
110
105
  ### Fixed
111
106
 
112
107
  - **Historical state migrations are reachable where akm cannot reinstall
@@ -222,39 +217,83 @@ with a confident wrong answer instead of an error.
222
217
  `bun run lint` failed locally while CI, which never has those files, stayed
223
218
  green.
224
219
 
225
- ### Changed
226
220
 
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.
221
+ - **`akm task sync` no longer spawns `npm root --global` on every call** (#901).
222
+ The npm-global-root probe behind `resolveAkmInvocation` is memoized for the
223
+ process, so a `task sync --rebind` cycle spawns npm at most once instead of
224
+ twice, and an installation that loops it every minute stops accumulating an
225
+ npm debug log per spawn.
226
+ - **A blocked v2 task now says how to convert it** (#902, #899). The
227
+ `argv-array-has-no-portable-shell-string` blocker printed by `akm migrate`
228
+ and the `TASK_SCHEMA_VERSION_UNSUPPORTED` read error now state that manual
229
+ conversion is required and name the rewrite (`command:` argv array →
230
+ `run:` string plus `shell:`). The full v2 → v4 field mapping is documented in
231
+ `docs/migration/v0.9.1-to-v0.9.2.md`.
232
+ - **Legacy `#fragment` proposal rows are repaired instead of warned about
233
+ forever** (#898). State migration 026 strips the retired export-fragment
234
+ selector from `proposals.ref` in place so the rows parse again, and an
235
+ unparseable proposal row now warns once per process instead of once per
236
+ read (`akm health --report` read the table seven times).
235
237
 
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.
238
+ - **A no-op incremental `akm index` no longer costs minutes of CPU** (#900).
239
+ Two causes: the per-directory freshness check ran two full scans of the
240
+ `entries` table for every directory (O(directories × entries)), and every
241
+ file was read, hashed, and parsed before the freshness check decided the
242
+ directory was unchanged. The directory lookup now uses the existing
243
+ `file_path` index, and a stat-based gate over each directory's walked file
244
+ set skips unchanged directories before any file is read. On a synthetic
245
+ 800-directory, 4,000-entry corpus a no-op pass fell from ~37 s to under 1 s
246
+ of CPU with identical entries and search results. The persisted directory
247
+ fingerprint now covers every walked file and `index_dir_state` gains a
248
+ `row_count` column; an existing index.db drains each directory once more
249
+ after upgrading, then takes the fast path.
242
250
 
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.
251
+ - **Task-migration snapshots are capped at the five most recent** (#897).
252
+ `akm migrate apply` writes one snapshot directory per run under
253
+ `backups/task-v3` and `backups/task-v4` and never pruned them; each apply
254
+ now keeps the five newest and removes the rest, the same policy config
255
+ backups already use. Nothing in the current code writes the legacy
256
+ `backups/migrations`, `manual`, `releases`, or `operations` directories,
257
+ so they are left alone; the new health advisory is what surfaces them.
245
258
 
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
259
 
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.
260
+ - **The incremental index no longer misses an edit whose timestamp did not move
261
+ forward.** The per-directory freshness check summarised a directory as its
262
+ file-name set plus the single newest mtime, which lost two kinds of change.
263
+ An edit to any file other than the newest one landed below that maximum and
264
+ was invisible even though its own mtime changed so a restore, checkout, or
265
+ archive extraction that stamped a plausible older date left stale content in
266
+ the index. And because mtime is writable by ordinary tooling (`touch -r`,
267
+ `rsync --times`, `cp -p`), an edit with a restored timestamp was invisible
268
+ outright. The directory is now digested per file over
269
+ `(basename, size, mtime, ctime)` at nanosecond resolution. It is the same one
270
+ `stat` call per file, so the incremental fast path costs what it did before.
271
+ Both gaps predate 0.9.8 and applied to every earlier release.
272
+
273
+ Trade-off worth knowing: `ctime` also moves on metadata-only changes such as
274
+ `chmod`, and after copying a tree, so those now cost one extra rescan. That
275
+ direction is deliberate — extra work, never stale content. Existing indexes
276
+ rescan once as the digest changes shape, then return to the fast path.
277
+
278
+ - **`akm migrate apply` can now clear a legacy `extraParams` config.** A config
279
+ still carrying a liftable key such as `extraParams.temperature` fails config
280
+ load closed, and that error names `akm migrate apply` as the fix — but the
281
+ migrate command resolved the stash directory and ran the task migrator, both
282
+ of which load config, so it died on the very error it exists to clear. An
283
+ operator hitting this had no reachable way forward. The config lift now runs
284
+ before anything that loads config, and `akm migrate status` reports the
285
+ pending lift as its blocker instead of re-raising the same error. A genuine
286
+ conflict, where an `extraParams` key and its first-class field disagree, still
287
+ hard-rejects and names both values rather than guessing.
288
+
289
+ - **`akm health` no longer warns about disk usage on a fresh install.** The
290
+ `data-dir-usage` advisory added earlier in 0.9.8 counted SQLite's `-wal` and
291
+ `-shm` sidecars toward the data directory's total but not toward the live
292
+ databases they belong to. On an untouched install the write-ahead log is most
293
+ of the directory, so the very first `akm health` reported a ~126x ratio and
294
+ exited `warn` with no user data present. Sidecars now count as part of their
295
+ database, and the advisory stays quiet below 1 GB, where a ratio says nothing
296
+ useful about disk pressure.
258
297
 
259
298
  ### Removed
260
299
 
@@ -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",
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": [