akm-cli 0.9.17-alpha.9 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +296 -2025
  2. package/dist/cli/unknown-flags.js +24 -1
  3. package/dist/cli.js +46 -1
  4. package/dist/commands/health/archive-usage.js +9 -15
  5. package/dist/commands/health/checks.js +6 -6
  6. package/dist/commands/health/improve-metrics.js +25 -12
  7. package/dist/commands/health.js +3 -3
  8. package/dist/commands/improve/consolidate/pair-pass.js +2 -2
  9. package/dist/commands/improve/consolidate.js +3 -3
  10. package/dist/commands/improve/distill.js +1 -1
  11. package/dist/commands/improve/memory/memory-improve.js +8 -15
  12. package/dist/commands/improve/preparation.js +2 -0
  13. package/dist/commands/improve/reflect.js +1 -1
  14. package/dist/commands/improve/stage.js +5 -3
  15. package/dist/commands/proposal/repository.js +5 -1
  16. package/dist/commands/sources/info.js +122 -18
  17. package/dist/commands/sources/stash-cli.js +21 -1
  18. package/dist/core/improve-result.js +6 -1
  19. package/dist/output/text/command-format.js +9 -0
  20. package/dist/scripts/akm-migrate-node.js +29 -2
  21. package/dist/scripts/akm-migrate.js +29 -2
  22. package/dist/sources/providers/git-stash.js +28 -0
  23. package/dist/storage/repositories/index-connection.js +5 -2
  24. package/dist/storage/sqlite-read-snapshot.js +46 -2
  25. package/dist/storage/state-db-integrity.js +12 -9
  26. package/docs/migration/README.md +1 -1
  27. package/docs/migration/release-notes/0.9.17.md +130 -41
  28. package/docs/migration/release-notes/README.md +7 -0
  29. package/docs/migration/v0.7-to-v0.8.md +2 -2
  30. package/docs/reference/cli.md +11 -2
  31. package/docs/reference/data-and-telemetry.md +5 -2
  32. package/package.json +1 -1
@@ -1,30 +1,87 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import path from "node:path";
4
5
  import { placementTypes } from "../../core/asset/asset-placement.js";
5
6
  import { resolveStashDir } from "../../core/common.js";
6
- import { getSources, loadConfig } from "../../core/config/config.js";
7
+ import { DEFAULT_CONFIG, getSources, loadConfig } from "../../core/config/config.js";
8
+ import { ConfigError } from "../../core/errors.js";
7
9
  import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
8
- import { getCacheDir, getConfigDir, getDataDir, getDbPath, getStateDir } from "../../core/paths.js";
10
+ import { getCacheDir, getConfigDir, getDataDir, getDefaultStashDir, getStateDir } from "../../core/paths.js";
9
11
  import { formatRegistryUrl } from "../../core/registry-url.js";
10
12
  import { error } from "../../core/warn.js";
11
- import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
13
+ import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
12
14
  import { getEntryCount, getEntryCountByType } from "../../storage/repositories/index-entries-repository.js";
13
15
  import { countLinksByKind } from "../../storage/repositories/index-links-repository.js";
14
16
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
17
+ import { isSqliteContentionError } from "../../storage/sqlite-transaction.js";
15
18
  import { pkgVersion } from "../../version.js";
