akm-cli 0.9.17-alpha.9 → 0.9.17

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.
@@ -26,8 +26,17 @@
26
26
  * validation) — conservative by design.
27
27
  */
28
28
  import { UsageError } from "../core/errors.js";
29
+ import { warn } from "../core/warn.js";
29
30
  import { cittyComparableName, findCittyTopLevelCommandIndex, toAliasArray, } from "./invocation.js";
30
31
  import { retiredFlagHint } from "./retired-commands.js";
32
+ /**
33
+ * Commands that must never refuse on an unrecognized flag — `akm info` warns
34
+ * and continues instead of exiting 2, the same tolerance a bare `akm help
35
+ * --whatever` already gets for free from its group-command fallback (no
36
+ * resolved subcommand, so this gate stands down entirely). `info` is a leaf
37
+ * command, so it needs an explicit opt-in here instead.
38
+ */
39
+ const UNKNOWN_FLAG_TOLERANT_COMMANDS = new Set(["info"]);
31
40
  /** Flags citty implements itself, which no command declares. */
32
41
  const IMPLICIT_FLAGS = ["help", "h", "version", "v"];
33
42
  /**
@@ -172,6 +181,10 @@ function throwUnknownFlag(shown, attempted, known) {
172
181
  ? `Did you mean \`${suggestion}\`? Run the command with \`--help\` to see its accepted flags.`
173
182
  : undefined);
174
183
  }
184
+ /** The {@link UNKNOWN_FLAG_TOLERANT_COMMANDS} counterpart to {@link throwUnknownFlag}: report, don't refuse. */
185
+ function warnUnknownFlag(shown, known) {
186
+ warn(`[akm ${known.path.join(" ")}] ignoring unknown flag "${shown}" — run with --help to see its accepted flags.`);
187
+ }
175
188
  /**
176
189
  * Throw a {@link UsageError} naming the first flag the resolved command does
177
190
  * not declare. Returns silently when every flag is known.
@@ -195,6 +208,7 @@ export function assertKnownFlags(root, rawArgs) {
195
208
  const dynamicNamedFlagCommands = new Set(["workflow run", "task run", "task explain"]);
196
209
  const dynamicWorkflowParams = dynamicNamedFlagCommands.has(known.path.join(" "));
197
210
  const selfDiagnosed = SELF_DIAGNOSED_FLAGS.get(known.path.join(" "));
211
+ const tolerant = UNKNOWN_FLAG_TOLERANT_COMMANDS.has(known.path.join(" "));
198
212
  for (let i = 0; i < ownArgs.length; i += 1) {
199
213
  const token = ownArgs[i];
200
214
  // Not a flag: positional, a bare `-` (stdin), or a negative number.
@@ -209,8 +223,13 @@ export function assertKnownFlags(root, rawArgs) {
209
223
  for (let offset = 0; offset < shortFlags.length; offset += 1) {
210
224
  const rawName = shortFlags[offset];
211
225
  const candidate = cittyComparableName(rawName);
212
- if (!known.names.has(candidate))
226
+ if (!known.names.has(candidate)) {
227
+ if (tolerant) {
228
+ warnUnknownFlag(token, known);
229
+ break;
230
+ }
213
231
  throwUnknownFlag(token, `-${rawName}`, known);
232
+ }
214
233
  if (known.valueFlags.has(candidate)) {
215
234
  if (offset === shortFlags.length - 1)
216
235
  i += 1;
@@ -239,6 +258,10 @@ export function assertKnownFlags(root, rawArgs) {
239
258
  // frozen plan before a run is inserted. Short flags remain strict.
240
259
  if (dynamicWorkflowParams)
241
260
  continue;
261
+ if (tolerant) {
262
+ warnUnknownFlag(token.split("=")[0], known);
263
+ continue;
264
+ }
242
265
  throwUnknownFlag(token.split("=")[0], `--${rawName}`, known);
243
266
  }
244
267
  // Skip a declared value flag's value so `--reason "--x"` is not scanned.
package/dist/cli.js CHANGED
@@ -645,6 +645,24 @@ export function shouldBypassConfigStartup(argv) {
645
645
  const subcommand = args.slice(configIndex + 1).find((arg) => !arg.startsWith("-"));
646
646
  return subcommand === "path";
647
647
  }
648
+ /**
649
+ * Whether `argv` resolves to the top-level `info` command — used by
650
+ * `runCli` (and mirrored in `tests/_helpers/cli.ts`) to scope the startup
651
+ * config read's best-effort fallback to `info` alone. `info` is NOT on
652
+ * {@link shouldBypassConfigStartup}'s allowlist: unlike a bare bypass, it
653
+ * still reads a valid config's `output.format`/`output.detail` like any
654
+ * other command, it just must not be aborted by one it cannot read (see
655
+ * `assembleInfo`'s doc comment, src/commands/sources/info.ts, for why).
656
+ * Every other command reads config exactly as before — a broken config
657
+ * throws here and the command never runs.
658
+ */
659
+ export function isInfoCommand(argv) {
660
+ const userArgs = argv.slice(2);
661
+ const separator = userArgs.indexOf("--");
662
+ const args = separator === -1 ? userArgs : userArgs.slice(0, separator);
663
+ const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
664
+ return (commandIndex >= 0 ? args[commandIndex] : undefined) === "info";
665
+ }
648
666
  // ── Exit codes ──────────────────────────────────────────────────────────────
