akm-cli 0.9.17 → 0.9.18

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
@@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.18] - 2026-09-29
10
+
11
+ ### Fixed
12
+
13
+ - **A running `akm improve` no longer loses its SQLite file locks, which let
14
+ an older `sqlite3` delete `state.db`'s WAL.** Copying a database file
15
+ inside a process that also holds it open drops every POSIX lock the process
16
+ holds on it, and `akm improve` did exactly that (its read snapshots copy
17
+ `state.db`, hundreds of times a run). A peer on SQLite older than 3.51 (the
18
+ system `sqlite3` command, Python's `sqlite3` module) that then opened
19
+ `state.db` read-write, even just for `.backup`, saw no reader and deleted
20
+ `state.db-wal`/`-shm` at close, leaving colliding rowids, stale index
21
+ entries and lost rows. Affected: every release since 0.9.2 through the
22
+ snapshot, and 0.9.2 through 0.9.16 also on every `openStateDatabase` (fixed
23
+ in 0.9.17-alpha.4). The snapshot now copies in a child `cp` (Windows keeps
24
+ the in-process copy; its locks belong to the handle), and `improve` reads
25
+ proposals through its own connection instead of snapshotting per asset.
26
+ `akm health`'s `state-db-integrity` check now runs `PRAGMA integrity_check`,
27
+ since `quick_check` does not compare indexes with their tables and reported
28
+ this damage as `ok`. On earlier releases, open a live database only with
29
+ `sqlite3 -readonly`.
30
+
9
31
  ## [0.9.17] - 2026-09-29
10
32
 
11
33
  ### Added
@@ -888,7 +888,7 @@ export const HEALTH_CHECKS = [
888
888
  // R0: nothing looked at state.db's own SQLite-level integrity before
889
889
  // this — the round-trip probe above only proves one row can be appended
890
890
  // and read back, which stays true on a database that fails
891
- // `PRAGMA quick_check` elsewhere (corrupt indexes, out-of-order rowids).
891
+ // `PRAGMA integrity_check` elsewhere (corrupt indexes, out-of-order rowids).
892
892
  // Also reports the freelist ratio (fraction of pages VACUUM could
893
893
  // reclaim) so a bloated-but-uncorrupted file is visible as a warning
894
894
  // rather than silence.
@@ -904,8 +904,8 @@ export const HEALTH_CHECKS = [
904
904
  kind: "deterministic",
905
905
  status: "fail",
906
906
  confidence: "high",
907
- message: `state.db failed PRAGMA quick_check: ${detail}. Repair: back up state.db, then run ` +
908
- `sqlite3 state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes quick_check, stop ` +
907
+ message: `state.db failed PRAGMA integrity_check: ${detail}. Repair: back up state.db, then run ` +
908
+ `sqlite3 -readonly state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes integrity_check, stop ` +
909
909
  "every akm process, then delete state.db-wal and state.db-shm before swapping state.new.db in as " +
910
910
  "state.db — a leftover WAL from the OLD database is replayed onto the new one and corrupts it.",
911
911
  evidence: { path: ctx.stateDbPath, lines, freelistRatio },
@@ -917,7 +917,7 @@ export const HEALTH_CHECKS = [
917
917
  kind: "deterministic",
918
918
  status: "fail",
919
919
  confidence: "high",
920
- message: `state.db passed PRAGMA quick_check, but reading its freelist/page-count failed: ${freelistError}.`,
920
+ message: `state.db passed PRAGMA integrity_check, but reading its freelist/page-count failed: ${freelistError}.`,
921
921
  evidence: { path: ctx.stateDbPath, lines, freelistError },
922
922
  };
923
923
  }
@@ -928,8 +928,8 @@ export const HEALTH_CHECKS = [
928
928
  status: freelistWarn ? "warn" : "pass",
929
929
  confidence: "high",
930
930
  message: freelistWarn
931
- ? `state.db passed PRAGMA quick_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
932
- : "state.db passed PRAGMA quick_check.",
931
+ ? `state.db passed PRAGMA integrity_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
932
+ : "state.db passed PRAGMA integrity_check.",
933
933
  evidence: {
934
934
  path: ctx.stateDbPath,
935
935
  freelistCount: ctx.stateDbFreelist.freelistCount,
@@ -17,7 +17,7 @@ import { countImproveRunsSince } from "../storage/repositories/improve-runs-repo
17
17
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
18
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
19
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
20
- import { getStateDbFreelistInfo, runStateDbQuickCheck } from "../storage/state-db-integrity.js";
20
+ import { getStateDbFreelistInfo, runStateDbIntegrityCheck } from "../storage/state-db-integrity.js";
21
21
  import { pkgVersion } from "../version.js";
22
22
  import { collectArchiveUsageAdvisory } from "./health/archive-usage.js";
23
23
  import { HEALTH_CHECKS, probeActiveImproveStrategy, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
@@ -182,10 +182,10 @@ function gatherTaskHistoryPhase(db, since, stateDbPath, now) {
182
182
  const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
183
183
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
184
184
  const probe = probeStateDbRoundTrip(stateDbPath);
185
- // R0: read-only, independent of the round-trip probe above — quick_check
185
+ // R0: read-only, independent of the round-trip probe above — integrity_check
186
186
  // catches corruption a successful append/read cannot (out-of-order rowids,
187
187
  // bad index entry counts), and the freelist reading is purely informational.
188
- const stateDbIntegrity = runStateDbQuickCheck(stateDbPath);
188
+ const stateDbIntegrity = runStateDbIntegrityCheck(stateDbPath);
189
189
  const stateDbFreelist = getStateDbFreelistInfo(stateDbPath);
190
190
  // D8 (spec §5.3): a marked "command" row or a legacy (unmarked) "prompt"
191
191
  // row is the agent/LLM arm; an unmarked "command" row is the legacy
@@ -635,7 +635,7 @@ seams = {}) {
635
635
  // also be judged as part of another until that decision resolves.
636
636
  const pendingRetireRefs = new Set();
637
637
  try {
638
- for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
638
+ for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, opts.proposalsCtx)) {
639
639
  if (!isRetireProposal(p))
640
640
  continue;
641
641
  pendingRetireRefs.add(stripBundle(p.ref));
@@ -657,7 +657,7 @@ seams = {}) {
657
657
  const rejectedPairKeys = new Set();
658
658
  try {
659
659
  for (const status of ["rejected", "reverted"]) {
660
- for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true })) {
660
+ for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, opts.proposalsCtx)) {
661
661
  if (!isRetireProposal(p) || !p.retirement)
662
662
  continue;
663
663
  rejectedPairKeys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
@@ -222,10 +222,10 @@ function injectRandomClusterMembers(memories, profile, warnings) {
222
222
  return out;
223
223
  }
224
224
  /** Body hashes of pending consolidate proposals, so the prompt can mark memories already queued. */
225
- function loadPendingConsolidateProposalHashes(stashDir) {
225
+ function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
226
226
  const hashes = new Set();
227
227
  try {
228
- for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
228
+ for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, proposalsCtx)) {
229
229
  if (p.source !== "consolidate")
230
230
  continue;
231
231
  try {
@@ -622,7 +622,7 @@ async function planConsolidation(opts, config, stashDir, memories, warnings, sta
622
622
  assertRunnerCredentials(llmRunner);
623
623
  const { ordered, embedTelemetry } = await clusterMemoriesBySimilarity(budgeted, config, stateDb, opts.signal);
624
624
  const chunks = slice(injectRandomClusterMembers(ordered, opts.improveProfile, warnings));
625
- const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
625
+ const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir, opts.proposalsCtx);
626
626
  warn(`[consolidate] ${budgeted.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
627
627
  ` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
628
628
  const planned = await judgeConsolidationChunks({
@@ -799,7 +799,7 @@ function readDistillFeedback(run) {
799
799
  }
800
800
  /** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
801
801
  async function buildDistillMessages(run, feedback, kind, outputRef) {
802
- const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx);
802
+ const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
803
803
  // CLS interleaving (default off): show related lessons so the model does not overwrite them.
804
804
  const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
805
805
  let clsContext = "";
@@ -209,6 +209,8 @@ async function runConsolidationPass(args) {
209
209
  maxChunkSize: processConfig?.maxChunkSize,
210
210
  signal: args.budgetSignal,
211
211
  p90ChunkSecondsDefault: processConfig?.p90ChunkSecondsDefault,
212
+ // Its read-only proposal lookups go through the run's own state.db handle.
213
+ ...(eventsCtx?.db ? { proposalsCtx: { db: eventsCtx.db } } : {}),
212
214
  }));
213
215
  }
214
216
  return { consolidation, plan: planned.plan };
@@ -761,7 +761,7 @@ async function gatherReflectPromptSources(options, stash, parsedRef, assetConten
761
761
  relatedLessons: options.ref && parsedRef
762
762
  ? await readRelatedLessons(stash, options.ref, parsedRef, options.itemRef, options.eventsCtx)
763
763
  : [],
764
- rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx),
764
+ rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
765
765
  standardsContext: resolveStandardsContext(options.ref, stash),
766
766
  };
767
767
  }
@@ -100,12 +100,14 @@ export const MAX_REJECTED_PROPOSALS = 3;
100
100
  /**
101
101
  * Reflexion context: the newest reviewer rejections for `ref`. Procedural
102
102
  * refusals (expiry, stale target, missing asset) are not judgements on the
103
- * content and are left out. Reads never create state.db.
103
+ * content and are left out. Reads never create state.db, and an improve run's
104
+ * live connection (`eventsCtx.db`) is read through, not copied.
104
105
  */
105
- export function rejectedProposalContext(stash, ref, ctx) {
106
+ export function rejectedProposalContext(stash, ref, ctx, eventsCtx) {
106
107
  if (!ref)
107
108
  return [];
108
- return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, ctx)
109
+ const proposalsCtx = eventsCtx?.db ? { ...ctx, db: eventsCtx.db } : ctx;
110
+ return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
109
111
  .filter((p) => !isProceduralRejection(p))
110
112
  .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
111
113
  .slice(0, MAX_REJECTED_PROPOSALS)
@@ -373,9 +373,13 @@ export function listProposals(stashDir, options = {}, ctx) {
373
373
  /**
374
374
  * {@link listProposals} on a read snapshot that never creates or migrates
375
375
  * state.db: prompt building runs before the first dispatch has validated its
376
- * credentials, and a missing store is simply empty.
376
+ * credentials, and a missing store is simply empty. A caller that already
377
+ * holds a live connection (`ctx.db`) reads through it: a snapshot would copy
378
+ * the whole database for nothing.
377
379
  */
378
380
  export function listProposalsReadOnly(stashDir, options = {}, ctx) {
381
+ if (ctx?.db)
382
+ return queryProposals(ctx.db, stashDir, options);
379
383
  const dbPath = ctx?.dbPath ?? getStateDbPath();
380
384
  if (!fs.existsSync(dbPath))
381
385
  return [];
@@ -52064,6 +52064,7 @@ import os6 from "node:os";
52064
52064
  import path44 from "node:path";
52065
52065
 
52066
52066
  // src/storage/sqlite-read-snapshot.ts
52067
+ import { spawnSync as spawnSync5 } from "node:child_process";
52067
52068
  import fs36 from "node:fs";
52068
52069
  import os5 from "node:os";
52069
52070
  import path43 from "node:path";
@@ -52108,6 +52109,26 @@ function fingerprintsEqual(left, right) {
52108
52109
  const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
52109
52110
  return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
52110
52111
  }
52112
+ function copyFileOutsideThisProcess(source, destination) {
52113
+ if (process.platform === "win32") {
52114
+ fs36.copyFileSync(source, destination);
52115
+ return;
52116
+ }
52117
+ const result = spawnSync5("cp", ["--", source, destination], {
52118
+ encoding: "utf8",
52119
+ stdio: ["ignore", "ignore", "pipe"]
52120
+ });
52121
+ if (result.error) {
52122
+ throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
52123
+ }
52124
+ if (result.status === 0)
52125
+ return;
52126
+ if (fileFingerprint(source) === undefined) {
52127
+ throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
52128
+ }
52129
+ const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
52130
+ throw new Error(`cp could not copy ${source}: ${reason}`);
52131
+ }
52111
52132
  function openSqliteReadSnapshot(dbPath) {
52112
52133
  if (!pathExists(dbPath))
52113
52134
  return;
@@ -52123,9 +52144,9 @@ function openSqliteReadSnapshot(dbPath) {
52123
52144
  if (pathExists(`${dbPath}-journal`))
52124
52145
  continue;
52125
52146
  const before = databaseFingerprint(dbPath);
52126
- fs36.copyFileSync(dbPath, snapshotPath);
52147
+ copyFileOutsideThisProcess(dbPath, snapshotPath);
52127
52148
  if (before.wal)
52128
- fs36.copyFileSync(`${dbPath}-wal`, `${snapshotPath}-wal`);
52149
+ copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
52129
52150
  else
52130
52151
  fs36.rmSync(`${snapshotPath}-wal`, { force: true });
52131
52152
  const after = databaseFingerprint(dbPath);
@@ -52059,6 +52059,7 @@ import os6 from "os";
52059
52059
  import path44 from "path";
52060
52060
 
52061
52061
  // src/storage/sqlite-read-snapshot.ts
52062
+ import { spawnSync as spawnSync5 } from "child_process";
52062
52063
  import fs36 from "fs";
52063
52064
  import os5 from "os";
52064
52065
  import path43 from "path";
@@ -52103,6 +52104,26 @@ function fingerprintsEqual(left, right) {
52103
52104
  const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
52104
52105
  return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
52105
52106
  }
52107
+ function copyFileOutsideThisProcess(source, destination) {
52108
+ if (process.platform === "win32") {
52109
+ fs36.copyFileSync(source, destination);
52110
+ return;
52111
+ }
52112
+ const result = spawnSync5("cp", ["--", source, destination], {
52113
+ encoding: "utf8",
52114
+ stdio: ["ignore", "ignore", "pipe"]
52115
+ });
52116
+ if (result.error) {
52117
+ throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
52118
+ }
52119
+ if (result.status === 0)
52120
+ return;
52121
+ if (fileFingerprint(source) === undefined) {
52122
+ throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
52123
+ }
52124
+ const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
52125
+ throw new Error(`cp could not copy ${source}: ${reason}`);
52126
+ }
52106
52127
  function openSqliteReadSnapshot(dbPath) {
52107
52128
  if (!pathExists(dbPath))
52108
52129
  return;
@@ -52118,9 +52139,9 @@ function openSqliteReadSnapshot(dbPath) {
52118
52139
  if (pathExists(`${dbPath}-journal`))
52119
52140
  continue;
52120
52141
  const before = databaseFingerprint(dbPath);
52121
- fs36.copyFileSync(dbPath, snapshotPath);
52142
+ copyFileOutsideThisProcess(dbPath, snapshotPath);
52122
52143
  if (before.wal)
52123
- fs36.copyFileSync(`${dbPath}-wal`, `${snapshotPath}-wal`);
52144
+ copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
52124
52145
  else
52125
52146
  fs36.rmSync(`${snapshotPath}-wal`, { force: true });
52126
52147
  const after = databaseFingerprint(dbPath);
@@ -9,7 +9,12 @@
9
9
  * must not do that. This helper copies a stable main/WAL pair and opens that
10
10
  * private copy, so SQLite never attaches to the operator's original
11
11
  * main/WAL/SHM files.
12
+ *
13
+ * The copy itself is made by a child process, never by this one: see
14
+ * {@link copyFileOutsideThisProcess} for why an in-process read of a live
15
+ * database is unsafe.
12
16
  */
17
+ import { spawnSync } from "node:child_process";
13
18
  import fs from "node:fs";
14
19
  import os from "node:os";
15
20
  import path from "node:path";
@@ -57,6 +62,45 @@ function fingerprintsEqual(left, right) {
57
62
  const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
58
63
  return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
59
64
  }
65
+ /**
66
+ * Copy `source` to `destination` without opening `source` in THIS process.
67
+ *
68
+ * POSIX advisory locks belong to the process, not to the descriptor: when a
69
+ * process closes ANY descriptor for a file, the kernel drops every lock that
70
+ * process holds on it, including the SHARED lock a live SQLite connection in
71
+ * this same process (`akm improve` keeps one on state.db for the whole run)
72
+ * holds. A peer using an older SQLite (< 3.51) read-write would then see no
73
+ * reader, take EXCLUSIVE when it closes, and delete the `-wal`/`-shm` this
74
+ * process is still using. The read therefore happens in a child `cp`, whose
75
+ * descriptors and locks are its own. Windows locks belong to the handle, so
76
+ * an in-process copy cannot release another handle's lock there (and there is
77
+ * no `cp`).
78
+ *
79
+ * A source that vanished (a WAL checkpointed away mid-copy) throws an
80
+ * `ENOENT` error, which {@link openSqliteReadSnapshot} retries.
81
+ */
82
+ function copyFileOutsideThisProcess(source, destination) {
83
+ if (process.platform === "win32") {
84
+ fs.copyFileSync(source, destination);
85
+ return;
86
+ }
87
+ const result = spawnSync("cp", ["--", source, destination], {
88
+ encoding: "utf8",
89
+ stdio: ["ignore", "ignore", "pipe"],
90
+ });
91
+ if (result.error) {
92
+ throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
93
+ }
94
+ if (result.status === 0)
95
+ return;
96
+ // `cp` reports its errors as text, so the vanished-source case is recognised
97
+ // by looking at the source itself.
98
+ if (fileFingerprint(source) === undefined) {
99
+ throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
100
+ }
101
+ const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
102
+ throw new Error(`cp could not copy ${source}: ${reason}`);
103
+ }
60
104
  /**
61
105
  * Open an isolated copy of an existing SQLite database.
62
106
  *
@@ -82,9 +126,9 @@ export function openSqliteReadSnapshot(dbPath) {
82
126
  if (pathExists(`${dbPath}-journal`))
83
127
  continue;
84
128
  const before = databaseFingerprint(dbPath);
85
- fs.copyFileSync(dbPath, snapshotPath);
129
+ copyFileOutsideThisProcess(dbPath, snapshotPath);
86
130
  if (before.wal)
87
- fs.copyFileSync(`${dbPath}-wal`, `${snapshotPath}-wal`);
131
+ copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
88
132
  else
89
133
  fs.rmSync(`${snapshotPath}-wal`, { force: true });
90
134
  const after = databaseFingerprint(dbPath);
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * `akm health`'s `state-db-integrity` check (src/commands/health/checks.ts)
9
9
  * is a pure projection like every other check, so the actual IO lives here:
10
- * a read-only `PRAGMA quick_check` and a read-only freelist/page-count read.
10
+ * a read-only `PRAGMA integrity_check` and a read-only freelist/page-count read.
11
11
  * Both open their own short-lived read-only connection via the plain
12
12
  * {@link openDatabase} opener rather than `openStateDatabase`
13
13
  * (src/core/state-db.ts), since a corrupt database must not need a clean
@@ -27,8 +27,8 @@
27
27
  import { appendEvent } from "../core/events.js";
28
28
  import { openDatabase } from "./database.js";
29
29
  import { applyReadonlyPragmas } from "./sqlite-pragmas.js";
30
- /** How many corruption errors `PRAGMA quick_check` collects before it stops scanning and returns. */
31
- const QUICK_CHECK_ERROR_LIMIT = 10;
30
+ /** How many corruption errors `PRAGMA integrity_check` collects before it stops scanning and returns. */
31
+ const INTEGRITY_CHECK_ERROR_LIMIT = 10;
32
32
  /**
33
33
  * Above this fraction of free pages, `state-db-integrity` warns, and
34
34
  * {@link vacuumIfReclaimable} compacts state.db (after improve's retention
@@ -48,16 +48,19 @@ function openReadonlyStateDb(dbPath) {
48
48
  return db;
49
49
  }
50
50
  /**
51
- * Run `PRAGMA quick_check(N)` against `dbPath` read-only. Sub-second on a
52
- * healthy multi-hundred-MB file; on a corrupt one, `N` bounds how many errors
53
- * SQLite collects before it stops scanning, which keeps the check's runtime
54
- * bounded even against a badly corrupt file.
51
+ * Run `PRAGMA integrity_check(N)` against `dbPath` read-only. Unlike
52
+ * `quick_check`, it also verifies every index against its table ("row N
53
+ * missing from index", "wrong # of entries in index"): the damage a WAL
54
+ * deleted under a live connection leaves behind, which `quick_check` reports
55
+ * as `ok`. Sub-second on a healthy state.db of a few hundred MB; on a corrupt
56
+ * one, `N` bounds how many errors SQLite collects before it stops scanning,
57
+ * which keeps the check's runtime bounded even against a badly corrupt file.
55
58
  */
56
- export function runStateDbQuickCheck(dbPath) {
59
+ export function runStateDbIntegrityCheck(dbPath) {
57
60
  let db;
58
61
  try {
59
62
  db = openReadonlyStateDb(dbPath);
60
- const rows = db.prepare(`PRAGMA quick_check(${QUICK_CHECK_ERROR_LIMIT})`).all();
63
+ const rows = db.prepare(`PRAGMA integrity_check(${INTEGRITY_CHECK_ERROR_LIMIT})`).all();
61
64
  const lines = rows.map((row) => String(firstColumn(row)));
62
65
  const ok = lines.length === 1 && lines[0] === "ok";
63
66
  return { ok, lines };
@@ -988,7 +988,7 @@ akm-migrate storage --dry-run
988
988
  and look for import warnings. You can also inspect `state.db` directly:
989
989
 
990
990
  ```sh
991
- sqlite3 ~/.local/share/akm/state.db 'SELECT COUNT(*) FROM events;'
991
+ sqlite3 -readonly ~/.local/share/akm/state.db 'SELECT COUNT(*) FROM events;'
992
992
  ```
993
993
 
994
994
  **`akm list` shows empty stashes.**
@@ -1039,7 +1039,7 @@ If you need to roll back to 0.7.x:
1039
1039
  wrote new events on 0.8.0 and need them in 0.7.x, export them:
1040
1040
 
1041
1041
  ```sh
1042
- sqlite3 ~/.local/share/akm/state.db \
1042
+ sqlite3 -readonly ~/.local/share/akm/state.db \
1043
1043
  "SELECT json_object('schemaVersion', schema_version, 'ts', ts, 'eventType', event_type, 'ref', ref, 'metadata', json(metadata)) FROM events ORDER BY id;" \
1044
1044
  >> ~/.cache/akm/events.jsonl
1045
1045
  ```
@@ -344,10 +344,13 @@ rm -rf ~/.cache/akm/config-backups/
344
344
 
345
345
  # Delete the events log from state.db (non-reversible)
346
346
  # There is no akm CLI command to do this directly (`akm log` only exposes
347
- # `list`/`tail`, no delete/purge verb). Use SQLite directly:
347
+ # `list`/`tail`, no delete/purge verb). Use SQLite directly.
348
+ # Stop akm first (no `akm` process or scheduled task running): an older
349
+ # `sqlite3` (< 3.51) opened read-write alongside a running akm can corrupt
350
+ # the database.
348
351
  sqlite3 ~/.local/share/akm/state.db "DELETE FROM events;"
349
352
 
350
- # Delete all proposals
353
+ # Delete all proposals (same precondition: stop akm first)
351
354
  sqlite3 ~/.local/share/akm/state.db "DELETE FROM proposals;"
352
355
  ```
353
356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
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": [