19
+ /**
20
+ * Bound for `akm info`'s diagnostic index.db read — short enough that a
21
+ * locked database never stalls the command, unlike the shared 30s
22
+ * `SQLITE_BUSY_TIMEOUT_MS` every write-capable opener uses. The opener's own
23
+ * layout check (`checkIndexLayout`) swallows a busy error on its one SELECT
24
+ * rather than surfacing it, so a genuinely locked database costs this
25
+ * timeout TWICE before the first real query here (`countLinksByKind`)
26
+ * throws for real — 750ms keeps that ~1.5s worst case well clear of the 3s
27
+ * bound integration tests hold this to, on a loaded CI box.
28
+ */
29
+ const INFO_INDEX_BUSY_TIMEOUT_MS = 750;
16
30
  /**
17
31
  * Assemble system info describing the current capabilities, configuration,
18
- * and index state. Used by `akm info`.
32
+ * and index state. Used by `akm info`, which must behave like a help
33
+ * command (owner ruling): always print a report and exit 0, whatever else
34
+ * is happening. Every section below is read best-effort, so one failing
35
+ * lookup degrades only its own field(s) instead of the whole report;
36
+ * `infoCommand` itself (stash-cli.ts) wraps this whole call as the final
37
+ * backstop for anything left over — e.g. a path resolver that needs an
38
+ * environment variable nothing here sets, such as `HOME`.
19
39
  *
20
40
  * @param options.dbPath - Override the database path (useful for testing)
21
41
  */
22
42
  export function assembleInfo(options) {
23
- const config = loadConfig();
43
+ let config;
44
+ let configError;
45
+ try {
46
+ config = loadConfig();
47
+ }
48
+ catch (err) {
49
+ config = DEFAULT_CONFIG;
50
+ configError = err instanceof Error ? err.message : String(err);
51
+ }
24
52
  // Primary stash directory + default bundle name — same resolution
25
53
  // `akm sources list` uses (R-057), so `akm info` and `akm sources list`
26
- // agree on which stash is primary.
27
- const stashDir = resolveStashDir();
54
+ // agree on which stash is primary. No bundle created yet
55
+ // (STASH_DIR_NOT_FOUND, the ordinary fresh-install state) reports where a
56
+ // fresh `akm setup`/`akm bundle create` would put it, same as "report the
57
+ // defaults" for a missing config — no error needed, nothing is actually
58
+ // wrong. A bundle that WAS configured (an env override or `bundles.*` in
59
+ // config) but doesn't resolve (STASH_DIR_UNREADABLE/STASH_DIR_NOT_A_DIRECTORY)
60
+ // is different: the fallback path is a courtesy, not a way to hide a
61
+ // genuine misconfiguration, so that reason is kept in `bundleDirError`.
62
+ let stashDir;
63
+ let bundleDirError;
64
+ try {
65
+ stashDir = resolveStashDir();
66
+ }
67
+ catch (err) {
68
+ if (!(err instanceof ConfigError) || err.code !== "STASH_DIR_NOT_FOUND") {
69
+ bundleDirError = err instanceof Error ? err.message : String(err);
70
+ }
71
+ // The fallback itself resolves purely from `HOME` (or its platform
72
+ // equivalent) and can throw the exact same way `resolveStashDir()` can
73
+ // (e.g. HOME entirely unset) — guarded so a blank `bundleDir` (never a
74
+ // throw) still always carries a reason, reusing the one above when the
75
+ // outer catch already set it (a configured-but-broken path takes
76
+ // precedence over restating "and the default isn't resolvable either").
77
+ try {
78
+ stashDir = getDefaultStashDir();
79
+ }
80
+ catch (fallbackErr) {
81
+ stashDir = "";
82
+ bundleDirError ??= fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
83
+ }
84
+ }
28
85
  const defaultBundle = config.defaultBundle ?? null;
29
86
  // Asset types (copy into a mutable array — `placementTypes()` returns readonly)
30
87
  const assetTypes = [...placementTypes()];
@@ -45,11 +102,29 @@ export function assembleInfo(options) {
45
102
  ...(s.url ? { url: s.url } : {}),
46
103
  ...(s.enabled !== undefined ? { enabled: s.enabled } : {}),
47
104
  }));