649
667
  // Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
650
668
  // the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
@@ -968,7 +986,34 @@ async function runCli() {
968
986
  try {
969
987
  applyEarlyStderrFlags(process.argv);
970
988
  const bypassConfig = shouldBypassConfigStartup(process.argv);
971
- initOutputMode(process.argv, bypassConfig ? (DEFAULT_CONFIG.output ?? {}) : (loadConfig().output ?? {}));
989
+ // Off the bypass allowlist, every command reads config here exactly as
990
+ // it always has: an invalid config.json throws, `emitJsonError` reports
991
+ // it, and the command never runs — no side effect of its own body ever
992
+ // happens (a lock taken, a network call made, a database opened
993
+ // read-write). `akm info` is the ONE exception (see `assembleInfo`'s
994
+ // doc comment, src/commands/sources/info.ts): only ITS read is
995
+ // best-effort, falling back to `DEFAULT_CONFIG.output` instead of
996
+ // throwing. Scoped narrowly on purpose — an earlier version of this fix
997
+ // made the read best-effort for every command, which silently changed
998
+ // outcomes across the CLI (some commands that should refuse at exit 78
999
+ // ran anyway; `health`/`index`/`config set`/`feedback` still failed,
1000
+ // but only after already taking a lock, opening a database read-write,
1001
+ // or making a network call).
1002
+ let outputDefaults = DEFAULT_CONFIG.output ?? {};
1003
+ if (!bypassConfig) {
1004
+ if (isInfoCommand(process.argv)) {
1005
+ try {
1006
+ outputDefaults = loadConfig().output ?? {};
1007
+ }
1008
+ catch {
1009
+ outputDefaults = DEFAULT_CONFIG.output ?? {};
1010
+ }
1011
+ }
1012
+ else {
1013
+ outputDefaults = loadConfig().output ?? {};
1014
+ }
1015
+ }
1016
+ initOutputMode(process.argv, outputDefaults);
972
1017
  }
973
1018
  catch (error) {
974
1019
  emitJsonError(error);
@@ -25,7 +25,7 @@ import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { MEMORY_ARCHIVE_REL } from "../../core/asset/memory-archive.js";
27
27
  import { toPosix } from "../../core/common.js";
28
- import { isGitBackedStash, tryListGitChangedPaths, tryListGitTrackedPaths, tryListGitUnverifiablePaths, } from "../../sources/providers/git-stash.js";
28
+ import { checkGitPathSafety, isGitBackedStash } from "../../sources/providers/git-stash.js";
29
29
  import { MAX_WALK_ENTRIES, sizeOfPath } from "./data-dir-usage.js";
30
30
  /**
31
31
  * Build the `memory-cleanup-archive` advisory, or `undefined` when there is
@@ -52,23 +52,17 @@ export function collectArchiveUsageAdvisory(stashDir) {
52
52
  }
53
53
  // Git-backed: the SAME three checks purgeGracedArchive runs (B1, G10) —
54
54
  // computed once here, not per file, and reused via `onFile` below instead
55
- // of a second walk of the same tree.
56
- const dirtyQuery = tryListGitChangedPaths(stashDir);
57
- const trackedQuery = tryListGitTrackedPaths(stashDir, MEMORY_ARCHIVE_REL);
58
- const unverifiableQuery = tryListGitUnverifiablePaths(stashDir, MEMORY_ARCHIVE_REL);
59
- // A failed git check here fails the same way purgeGracedArchive's own
60
- // sweep would: nothing in the archive can be proven purgeable, so every
61
- // byte counts as unpurgeable rather than guessing.
62
- const gitStateKnown = dirtyQuery.ok && trackedQuery.ok && unverifiableQuery.ok;
63
- const dirty = new Set(dirtyQuery.paths);
64
- const tracked = new Set(trackedQuery.paths);
65
- const unverifiable = new Set(unverifiableQuery.paths);
55
+ // of a second walk of the same tree. A failed git check here fails the
56
+ // same way purgeGracedArchive's own sweep would: nothing in the archive
57
+ // can be proven purgeable, so every byte counts as unpurgeable rather
58
+ // than guessing (`checkGitPathSafety`'s `isSafe` is always `false` when
59
+ // `ok` is `false`).
60
+ const gitSafety = checkGitPathSafety(stashDir, MEMORY_ARCHIVE_REL);
66
61
  let unpurgeableFiles = 0;
67
62
  let unpurgeableBytes = 0;
68
63
  const usage = sizeOfPath(archiveRoot, { remaining: MAX_WALK_ENTRIES }, (filePath, bytes) => {
69
64
  const key = toPosix(path.relative(stashDir, filePath));
70
- const safe = gitStateKnown && tracked.has(key) && !dirty.has(key) && !unverifiable.has(key);
71
- if (!safe) {
65
+ if (!gitSafety.isSafe(key)) {
72
66
  unpurgeableFiles++;
73
67
  unpurgeableBytes += bytes;
74
68
  }
@@ -92,7 +86,7 @@ export function collectArchiveUsageAdvisory(stashDir) {
92
86
  truncated: usage.truncated,
93
87
  unpurgeableFiles,
94
88
  unpurgeableBytes,
95
- gitStateKnown,
89
+ gitStateKnown: gitSafety.ok,
96
90
  },
97
91
  };
98
92
  }
@@ -53,6 +53,25 @@ export function countAgentFailureReasons(agentFailures) {
53
53
  }
54
54
  return counts;
55
55
  }
56
+ /**
57
+ * Decode one `improve_runs.result_json` envelope, warning once per row on
58
+ * failure (mirrors {@link taskFailureDetail}'s handling of the analogous
59
+ * `task_history` case) and returning `undefined` instead of throwing.
60
+ * Callers count the `undefined` case themselves (`resultRows.skipped.invalid`
61
+ * / `resultStatus: "invalid"`) so a decode failure is never silent — the
62
+ * warning names *why* (corrupt data, or a decoder too strict for a shape an
63
+ * older release legitimately wrote), the counters say *how many*.
64
+ */
65
+ function decodeImproveResultRow(row) {
66
+ try {
67
+ return decodeImproveResult(row.result_json).envelope;
68
+ }
69
+ catch (error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ console.warn(`[akm] Skipping unparseable improve_runs row in health metrics (id=${row.id}, started_at=${row.started_at}): ${message}`);
72
+ return undefined;
73
+ }
74
+ }
56
75
  /** A zeroed accumulator — also what health reports when it could not read state.db at all (#791). */
57
76
  export function emptyImproveMetrics() {
58
77
  return {
@@ -273,11 +292,8 @@ export function summarizeImproveRuns(db, since, until) {
273
292
  // newest complete run's snapshot (current state) — not a sum across runs.
274
293
  let latest;
275
294
  for (const row of rows) {
276
- let result;
277
- try {
278
- result = decodeImproveResult(row.result_json).envelope;
279
- }
280
- catch {
295
+ const result = decodeImproveResultRow(row);
296
+ if (!result) {
281
297
  resultRows.skipped.invalid += 1;
282
298
  continue;
283
299
  }
@@ -298,13 +314,10 @@ export function summarizeImproveRuns(db, since, until) {
298
314
  }
299
315
  /** Project an improve_runs row + wall time + task attribution into one {@link ImproveRunSummary}. */
300
316
  export function projectImproveRunSummary(row, wallTimeMs, taskId) {
301
- let result = {};
302
- let resultStatus = "invalid";
303
- try {
304
- result = decodeImproveResult(row.result_json).envelope;
305
- resultStatus = "valid";
306
- }
307
- catch {
317
+ const decoded = decodeImproveResultRow(row);
318
+ const result = decoded ?? {};
319
+ const resultStatus = decoded ? "valid" : "invalid";
320
+ if (!decoded) {
308
321
  // Keep the persisted row visible in per-run output, but do not project its
309
322
  // unknown payload or admit its duration to result-derived denominators.
310
323
  wallTimeMs = 0;
@@ -12,7 +12,7 @@ import { DERIVED_SUFFIX } from "../../../core/recognition-util.js";
12
12
  import { warn } from "../../../core/warn.js";
13
13
  import { recordWrittenPath } from "../../../core/write-provenance.js";
14
14
  import { walkMarkdownFiles } from "../../../indexer/walk/walker.js";
15
- import { isGitBackedStash, tryListGitChangedPaths, tryListGitTrackedPaths, tryListGitUnverifiablePaths, } from "../../../sources/providers/git-stash.js";
15
+ import { checkGitPathSafety, isGitBackedStash } from "../../../sources/providers/git-stash.js";
16
16
  import { contentHash } from "../content-hash.js";
17
17
  import { isDerivedMemory, memoryIdentityRef, parseMemoryName, resolveParentRef } from "./derived-ref.js";
18
18
  export function analyzeMemoryCleanup(stashDir, options = {}) {
@@ -665,20 +665,16 @@ export function purgeGracedArchive(stashDir, now = new Date()) {
665
665
  // sweep rather than silently trusting whichever check happened to
666
666
  // succeed — a `dirty`/`unverifiable` set that came back empty ONLY
667
667
  // because the call failed must never read as "nothing to protect".
668
- const dirtyQuery = tryListGitChangedPaths(stashDir);
669
- const trackedQuery = tryListGitTrackedPaths(stashDir, MEMORY_ARCHIVE_REL);
670
- const unverifiableQuery = tryListGitUnverifiablePaths(stashDir, MEMORY_ARCHIVE_REL);
671
- if (!dirtyQuery.ok || !trackedQuery.ok || !unverifiableQuery.ok) {
668
+ // G10: assume-unchanged / skip-worktree files never show up as dirty even
669
+ // when genuinely modified — `checkGitPathSafety` treats them the same as
670
+ // "not tracked" below, so such a file (and its whole retirement) is left
671
+ // for a later sweep.
672
+ const gitSafety = checkGitPathSafety(stashDir, MEMORY_ARCHIVE_REL);
673
+ if (!gitSafety.ok) {
672
674
  warn(`[improve] archive purge: skipped this sweep — could not determine the archive's git state at ${stashDir} ` +
673
675
  "(git status/ls-files failed); nothing was purged.");
674
676
  return EMPTY_ARCHIVE_PURGE_RESULT;
675
677
  }
676
- const dirty = new Set(dirtyQuery.paths);
677
- const tracked = new Set(trackedQuery.paths);
678
- // G10: assume-unchanged / skip-worktree files never show up as dirty even
679
- // when genuinely modified — treated the same as "not tracked" below, so
680
- // such a file (and its whole retirement) is left for a later sweep.
681
- const unverifiable = new Set(unverifiableQuery.paths);
682
678
  let purgedDirs = 0;
683
679
  let purgedFiles = 0;
684
680
  for (const entry of entries) {
@@ -705,10 +701,7 @@ export function purgeGracedArchive(stashDir, now = new Date()) {
705
701
  if (!Number.isFinite(retiredMs) || retiredMs >= cutoffMs)
706
702
  continue; // "more than" the grace period — exactly at it is not enough
707
703
  const allFiles = listFilesRecursive(dir); // tombstone included — the whole entry must be a clean, committed unit
708
- const isSafeToPurge = allFiles.every((filePath) => {
709
- const key = toPosix(path.relative(stashDir, filePath));
710
- return tracked.has(key) && !dirty.has(key) && !unverifiable.has(key);
711
- });
704
+ const isSafeToPurge = allFiles.every((filePath) => gitSafety.isSafe(toPosix(path.relative(stashDir, filePath))));
712
705
  if (!isSafeToPurge)
713
706
  continue; // untracked, modified, or unverifiable entry — skip the whole directory this sweep (B1, G10)
714
707
  let children;
@@ -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
  }
@@ -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
  }
@@ -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) {