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.
- package/CHANGELOG.md +276 -2027
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +46 -1
- package/dist/commands/health/archive-usage.js +9 -15
- package/dist/commands/health/improve-metrics.js +25 -12
- package/dist/commands/improve/memory/memory-improve.js +8 -15
- package/dist/commands/sources/info.js +122 -18
- package/dist/commands/sources/stash-cli.js +21 -1
- package/dist/core/improve-result.js +6 -1
- package/dist/output/text/command-format.js +9 -0
- package/dist/scripts/akm-migrate-node.js +6 -0
- package/dist/scripts/akm-migrate.js +6 -0
- package/dist/sources/providers/git-stash.js +28 -0
- package/dist/storage/repositories/index-connection.js +5 -2
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.17.md +130 -41
- package/docs/migration/release-notes/README.md +7 -0
- package/docs/reference/cli.md +11 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 {
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|