48
- // Index stats — `options.dbPath` is a test-only override (see the param
49
- // doc above); real callers fall through to the same `getDbPath()` that
50
- // health and search use, so info reads the same database they do.
51
- const resolvedDbPath = options?.dbPath ?? getDbPath();
52
- const indexStats = readIndexStats(resolvedDbPath);
105
+ // Data directory. `getDataDir()` can itself throw (e.g. TEST_ISOLATION_MISSING
106
+ // when NODE_ENV=test leaks into a real invocation — a JS test runner's
107
+ // child process — with no XDG_DATA_HOME/AKM_DATA_DIR override), caught
108
+ // here so that failure degrades only `dataDir`/`indexStats` rather than
109
+ // the whole report. Always attempted, even when `options.dbPath` (a
110
+ // test-only override, see the param doc above) means it is not needed to
111
+ // resolve the index path below — `dataDir` is its own reported field.
112
+ let dataDir = "";
113
+ let dataDirError;
114
+ try {
115
+ dataDir = getDataDir();
116
+ }
117
+ catch (err) {
118
+ dataDirError = err instanceof Error ? err.message : String(err);
119
+ }
120
+ // Index stats. `options.dbPath` overrides the resolved path outright;
121
+ // otherwise it is derived from `dataDir` above, so a data dir that failed
122
+ // to resolve degrades index stats the same way rather than retrying (and
123
+ // re-throwing) `getDataDir()` a second time.
124
+ const resolvedDbPath = options?.dbPath ?? (dataDir ? path.join(dataDir, "index.db") : undefined);
125
+ const indexStats = resolvedDbPath
126
+ ? readIndexStats(resolvedDbPath)
127
+ : { entryCount: 0, byType: {}, lastBuiltAt: null, hasEmbeddings: false, unavailable: dataDirError };
53
128
  // Semantic status is read live from the index's own state, not a cached
54
129
  // verdict — a failed embed attempt at search time falls back to FTS and
55
130
  // reports that in the search response, it never disables the mode here.
