akm-cli 0.10.261019 → 0.10.261021

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261021] - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - `akm upgrade --channel latest|next` switches the CLI and installed Claude,
14
+ Codex and OpenCode plugins together, preserving unrelated native settings and
15
+ writing backups. `--check` shows channel mismatches; an older target requires
16
+ `--allow-downgrade` (#1285).
17
+
18
+ ### Changed
19
+
20
+ - `akm metrics` replaces `usage.selectRate` with `usage.selectsPerSearch` and
21
+ adds `usage.totals.searchesWithHits`. Selects carry no source or search id, so
22
+ the figure is an unbounded ratio, not a conversion percentage: eight selects
23
+ after one search read 8.00, not 800% (#1293).
24
+
25
+ ### Fixed
26
+
27
+ - A model call whose configured deadline is longer than five minutes is no
28
+ longer cut off at five minutes by Bun's socket-idle timer (#1295).
29
+ - Workflow params that are ordinary file paths (absolute, relative, Windows,
30
+ with dates, digits or a UUID directory) are no longer warned about or redacted
31
+ as secrets; credential-named keys, known token prefixes and token-shaped path
32
+ segments still are (#1292).
33
+ - `akm bundle add --writable` on a bundle installed read-only from Git makes a
34
+ fresh checkout instead of failing with "is not a checkout", keeping the
35
+ bundle's name, components, order and credential (#1296).
36
+ - `akm health` checks every configured git bundle for tracked `env/` or
37
+ `secrets/` files with a remote, not only the working stash, and names the
38
+ bundle in each warning. A bundle whose `.gitignore` carries
39
+ `# akm: intentionally track env and secrets` is not reported; the warning
40
+ names that line as the way to acknowledge intentional tracking (#1291).
41
+ - `akm health`'s `type-directory-disagreement` warning uses the indexer's own
42
+ directory rules, so `scripts/README.md` or `knowledge/example.sh` no longer
43
+ warn, and it checks only `akm`-adapter entries inside their component root (#1288).
44
+ - `akm metrics` reports token usage as unknown, with the number of calls that
45
+ reported it, instead of zero when the provider sent none. A call reporting
46
+ prompt and completion tokens but no total now counts their sum in the LLM
47
+ usage aggregate `akm health` shares (#1294).
48
+ - Claude plugin health uses native installation, scope and enabled metadata
49
+ instead of choosing the highest cached version. It reads `AKM_MIN_VERSION`
50
+ as well as the legacy range, and reports uncertain selections as unknown (#1286).
51
+ - Repeated fenced identifiers contribute once per document to search content,
52
+ reducing repetition bias while retaining distinct code names. Existing
53
+ content projections and embedding inputs refresh on the next index pass (#1284).
54
+
9
55
  ## [0.10.261019] - 2026-10-29
10
56
 
11
57
  The first stable release of the 0.10 line, published to npm `latest`. Its
package/STABILITY.md CHANGED
@@ -4,14 +4,6 @@
4
4
  **with one caveat**: while the version is 0.x, minor releases (0.x → 0.x+1)
5
5
  may include breaking changes.
6
6
 
7
- **0.9.x series exception.** The 0.9.x releases are a deliberate refactoring
8
- and clean-up series: the goal is to pay off all remaining technical debt and
9
- land every planned breaking change before 0.10. While that work completes,
10
- **0.9.x patch releases may also contain breaking changes** — each one called
11
- out in the CHANGELOG with a migration note. The 0.10.x series returns to bug
12
- fixes and tuning, and aims to restore the normal discipline of breaking
13
- changes only in major and minor releases.
14
-
15
7
  **Version floor (0.10, #1091).** 0.10 reads what 0.9.15 and later wrote: config,
16
8
  `state.db`, task sources, frozen workflow plans and scheduler rows. It does not
17
9
  convert anything older. An install on an earlier release runs `akm migrate
@@ -363,17 +363,19 @@ function aggregate(rows, opts) {
363
363
  // and carry no ref, source or event type, so only the date range applies.
364
364
  function aggregateLlm(rows, f) {
365
365
  var by = { stage: Object.create(null), process: Object.create(null), engine: Object.create(null) }, daily = new Map();
366
- var t = { calls: 0, tokens: 0, failures: 0, durationMs: 0 };
366
+ var t = { calls: 0, tokens: 0, failures: 0, durationMs: 0, usageCalls: 0 };
367
367
  rows.forEach(function (r) {
368
368
  var d = dayOf(r.day);
369
369
  if (f.from && d < f.from) return;
370
370
  if (f.to && d > f.to) return;
371
371
  var tok = tokensOf(r), calls = r.calls || 0, failed = r.outcome === 'error' ? calls : 0;
372
- t.calls += calls; t.tokens += tok; t.durationMs += r.durationMs || 0; t.failures += failed;
372
+ // Rows from a report that predates usage coverage count as fully measured.
373
+ var measured = r.usageCalls != null ? r.usageCalls : calls;
374
+ t.calls += calls; t.tokens += tok; t.durationMs += r.durationMs || 0; t.failures += failed; t.usageCalls += measured;
373
375
  [['stage', r.stage], ['process', r.process], ['engine', r.engine]].forEach(function (p) {
374
376
  var k = p[1] || 'unattributed';
375
- var e = by[p[0]][k] || (by[p[0]][k] = { name: k, calls: 0, tokens: 0, failures: 0 });
376
- e.calls += calls; e.tokens += tok; e.failures += failed;
377
+ var e = by[p[0]][k] || (by[p[0]][k] = { name: k, calls: 0, tokens: 0, failures: 0, usageCalls: 0 });
378
+ e.calls += calls; e.tokens += tok; e.failures += failed; e.usageCalls += measured;
377
379
  });
378
380
  var day = daily.get(d) || { day: d, calls: 0, durationMs: 0 };
379
381
  day.calls += calls; day.durationMs += r.durationMs || 0;
@@ -621,7 +623,7 @@ function toCsv(columns, rows) {
621
623
  var agg = useRows ? aggregateLlm(LLM, { from: state.from, to: state.to }) : null;
622
624
  $('llm-hint').textContent = useRows ? 'follows the date range only' : 'window-wide aggregate (no per-call rows)';
623
625
  function fromAggregate(o) {
624
- return Object.keys(o || {}).map(function (k) { return { name: k, tokens: o[k].totalTokens, calls: o[k].calls, failures: o[k].failures }; })
626
+ return Object.keys(o || {}).map(function (k) { return { name: k, tokens: o[k].totalTokens, calls: o[k].calls, failures: o[k].failures, usageCalls: o[k].usageCalls != null ? o[k].usageCalls : o[k].calls }; })
625
627
  .sort(function (a, b) { return b.tokens - a.tokens; });
626
628
  }
627
629
  var dims = [
@@ -633,7 +635,7 @@ function toCsv(columns, rows) {
633
635
  var list = d[1].slice(0, 15);
634
636
  if (!hasData(d[0], list.length > 0, 'No LLM calls recorded')) return;
635
637
  chart(d[0], Object.assign(barOption(list.map(function (x) { return x.name; }),
636
- [{ name: 'tokens', type: 'bar', itemStyle: { color: css('--purple') }, data: list.map(function (x) { return x.tokens; }) }], true), {}));
638
+ [{ name: 'tokens', type: 'bar', itemStyle: { color: css('--purple') }, data: list.map(function (x) { return x.calls > 0 && x.usageCalls === 0 ? null : x.tokens; }) }], true), {}));
637
639
  });
638
640
  if (hasData('ch-llm-time', useRows && agg.daily.length > 0, 'No per-call rows in this report')) {
639
641
  var days = agg.daily.map(function (d) { return d.day; });
@@ -744,15 +746,19 @@ function toCsv(columns, rows) {
744
746
  var t = agg.totals;
745
747
  var tokens = llm ? llm.totals.tokens : DATA.llm.totalTokens;
746
748
  var calls = llm ? llm.totals.calls : DATA.llm.calls;
749
+ var usageCalls = llm ? llm.totals.usageCalls : (DATA.llm.usageCalls != null ? DATA.llm.usageCalls : calls);
750
+ // Calls whose provider sent no usage are unknown, not zero tokens.
751
+ var llmSub = fmtN(calls) + ' calls' + (usageCalls < calls ? ', ' + fmtN(usageCalls) + ' reported usage' : '');
747
752
  var tasks = DATA.tasks || {};
748
753
  var cards = [
749
754
  ['Searches', fmtN(t.searches), fmtN(t.curates) + ' curates', ''],
750
755
  ['Shows', fmtN(t.shows), '', ''],
751
- ['Select rate', fmtPct(DATA.usage.selectRate), 'window-wide', ''],
756
+ ['Selects per search', DATA.usage.selectsPerSearch == null ? '—' : DATA.usage.selectsPerSearch.toFixed(2),
757
+ fmtN(DATA.usage.totals.selects) + ' selects / ' + fmtN(DATA.usage.totals.searchesWithHits) + ' searches with hits, window-wide, selects not source-filtered', ''],
752
758
  ['Zero-result rate', fmtPct(t.searches ? t.zeroResult / t.searches : null), fmtN(t.zeroResult) + ' searches', t.zeroResult ? 'yellow' : ''],
753
759
  ['Feedback', '<span class="pos">+' + fmtN(t.positive) + '</span> / <span class="neg">−' + fmtN(t.negative) + '</span>',
754
760
  'net ' + (t.positive - t.negative >= 0 ? '+' : '−') + Math.abs(t.positive - t.negative), ''],
755
- ['LLM tokens', fmtN(tokens), fmtN(calls) + ' calls', ''],
761
+ ['LLM tokens', calls > 0 && usageCalls === 0 ? 'unknown' : fmtN(tokens), llmSub, usageCalls < calls ? 'yellow' : ''],
756
762
  ['Task fail rate', fmtPct(tasks.failRate), fmtN(tasks.failed) + ' of ' + fmtN(tasks.runs) + ' runs, window-wide', tasks.failed ? 'red' : '']
757
763
  ];
758
764
  $('kpis').innerHTML = cards.map(function (c) {
@@ -70,10 +70,10 @@ function metricsSection(m) {
70
70
  ${tile("Searches", fmtNum(u.totals.searches), `${fmtNum(u.totals.zeroResultSearches)} with no results`)}
71
71
  ${tile("Shows", fmtNum(u.totals.shows), `${fmtNum(u.totals.distinctAssets)} distinct assets`)}
72
72
  ${tile("Curates", fmtNum(u.totals.curates))}
73
- ${tile("Select rate", fmtPct(u.selectRate), `${fmtNum(u.totals.selects)} selects`)}
73
+ ${tile("Selects per search", u.selectsPerSearch == null ? "—" : u.selectsPerSearch.toFixed(2), `${fmtNum(u.totals.selects)} selects / ${fmtNum(u.totals.searchesWithHits)} searches with hits`)}
74
74
  ${tile("Median search", fmtMs(u.searchMedianMs))}
75
75
  ${tile("Feedback", `+${fmtNum(m.feedback.totals.positive)} / -${fmtNum(m.feedback.totals.negative)}`, "positive / negative")}
76
- ${tile("LLM calls", fmtNum(m.llm.calls), `${fmtNum(m.llm.failures)} failed, ${fmtNum(m.llm.totalTokens)} tokens`)}
76
+ ${tile("LLM calls", fmtNum(m.llm.calls), `${fmtNum(m.llm.failures)} failed, ${m.llm.calls > 0 && m.llm.usageCalls === 0 ? "unknown" : fmtNum(m.llm.totalTokens)} tokens`)}
77
77
  ${tile("Pending proposals", fmtNum(m.proposals.byStatus.pending ?? 0))}
78
78
  ${tile("Task runs", fmtNum(m.tasks.runs), `${fmtNum(m.tasks.failed)} failed`)}
79
79
  ${tile("Index builds", fmtNum(m.index.runs), `median ${fmtMs(m.index.medianMs)}`)}
@@ -21,6 +21,13 @@ function emptyLlmUsageStageAggregate() {
21
21
  failures: 0,
22
22
  };
23
23
  }
24
+ /** True when the provider response carried any token count. Absent usage is unknown, not zero. */
25
+ export function hasTokenUsage(record) {
26
+ return (record.promptTokens !== undefined ||
27
+ record.completionTokens !== undefined ||
28
+ record.totalTokens !== undefined ||
29
+ record.reasoningTokens !== undefined);
30
+ }
24
31
  /** A zeroed aggregate — also the value health reports when it could not read state.db at all (#791). */
25
32
  export function emptyLlmUsageAggregate() {
26
33
  return { ...emptyLlmUsageStageAggregate(), byStage: {}, byProcess: {}, byEngine: {} };
@@ -28,7 +35,7 @@ export function emptyLlmUsageAggregate() {
28
35
  /**
29
36
  * Aggregate `llm_usage` events (#576) into a window total plus a per-stage
30
37
  * breakdown of call count, wall-time, and token usage. Token fields absent from
31
- * a best-effort record contribute 0. Calls with no `stage` land under
38
+ * a best-effort record add nothing and the call is left out of `usageCalls`. Calls with no `stage` land under
32
39
  * {@link UNATTRIBUTED_STAGE}.
33
40
  */
34
41
  export function summarizeLlmUsage(events) {
@@ -47,11 +54,18 @@ export function summarizeLlmUsage(events) {
47
54
  dimensions.push(groups[key]);
48
55
  }
49
56
  for (const target of [aggregate, ...dimensions]) {
57
+ target.usageCalls ??= 0;
58
+ target.reasoningCalls ??= 0;
50
59
  target.calls += 1;
60
+ if (hasTokenUsage(record))
61
+ target.usageCalls += 1;
62
+ if (record.reasoningTokens !== undefined)
63
+ target.reasoningCalls += 1;
51
64
  target.totalDurationMs += record.durationMs;
52
65
  target.promptTokens += record.promptTokens ?? 0;
53
66
  target.completionTokens += record.completionTokens ?? 0;
54
- target.totalTokens += record.totalTokens ?? 0;
67
+ // A call without a total counts as prompt + completion, as the per-day metrics rows do.
68
+ target.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
55
69
  target.reasoningTokens += record.reasoningTokens ?? 0;
56
70
  if (record.outcome === "error")
57
71
  target.failures += 1;
@@ -2,51 +2,11 @@
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
4
  /**
5
- * `plugin-version` advisory for `akm health` (itlackey/akm#832).
6
- *
7
- * #828 was filed as a CLI bug — session extraction failing on 232/234 runs —
8
- * and took a full investigation to resolve. The actual cause: the harness
9
- * plugin (`akm@akm-plugins`, installed in Claude Code's plugin cache) was
10
- * three days stale relative to the fix, and its own `AKM_VERSION_RANGE` gate
11
- * had nothing to do with it — the plugin was simply running old code. Every
12
- * fact needed to reach that conclusion in one step was already on disk:
13
- *
14
- * - installed plugin version: `~/.claude/plugins/cache/<marketplace>/akm/<version>/.claude-plugin/plugin.json`
15
- * - the plugin's own akm-cli compatibility contract: `<pluginDir>/shared/akm-version.ts`'s `AKM_VERSION_RANGE`
16
- * - the running CLI's version: `../../version.ts`'s `pkgVersion`
17
- *
18
- * Nothing correlated them, so a stale plugin was indistinguishable from a
19
- * broken CLI. This module closes that gap with three checks, one per
20
- * detected plugin:
21
- *
22
- * 1. report the installed version;
23
- * 2. compare it against the newest tag published to the plugin's git
24
- * remote, and warn (naming the update command) when behind;
25
- * 3. the sharp one — check whether the *installed plugin's* declared
26
- * `AKM_VERSION_RANGE` admits the *running* CLI version. When it does
27
- * not, the plugin has silently disabled itself (both surfaces log
28
- * `version_out_of_range` / `akm_version_mismatch` and degrade quietly)
29
- * and there was previously no way to know that from the CLI side.
30
- *
31
- * Read-only: this never fetches, writes, or mutates the plugin cache or
32
- * marketplace clone. Check 2 is one of two deliberate exceptions to "`akm
33
- * health` makes no network call" (see `./health-advisories.md`; the other is
34
- * the `cli-version` advisory in `./version-drift.ts`, #950): a plugin's
35
- * local marketplace clone is not proof of what is newest upstream — the
36
- * incident above involved a clone that hadn't seen the fix's tag at all — so
37
- * the only way to ever detect drift is to ask the remote what tags exist.
38
- * That query is a `git ls-remote --symref` (lists refs; fetches nothing,
39
- * writes nothing) with a short timeout, and any failure (offline, no
40
- * remote, timeout) degrades to "installed version reported, no staleness
41
- * claim" rather than a false positive or a hang. Like `cli-version`, this
42
- * check is gated behind `--probe`/`--no-probe`: with probing off the remote is
43
- * not asked, the row reports `unknown` ("not probed") and only the local range
44
- * check (3) runs.
45
- *
46
- * Every collector here is best-effort and silent on missing/unreadable
47
- * input: no Claude plugin installed, no marketplace clone, an unreadable
48
- * manifest, or a malformed version range must never crash `akm health` and
49
- * must never produce a false "stale" or "inactive" warning.
5
+ * Report the installations Claude's native listing selects, not the versions
6
+ * left in its cache. The listing owns scope and merged enabled settings; it
7
+ * cannot prove what an already-running session has loaded. Ambiguous scoped
8
+ * installations and unavailable compatibility contracts are reported as unknown.
9
+ * Remote release checks remain optional under --no-probe.
50
10
  */
51
11
  import { spawnSync } from "node:child_process";
52
12
  import fs from "node:fs";
@@ -64,7 +24,9 @@ import { openCodeConfigSpec } from "../sources/plugin-upgrade.js";
64
24
  * `AKM_CLAUDE_PROJECTS_DIR` in `../../integrations/harnesses/claude/session-log.ts`.
65
25
  */
66
26
  function claudePluginsDir() {
67
- return process.env.AKM_CLAUDE_PLUGINS_DIR ?? path.join(os.homedir(), ".claude", "plugins");
27
+ return (process.env.AKM_CLAUDE_PLUGINS_DIR ??
28
+ process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ??
29
+ path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "plugins"));
68
30
  }
69
31
  /**
70
32
  * Root of OpenCode's package cache, same override pattern as
@@ -159,74 +121,90 @@ function buildOpencodeBundledAdvisory(plugin, bundled, cliVersion) {
159
121
  evidence: bundled.spec ? { bundledVersion, cliVersion, spec: bundled.spec } : { bundledVersion, cliVersion },
160
122
  };
161
123
  }
162
- /**
163
- * Scan `<pluginsRoot>/cache/<marketplace>/akm/<version>/` for every
164
- * `akm` plugin cache entry, picking the highest cached version per
165
- * marketplace when more than one is present. Returns `[]` (never throws)
166
- * when the cache directory is absent, empty, or unreadable — that is the
167
- * ordinary "no Claude plugin installed" case, not an error.
168
- */
169
124
  function detectInstalledPlugins(pluginsRoot) {
170
- const cacheDir = path.join(pluginsRoot, "cache");
171
- let marketplaces;
125
+ if (!fs.existsSync(path.join(pluginsRoot, "installed_plugins.json")) &&
126
+ !fs.existsSync(path.join(pluginsRoot, "cache")))
127
+ return [];
172
128
  try {
173
- marketplaces = fs.readdirSync(cacheDir);
129
+ const listed = spawnSync("claude", ["plugin", "list", "--json"], {
130
+ encoding: "utf8",
131
+ timeout: 30_000,
132
+ env: {
133
+ ...process.env,
134
+ ...(process.env.AKM_CLAUDE_PLUGINS_DIR
135
+ ? { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR ?? path.dirname(pluginsRoot) }
136
+ : {}),
137
+ CLAUDE_CODE_PLUGIN_CACHE_DIR: pluginsRoot,
138
+ },
139
+ });
140
+ if (listed.status !== 0)
141
+ return undefined;
142
+ const rows = JSON.parse(listed.stdout);
143
+ if (!Array.isArray(rows))
144
+ return undefined;
145
+ const detected = rows.flatMap((row) => {
146
+ if (typeof row?.id !== "string" || !row.id.startsWith("akm@"))
147
+ return [];
148
+ const applicable = typeof row.projectPath !== "string" ||
149
+ path.resolve(process.cwd()) === path.resolve(row.projectPath) ||
150
+ path.resolve(process.cwd()).startsWith(`${path.resolve(row.projectPath)}${path.sep}`);
151
+ return [
152
+ {
153
+ harness: "claude",
154
+ marketplace: row.id.slice(4),
155
+ pluginName: "akm",
156
+ version: typeof row.folderVersion === "string"
157
+ ? row.folderVersion
158
+ : typeof row.version === "string"
159
+ ? row.version
160
+ : "unknown",
161
+ pluginDir: typeof row.readFromFolder === "string"
162
+ ? row.readFromFolder
163
+ : typeof row.installPath === "string"
164
+ ? row.installPath
165
+ : "",
166
+ applicable,
167
+ selectionKnown: false,
168
+ scope: typeof row.scope === "string" ? row.scope : "unknown",
169
+ enabled: typeof row.enabled === "boolean" ? row.enabled : undefined,
170
+ ...(typeof row.projectPath === "string" ? { projectPath: row.projectPath } : {}),
171
+ },
172
+ ];
173
+ });
174
+ for (const plugin of detected) {
175
+ const applicable = detected.filter((p) => p.marketplace === plugin.marketplace && p.applicable);
176
+ plugin.selectionKnown =
177
+ plugin.applicable &&
178
+ isExactSemver(plugin.version) &&
179
+ plugin.scope !== "unknown" &&
180
+ plugin.pluginDir !== "" &&
181
+ new Set(applicable.map((p) => `${p.version}:${p.pluginDir}`)).size === 1;
182
+ }
183
+ return detected;
174
184
  }
175
185
  catch {
176
- return [];
177
- }
178
- const detected = [];
179
- for (const marketplace of marketplaces) {
180
- const pluginDir = path.join(cacheDir, marketplace, "akm");
181
- let versions;
182
- try {
183
- versions = fs.readdirSync(pluginDir).filter(isExactSemver);
184
- }
185
- catch {
186
- continue;
187
- }
188
- if (versions.length === 0)
189
- continue;
190
- const latest = highestVersion(versions);
191
- if (!latest)
192
- continue;
193
- const versionDir = path.join(pluginDir, latest);
194
- const manifestPath = path.join(versionDir, ".claude-plugin", "plugin.json");
195
- try {
196
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
197
- if (typeof manifest.version !== "string")
198
- continue;
199
- detected.push({
200
- harness: "claude",
201
- marketplace,
202
- pluginName: "akm",
203
- version: manifest.version,
204
- pluginDir: versionDir,
205
- });
206
- }
207
- catch {
208
- // Unreadable/malformed manifest — skip this entry rather than crash.
209
- }
186
+ return undefined;
210
187
  }
211
- return detected;
212
188
  }
213
- /**
214
- * Extract `AKM_VERSION_RANGE` from the installed plugin's vendored
215
- * `shared/akm-version.ts`. Returns `undefined` when the file is missing,
216
- * unreadable, or does not contain the expected declaration — callers must
217
- * treat that as "compatibility unknown", never as a mismatch.
218
- */
219
- function readVersionRange(pluginDir) {
220
- const versionFilePath = path.join(pluginDir, "shared", "akm-version.ts");
189
+ /** Read the contract, never execute plugin source. A minimum admits later prereleases too. */
190
+ function readCompatibility(pluginDir, cliVersion) {
221
191
  let text;
222
192
  try {
223
- text = fs.readFileSync(versionFilePath, "utf8");
193
+ text = fs.readFileSync(path.join(pluginDir, "shared", "akm-version.ts"), "utf8");
224
194
  }
225
195
  catch {
226
- return undefined;
196
+ return {};
197
+ }
198
+ const minimum = text.match(/export\s+const\s+AKM_MIN_VERSION\s*=\s*["']([^"']+)["']/)?.[1];
199
+ if (minimum !== undefined) {
200
+ if (!isExactSemver(minimum) || !isExactSemver(cliVersion))
201
+ return {};
202
+ return { versionRange: `>=${minimum}`, admitted: highestVersion([minimum, cliVersion]) === cliVersion };
227
203
  }
228
- const match = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/);
229
- return match?.[1];
204
+ const range = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/)?.[1];
205
+ if (!range || !isSemverRange(range) || !isExactSemver(cliVersion))
206
+ return {};
207
+ return { versionRange: range, admitted: satisfiesRange(cliVersion, range) };
230
208
  }
231
209
  const LS_REMOTE_TIMEOUT_MS = 5_000;
232
210
  /**
@@ -311,7 +289,17 @@ export function collectPluginStalenessAdvisories(options) {
311
289
  const listRemoteTags = options.listRemoteTags ?? realListRemoteTags;
312
290
  const plugins = detectInstalledPlugins(pluginsRoot);
313
291
  const probe = options.probe ?? true;
314
- const results = plugins.map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags, probe));
292
+ const results = (plugins ?? []).map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags, probe));
293
+ if (plugins === undefined && fs.existsSync(pluginsRoot)) {
294
+ results.push({
295
+ name: "plugin-version",
296
+ kind: "deterministic",
297
+ status: "unknown",
298
+ confidence: "high",
299
+ message: "Claude plugin installation and enabled state could not be resolved with `claude plugin list --json`; cached versions do not establish the selected installation.",
300
+ evidence: { harness: "claude", installedVersion: null, enabled: null, admitted: null },
301
+ });
302
+ }
315
303
  const opencodeCacheRoot = options.opencodeCacheRoot ?? defaultOpencodeCacheRoot();
316
304
  for (const plugin of OPENCODE_PLUGIN_LOCATIONS) {
317
305
  for (const bundled of findBundledAkm(plugin, opencodeCacheRoot)) {
@@ -326,7 +314,9 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
326
314
  const marketplaceDir = path.join(pluginsRoot, "marketplaces", plugin.marketplace);
327
315
  let availableVersion;
328
316
  try {
329
- const tags = probe && fs.existsSync(marketplaceDir) ? listRemoteTags(marketplaceDir) : undefined;
317
+ const tags = probe && plugin.selectionKnown && plugin.enabled !== false && fs.existsSync(marketplaceDir)
318
+ ? listRemoteTags(marketplaceDir)
319
+ : undefined;
330
320
  availableVersion = tags && tags.length > 0 ? highestVersion(tags) : undefined;
331
321
  }
332
322
  catch {
@@ -336,10 +326,20 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
336
326
  plugin.version !== availableVersion &&
337
327
  highestVersion([plugin.version, availableVersion]) === availableVersion;
338
328
  // Point 3: does the plugin's own declared range admit the running CLI?
339
- const versionRange = readVersionRange(plugin.pluginDir);
340
- const rangeKnown = versionRange !== undefined && isSemverRange(versionRange);
341
- const admitted = rangeKnown ? satisfiesRange(cliVersion, versionRange) : undefined;
342
- const messageParts = [`${pluginRef}: installed ${plugin.version}`];
329
+ const { versionRange, admitted } = readCompatibility(plugin.pluginDir, cliVersion);
330
+ const messageParts = [
331
+ `${pluginRef}: installed ${plugin.version} (${plugin.scope}${plugin.projectPath ? `: ${plugin.projectPath}` : ""})`,
332
+ ];
333
+ if (!plugin.applicable)
334
+ messageParts.push("installed for another project; not selected in this directory");
335
+ else if (!plugin.selectionKnown)
336
+ messageParts.push("effective selection unknown across native installations");
337
+ if (plugin.enabled === false)
338
+ messageParts.push("disabled in Claude settings");
339
+ else if (plugin.enabled === undefined)
340
+ messageParts.push("enabled state unknown");
341
+ if (admitted === undefined)
342
+ messageParts.push("CLI compatibility unknown");
343
343
  if (!probe)
344
344
  messageParts.push("newest tag was not probed");
345
345
  if (availableVersion !== undefined) {
@@ -347,15 +347,23 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
347
347
  }
348
348
  if (stale)
349
349
  messageParts.push(`-> claude plugin update ${pluginRef}`);
350
- if (rangeKnown && admitted === false) {
351
- messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED (plugin is inactive)`);
350
+ if (plugin.selectionKnown && plugin.enabled === true && admitted === false) {
351
+ messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED by the plugin compatibility contract`);
352
352
  }
353
353
  return {
354
354
  name: "plugin-version",
355
355
  kind: "deterministic",
356
356
  // Not probed => staleness is unknown, not "up to date"; only the local
357
357
  // range check can still warn.
358
- status: stale || admitted === false ? "warn" : probe ? "pass" : "unknown",
358
+ status: plugin.enabled === false || !plugin.applicable
359
+ ? "pass"
360
+ : !plugin.selectionKnown || plugin.enabled === undefined
361
+ ? "unknown"
362
+ : stale || admitted === false
363
+ ? "warn"
364
+ : probe && availableVersion !== undefined && admitted !== undefined && plugin.enabled === true
365
+ ? "pass"
366
+ : "unknown",
359
367
  confidence: "high",
360
368
  message: messageParts.join(" — "),
361
369
  evidence: {
@@ -363,6 +371,12 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
363
371
  marketplace: plugin.marketplace,
364
372
  plugin: plugin.pluginName,
365
373
  installedVersion: plugin.version,
374
+ scope: plugin.scope,
375
+ applicable: plugin.applicable,
376
+ selectionKnown: plugin.selectionKnown,
377
+ projectPath: plugin.projectPath ?? null,
378
+ enabled: plugin.enabled ?? null,
379
+ installPath: plugin.pluginDir,
366
380
  availableVersion: availableVersion ?? null,
367
381
  ...(probe ? {} : { probed: false }),
368
382
  stale,
@@ -9,18 +9,35 @@
9
9
  * is when secret assets are git-TRACKED **and** a remote is configured — only
10
10
  * then can a `git push` exfiltrate tokens/keys. Warn on exactly that
11
11
  * combination; stay silent on the tracked-but-no-remote opt-in so the advisory
12
- * catches the exposure without nagging the intentional backup.
12
+ * catches the exposure without nagging the intentional backup. A stash whose
13
+ * `.gitignore` carries the intentional-tracking marker has acknowledged the
14
+ * risk, so it is not warned about at all.
13
15
  */
14
16
  import { spawnSync } from "node:child_process";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { STASH_GITIGNORE_TRACK_SECRETS_MARKER } from "../sources/stash-skeleton.js";
15
20
  const realGit = (stashDir, args) => {
16
21
  const result = spawnSync("git", ["-C", stashDir, ...args], { encoding: "utf8", timeout: 5_000 });
17
22
  return { ok: result.status === 0, stdout: (result.stdout ?? "").trim() };
18
23
  };
24
+ /** True when the stash `.gitignore` carries the explicit intentional-tracking marker. */
25
+ function hasIntentionalTrackingMarker(stashDir) {
26
+ try {
27
+ return fs.readFileSync(path.join(stashDir, ".gitignore"), "utf8").includes(STASH_GITIGNORE_TRACK_SECRETS_MARKER);
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
19
33
  /**
20
34
  * Build the `stash-git-exposure` advisory, or `undefined` when there is nothing
21
- * to warn about (not a git repo, no tracked env/secret assets, or no remote).
35
+ * to warn about (tracking acknowledged by the marker, not a git repo, no
36
+ * tracked env/secret assets, or no remote).
22
37
  */
23
38
  export function collectStashExposureAdvisory(stashDir, git = realGit) {
39
+ if (hasIntentionalTrackingMarker(stashDir))
40
+ return undefined;
24
41
  // Not a git work tree → nothing to expose.
25
42
  if (!git(stashDir, ["rev-parse", "--is-inside-work-tree"]).ok)
26
43
  return undefined;
@@ -37,10 +54,11 @@ export function collectStashExposureAdvisory(stashDir, git = realGit) {
37
54
  kind: "deterministic",
38
55
  status: "warn",
39
56
  confidence: "high",
40
- message: `${trackedFiles.length} env/secret file(s) are git-tracked AND a remote is configured — ` +
57
+ message: `Bundle at ${stashDir}: ${trackedFiles.length} env/secret file(s) are git-tracked AND a remote is configured — ` +
41
58
  `a 'git push' can leak tokens/keys (${preview}). ` +
42
59
  "Run 'git rm --cached' on them (a .gitignore rule alone does NOT untrack already-tracked " +
43
- "files) and then add env/+secrets/ to .gitignore to prevent recurrence (akm bundle create scaffolds it).",
44
- evidence: { trackedSecretFiles: trackedFiles },
60
+ "files) and then add env/+secrets/ to .gitignore to prevent recurrence (akm bundle create scaffolds it), " +
61
+ `or, to keep tracking them on purpose, add '${STASH_GITIGNORE_TRACK_SECRETS_MARKER}' to .gitignore.`,
62
+ evidence: { bundleRoot: stashDir, trackedSecretFiles: trackedFiles },
45
63
  };
46
64
  }