@@ -63,10 +138,12 @@ export function assembleInfo(options) {
63
138
  version: pkgVersion,
64
139
  bundleDir: stashDir,
65
140
  defaultBundle,
66
- dataDir: getDataDir(),
67
- configDir: getConfigDir(),
68
- cacheDir: getCacheDir(),
69
- stateDir: getStateDir(),
141
+ ...(configError ? { configError } : {}),
142
+ ...(bundleDirError ? { bundleDirError } : {}),
143
+ dataDir,
144
+ configDir: safePath(getConfigDir),
145
+ cacheDir: safePath(getCacheDir),
146
+ stateDir: safePath(getStateDir),
70
147
  assetTypes,
71
148
  searchModes,
72
149
  semanticSearch: {
@@ -78,6 +155,15 @@ export function assembleInfo(options) {
78
155
  indexStats,
79
156
  };
80
157
  }
158
+ /** Best-effort path resolution for a section that must degrade, not throw: an empty string in place of an exception. */
159
+ function safePath(fn) {
160
+ try {
161
+ return fn();
162
+ }
163
+ catch {
164
+ return "";
165
+ }
166
+ }
81
167
  function readIndexStats(resolvedPath) {
82
168
  const EMPTY = {
83
169
  entryCount: 0,
@@ -99,7 +185,15 @@ function readIndexStats(resolvedPath) {
99
185
  }
100
186
  let db;
101
187
  try {
102
- db = openExistingDatabase(resolvedPath);
188
+ // Never writes the index — no schema or journal-mode change — and
189
+ // bounded to INFO_INDEX_BUSY_TIMEOUT_MS rather than the shared 30s
190
+ // busy_timeout every write-capable opener uses. A newer index layout is
191
+ // reported below rather than refused (checkIndexLayout throws); an
192
+ // older layout just warns and is served as-is, never migrated — both
193
+ // already true of this opener.
194
+ db = openReadonlyExistingDatabase(resolvedPath, { busyTimeoutMs: INFO_INDEX_BUSY_TIMEOUT_MS });
195
+ if (!db)
196
+ return EMPTY; // raced away (deleted) between the access check above and here
103
197
  const links = countLinksByKind(db);
104
198
  return {
105
199
  entryCount: getEntryCount(db),
@@ -115,7 +209,7 @@ function readIndexStats(resolvedPath) {
115
209
  // Routed through core/warn's `error()` (not a raw process.stderr.write)
116
210
  // so `--quiet`/`setQuiet()` actually gate this line (R-057).
117
211
  error(`[akm info] failed to read index stats from ${resolvedPath}: ${String(err)}`);
118
- return EMPTY;
212
+ return { ...EMPTY, unavailable: describeIndexReadFailure(err) };
119
213
  }
120
214
  finally {
121
215
  if (db) {
@@ -128,3 +222,13 @@ function readIndexStats(resolvedPath) {
128
222
  }
129
223
  }
130
224
  }
225
+ /**
226
+ * Reason for `indexStats.unavailable`. Contention gets its own clear
227
+ * wording; everything else (a too-new layout, on-disk corruption, ...) is
228
+ * already descriptive as the driver/opener's own message.
229
+ */
230
+ function describeIndexReadFailure(err) {
231
+ if (isSqliteContentionError(err))
232
+ return "index.db is locked by another akm process";
233
+ return err instanceof Error ? err.message : String(err);
234
+ }
@@ -33,6 +33,7 @@ import * as p from "../../cli/clack.js";
33
33
  import { getParsedInvocation } from "../../cli/invocation.js";
34
34
  import { defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, parseAllFlagValues, runWithJsonErrors } from "../../cli/shared.js";
35
35
  import { assertFlatAssetName } from "../../core/asset/asset-create.js";
36
+ import { placementTypes } from "../../core/asset/asset-placement.js";
36
37
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
37
38
  import { isHttpUrl, resolveStashDir } from "../../core/common.js";
38
39
  import { loadConfig } from "../../core/config/config.js";
@@ -45,6 +46,7 @@ import { resolveWriteTarget } from "../../core/write-source.js";
45
46
  import { releaseIndexRebuildLock, tryAcquireIndexRebuildLock } from "../../indexer/index-rebuild-lock.js";
46
47
  import { akmIndex } from "../../indexer/indexer.js";
47
48
  import { getHyphenatedBoolean, getOutputMode } from "../../output/context.js";
49
+ import { pkgVersion } from "../../version.js";
48
50
  import { inferAssetName, mergeXrefsIntoContent, readKnowledgeInput, resolveSupersedesForWrite, resolveSupersedesWriteTarget, resolveXrefsForWrite, writeMarkdownAsset, } from "../read/knowledge.js";
49
51
  import { assembleInfo } from "./info.js";
50
52
  /** Matches the high-frequency per-committed-batch progress line (#954), excluded from non-verbose JSON-mode stderr. */
@@ -181,7 +183,25 @@ export const indexCommand = defineCommand({
181
183
  export const infoCommand = defineJsonCommand({
182
184
  meta: { name: "info", description: "Show system capabilities, configuration, and index stats" },
183
185
  run() {
184
- const result = assembleInfo();
186
+ // `akm info` must behave like a help command: always exit 0 with a
187
+ // report. `assembleInfo()` already degrades what it reasonably can
188
+ // per-section; this is the final backstop for whatever it can't (e.g.
189
+ // every path resolver needs an environment variable, such as `HOME`,
190
+ // that this process genuinely does not have) — still a report, with
191
+ // whatever is cheap and safe enough to never itself throw.
192
+ let result;
193
+ try {
194
+ result = assembleInfo();
195
+ }
196
+ catch (err) {
197
+ result = {
198
+ schemaVersion: 1,
199
+ version: pkgVersion,
200
+ error: err instanceof Error ? err.message : String(err),
201
+ assetTypes: [...placementTypes()],
202
+ searchModes: ["fts"],
203
+ };
204
+ }
185
205
  output("info", result);
186
206
  },
187
207
  });
@@ -393,7 +393,12 @@ function validateImprovePlan(value, dryRun, plannedRefNames) {
393
393
  if (value.limits.totalCeiling !== undefined && value.effectiveRefs.length > value.limits.totalCeiling) {
394
394
  fail("plan.effectiveRefs cannot exceed plan.limits.totalCeiling");
395
395
  }
396
- validateProcessRoutingRows(value.processes);
396
+ // #947 added plan.processes (2026-09-09T09:03:19Z); every run recorded
397
+ // before it legitimately stored `plan` with no `processes` key at all —
398
+ // validate the rows only when present, same as `proactive` below, so a
399
+ // pre-#947 envelope still decodes (AGENTS.md "Reading persisted data").
400
+ if (value.processes !== undefined)
401
+ validateProcessRoutingRows(value.processes);
397
402
  if (value.proactive !== undefined)
398
403
  validateProactivePlan(value.proactive);
399
404
  validateConsolidationPlan(value.consolidation);
@@ -20,11 +20,20 @@ export function formatInfoPlain(r) {
20
20
  const lines = [];
21
21
  if (r.version)
22
22
  lines.push(`version: ${String(r.version)}`);
23
+ // Only present in infoCommand's own last-resort fallback (stash-cli.ts),
24
+ // when assembleInfo() itself threw — every field below it belongs to the
25
+ // normal shape and is absent there.
26
+ if (typeof r.error === "string")
27
+ lines.push(`error: ${r.error}`);
23
28
  if (r.bundleDir)
24
29
  lines.push(`bundleDir: ${String(r.bundleDir)}`);
25
30
  if (r.defaultBundle !== undefined) {
26
31
  lines.push(`defaultBundle: ${r.defaultBundle === null ? "(none)" : String(r.defaultBundle)}`);
27
32
  }
33
+ if (typeof r.configError === "string")
34
+ lines.push(`configError: ${r.configError}`);
35
+ if (typeof r.bundleDirError === "string")
36
+ lines.push(`bundleDirError: ${r.bundleDirError}`);
28
37
  if (Array.isArray(r.assetTypes) && r.assetTypes.length > 0) {
29
38
  lines.push(`assetTypes: ${r.assetTypes.join(", ")}`);
30
39
  }
@@ -29609,11 +29609,17 @@ function formatInfoPlain(r) {
29609
29609
  const lines = [];
29610
29610
  if (r.version)
29611
29611
  lines.push(`version: ${String(r.version)}`);
29612
+ if (typeof r.error === "string")
29613
+ lines.push(`error: ${r.error}`);
29612
29614
  if (r.bundleDir)
29613
29615
  lines.push(`bundleDir: ${String(r.bundleDir)}`);
29614
29616
  if (r.defaultBundle !== undefined) {
29615
29617
  lines.push(`defaultBundle: ${r.defaultBundle === null ? "(none)" : String(r.defaultBundle)}`);
29616
29618
  }
29619
+ if (typeof r.configError === "string")
29620
+ lines.push(`configError: ${r.configError}`);
29621
+ if (typeof r.bundleDirError === "string")
29622
+ lines.push(`bundleDirError: ${r.bundleDirError}`);
29617
29623
  if (Array.isArray(r.assetTypes) && r.assetTypes.length > 0) {
29618
29624
  lines.push(`assetTypes: ${r.assetTypes.join(", ")}`);
29619
29625
  }
@@ -52058,6 +52064,7 @@ import os6 from "node:os";
52058
52064
  import path44 from "node:path";
52059
52065
 
52060
52066
  // src/storage/sqlite-read-snapshot.ts
52067
+ import { spawnSync as spawnSync5 } from "node:child_process";
52061
52068
  import fs36 from "node:fs";
52062
52069
  import os5 from "node:os";
52063
52070
  import path43 from "node:path";
@@ -52102,6 +52109,26 @@ function fingerprintsEqual(left, right) {
52102
52109
  const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
52103
52110
  return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
52104
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
+ }
52105
52132
  function openSqliteReadSnapshot(dbPath) {
52106
52133
  if (!pathExists(dbPath))
52107
52134
  return;
@@ -52117,9 +52144,9 @@ function openSqliteReadSnapshot(dbPath) {
52117
52144
  if (pathExists(`${dbPath}-journal`))
52118
52145
  continue;
52119
52146
  const before = databaseFingerprint(dbPath);
52120
- fs36.copyFileSync(dbPath, snapshotPath);
52147
+ copyFileOutsideThisProcess(dbPath, snapshotPath);
52121
52148
  if (before.wal)
52122
- fs36.copyFileSync(`${dbPath}-wal`, `${snapshotPath}-wal`);
52149
+ copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
52123
52150
  else
52124
52151
  fs36.rmSync(`${snapshotPath}-wal`, { force: true });
52125
52152
  const after = databaseFingerprint(dbPath);
@@ -28937,11 +28937,17 @@ function formatInfoPlain(r) {
28937
28937
  const lines = [];
28938
28938
  if (r.version)
28939
28939
  lines.push(`version: ${String(r.version)}`);
28940
+ if (typeof r.error === "string")
28941
+ lines.push(`error: ${r.error}`);
28940
28942
  if (r.bundleDir)
28941
28943
  lines.push(`bundleDir: ${String(r.bundleDir)}`);
28942
28944
  if (r.defaultBundle !== undefined) {
28943
28945
  lines.push(`defaultBundle: ${r.defaultBundle === null ? "(none)" : String(r.defaultBundle)}`);
28944
28946
  }
28947
+ if (typeof r.configError === "string")
28948
+ lines.push(`configError: ${r.configError}`);
28949
+ if (typeof r.bundleDirError === "string")
28950
+ lines.push(`bundleDirError: ${r.bundleDirError}`);
28945
28951
  if (Array.isArray(r.assetTypes) && r.assetTypes.length > 0) {
28946
28952
  lines.push(`assetTypes: ${r.assetTypes.join(", ")}`);
28947
28953
  }
@@ -52053,6 +52059,7 @@ import os6 from "os";
52053
52059
  import path44 from "path";
52054
52060
 
52055
52061
  // src/storage/sqlite-read-snapshot.ts
52062
+ import { spawnSync as spawnSync5 } from "child_process";
52056
52063
  import fs36 from "fs";
52057
52064
  import os5 from "os";
52058
52065
  import path43 from "path";
@@ -52097,6 +52104,26 @@ function fingerprintsEqual(left, right) {
52097
52104
  const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
52098
52105
  return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
52099
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
+ }
52100
52127
  function openSqliteReadSnapshot(dbPath) {
52101
52128
  if (!pathExists(dbPath))
52102
52129
  return;
@@ -52112,9 +52139,9 @@ function openSqliteReadSnapshot(dbPath) {
52112
52139
  if (pathExists(`${dbPath}-journal`))
52113
52140
  continue;
52114
52141
  const before = databaseFingerprint(dbPath);
52115
- fs36.copyFileSync(dbPath, snapshotPath);
52142
+ copyFileOutsideThisProcess(dbPath, snapshotPath);
52116
52143
  if (before.wal)
52117
- fs36.copyFileSync(`${dbPath}-wal`, `${snapshotPath}-wal`);
52144
+ copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
52118
52145
  else
52119
52146
  fs36.rmSync(`${snapshotPath}-wal`, { force: true });
52120
52147
  const after = databaseFingerprint(dbPath);
@@ -97,6 +97,34 @@ export function tryListGitUnverifiablePaths(repoDir, pathspec) {
97
97
  }
98
98
  return { paths, ok: true };
99
99
  }
100
+ /**
101
+ * The three-part git cleanliness check shared by the archive purge sweep
102
+ * (`purgeGracedArchive` in `commands/improve/memory/memory-improve.ts`) and
103
+ * the `memory-cleanup-archive` health advisory (`collectArchiveUsageAdvisory`
104
+ * in `commands/health/archive-usage.ts`): a path is safe to treat as
105
+ * committed only if it is tracked ({@link tryListGitTrackedPaths}), not dirty
106
+ * ({@link tryListGitChangedPaths}), and not assume-unchanged/skip-worktree
107
+ * ({@link tryListGitUnverifiablePaths} — those hide their own edits from
108
+ * `git status`, so an unverifiable file is never trusted as clean either).
109
+ *
110
+ * Each of the three git calls can fail independently (a broken submodule, or
111
+ * git missing from `PATH`); `ok` is `false` if any one does, and `isSafe`
112
+ * then returns `false` for every path rather than guessing — callers that
113
+ * need to short-circuit before doing other work still check `ok` themselves.
114
+ */
115
+ export function checkGitPathSafety(repoDir, pathspec) {
116
+ const dirtyQuery = tryListGitChangedPaths(repoDir);
117
+ const trackedQuery = tryListGitTrackedPaths(repoDir, pathspec);
118
+ const unverifiableQuery = tryListGitUnverifiablePaths(repoDir, pathspec);
119
+ const ok = dirtyQuery.ok && trackedQuery.ok && unverifiableQuery.ok;
120
+ const dirty = new Set(dirtyQuery.paths);
121
+ const tracked = new Set(trackedQuery.paths);
122
+ const unverifiable = new Set(unverifiableQuery.paths);
123
+ return {
124
+ ok,
125
+ isSafe: (repoRelativePath) => ok && tracked.has(repoRelativePath) && !dirty.has(repoRelativePath) && !unverifiable.has(repoRelativePath),
126
+ };
127
+ }
100
128
  export class GitStashPushError extends Error {
101
129
  commit;
102
130
  constructor(message, commit) {
@@ -180,9 +180,12 @@ export function openReadonlyExistingDatabase(dbPath, options) {
180
180
  // never block — but in the DELETE/TRUNCATE modes the network-FS fallback and
181
181
  // AKM_SQLITE_JOURNAL_MODE can select, a concurrent writer makes every read
182
182
  // fail instantly with SQLITE_BUSY. busy_timeout is legal on a read-only
183
- // connection, so apply just that one.
183
+ // connection, so apply just that one. `busyTimeoutMs` defaults to the
184
+ // shared 30s constant; a caller that must never sit behind another akm
185
+ // process's write lock for long (e.g. `akm info`) can pass a much shorter
186
+ // bound instead.
184
187
  try {
185
- db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
188
+ db.exec(`PRAGMA busy_timeout = ${options?.busyTimeoutMs ?? SQLITE_BUSY_TIMEOUT_MS}`);
186
189
  checkIndexLayout(db, resolvedPath);
187
190
  return db;
188
191
  }
@@ -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 };
@@ -5,7 +5,7 @@ Upgrade guides and per-release migration notes.
5
5
  - [v0.9.1 -> v0.9.2 migration guide](v0.9.1-to-v0.9.2.md) -- Task-v2/task-v3 to task source v4 conversion, the durable-v4-family workflow boundary at executable `irVersion: 5`, and release behavior changes
6
6
  - [v0.9.2 release note](release-notes/0.9.2.md) -- Self-contained terminal upgrade summary shipped for `akm help migrate 0.9.2`
7
7
  - [v0.9.16 release note](release-notes/0.9.16.md) -- Source-bound scheduler grants, local execution authority, and split unsafe overrides
8
- - [v0.9.17 release note](release-notes/0.9.17.md) -- Tolerant readers, one config migration step, a plain scheduler list, less machinery, and the upgrade rehearsal gate
8
+ - [v0.9.17 release note](release-notes/0.9.17.md) -- Consolidate's retire proposals and index layout 26, replacing the LLM entity graph with declared links, and `akm improve` scoped to what retrieval actually returns
9
9
  - [v0.8 -> current v0.9 migration guide](v0.8-to-v0.9.md) -- Package upgrade with fresh current config/state and explicit task conversion
10
10
  - [v0.7 -> v0.8 migration guide](v0.7-to-v0.8.md) -- Task schema and 0.8-era changes
11
11
  - [v0.5 -> v0.6 migration guide](https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md) -- Terminology cut, registry schema v3, publisher changes