akm-cli 0.10.261020-beta → 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,7 +6,7 @@ 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.261020] - 2026-10-29
9
+ ## [0.10.261021] - 2026-10-30
10
10
 
11
11
  ### Added
12
12
 
@@ -15,8 +15,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
15
15
  writing backups. `--check` shows channel mismatches; an older target requires
16
16
  `--allow-downgrade` (#1285).
17
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
+
18
25
  ### Fixed
19
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).
20
48
  - Claude plugin health uses native installation, scope and enabled metadata
21
49
  instead of choosing the highest cached version. It reads `AKM_MIN_VERSION`
22
50
  as well as the legacy range, and reports uncertain selections as unknown (#1286).
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;
@@ -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
  }
@@ -22,28 +22,21 @@
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
25
+ import { buildFileContext } from "../../indexer/walk/file-context.js";
26
+ import { declaredTypeForPath } from "../../indexer/walk/matchers.js";
25
27
  /**
26
- * Directory → declared-type map, mirroring `DIR_TYPE_MAP` in
27
- * `src/indexer/walk/matchers.ts` minus its per-directory extension test —
28
- * this check only needs "which type does this directory declare", not which
29
- * extensions it accepts. Keep in sync if `DIR_TYPE_MAP` gains, renames, or
30
- * removes a directory.
28
+ * The type the entry's location declares under the `akm` stash rules, derived
29
+ * with the indexer's own directory predicates (see `declaredTypeForPath`).
30
+ * Other adapters, and paths outside the component root, declare nothing.
31
31
  */
32
- const DECLARED_DIR_TYPES = {
33
- memories: "memory",
34
- knowledge: "knowledge",
35
- commands: "command",
36
- agents: "agent",
37
- workflows: "workflow",
38
- facts: "fact",
39
- lessons: "lesson",
40
- sessions: "session",
41
- instructions: "instruction",
42
- scripts: "script",
43
- env: "env",
44
- secrets: "secret",
45
- tasks: "task",
46
- };
32
+ function declaredTypeFor(entry) {
33
+ if (entry.adapterId !== "akm" || !entry.root)
34
+ return undefined;
35
+ const rel = path.relative(entry.root, entry.filePath);
36
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
37
+ return undefined;
38
+ return declaredTypeForPath(buildFileContext(entry.root, entry.filePath));
39
+ }
47
40
  const realReadFile = (absPath) => {
48
41
  try {
49
42
  return fs.readFileSync(absPath, "utf8");
@@ -52,31 +45,6 @@ const realReadFile = (absPath) => {
52
45
  return undefined;
53
46
  }
54
47
  };
55
- /**
56
- * The type a file's directory declares, mirroring the `classifyByDirectory` /
57
- * `classifyByParentDirHint` precedence in matchers.ts: the immediate parent
58
- * directory wins when it is itself typed (parentDirHint, specificity 15);
59
- * otherwise the outermost typed ancestor wins (directoryMatcher, specificity
60
- * 10, which walks root-to-leaf and returns on the first hit).
61
- */
62
- function declaredTypeForPath(absPath) {
63
- const segments = path
64
- .dirname(absPath)
65
- .split(path.sep)
66
- .filter((seg) => seg.length > 0);
67
- const immediateParent = segments.at(-1);
68
- if (immediateParent) {
69
- const parentType = DECLARED_DIR_TYPES[immediateParent];
70
- if (parentType)
71
- return { dir: immediateParent, type: parentType };
72
- }
73
- for (const seg of segments) {
74
- const type = DECLARED_DIR_TYPES[seg];
75
- if (type)
76
- return { dir: seg, type };
77
- }
78
- return undefined;
79
- }
80
48
  /**
81
49
  * Best-effort explanation for why `classifyBySmartMd` (matchers.ts) would
82
50
  * have produced `resolvedType` for this content, in the SAME precedence
@@ -110,7 +78,7 @@ function explainOverride(resolvedType, content) {
110
78
  }
111
79
  /**
112
80
  * Compare every indexed entry's resolved type against the type its
113
- * directory declares (see {@link DECLARED_DIR_TYPES}), and return one
81
+ * directory declares (see {@link declaredTypeFor}), and return one
114
82
  * {@link TypeDirectoryDisagreement} per mismatch, sorted by path. Entries
115
83
  * outside any declared-type directory are not checked — this is only the
116
84
  * "directory declares type" invariant.
@@ -118,15 +86,15 @@ function explainOverride(resolvedType, content) {
118
86
  export function collectTypeDirectoryDisagreements(entries, readFile = realReadFile) {
119
87
  const disagreements = [];
120
88
  for (const entry of entries) {
121
- const declared = declaredTypeForPath(entry.filePath);
122
- if (!declared || declared.type === entry.type)
89
+ const declared = declaredTypeFor(entry);
90
+ if (!declared || declared === entry.type)
123
91
  continue;
124
92
  const content = readFile(entry.filePath);
125
93
  const explanation = content === undefined ? undefined : explainOverride(entry.type, content);
126
94
  disagreements.push({
127
95
  path: entry.filePath,
128
96
  resolved: entry.type,
129
- expected: declared.type,
97
+ expected: declared,
130
98
  winner: explanation?.winner ?? "unknown",
131
99
  knownGoodOverride: explanation?.knownGoodOverride ?? false,
132
100
  });
@@ -10,6 +10,7 @@ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-acces
10
10
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
11
11
  import { listExistingTableNames, listPendingStateMigrations, openStateDatabaseWithReport, withStateDb, } from "../core/state-db.js";
12
12
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
13
+ import { resolveSourceEntries } from "../indexer/search/search-source.js";
13
14
  import { probeLlmEndpoint } from "../llm/client.js";
14
15
  import { openDatabase } from "../storage/database.js";
15
16
  import { insertEvent } from "../storage/repositories/events-repository.js";
@@ -251,24 +252,36 @@ function gatherAncillaryAdvisories(db, options, egressConfigView) {
251
252
  const indexStateMismatch = detectIndexStateGenerationMismatch(db);
252
253
  if (indexStateMismatch)
253
254
  advisories.push(indexStateMismatch);
254
- // Surface a `stash-git-exposure` advisory when env/secret assets are
255
- // git-tracked AND a remote is configured (the leak moment). Best-effort.
256
- // Cheap guard: only shell out to git when the stash has its OWN `.git` (or a
257
- // test injected a fake seam), so the hot path never spawns for a non-git
258
- // stash — the common unit-test case. Trade-off: a stash manually pointed at a
259
- // bare subdirectory of a parent git repo (no `.git` of its own) is not
260
- // checked. akm-init always creates `.git` at the stash root, so any
261
- // akm-initialised stash is covered; this only skips hand-pointed nested ones.
255
+ // Surface a `stash-git-exposure` advisory for each bundle whose env/secret
256
+ // assets are git-tracked AND that has a remote (the leak moment). Every
257
+ // configured bundle is checked, not just the working stash; each honours the
258
+ // intentional-tracking marker in its own `.gitignore`. Best-effort.
259
+ // Cheap guard: only shell out to git for a bundle root with its OWN `.git`
260
+ // (or when a test injected a fake seam), so the hot path never spawns for
261
+ // non-git bundles. Trade-off: a bundle pointed at a bare subdirectory of a
262
+ // parent git repo (no `.git` of its own) is not checked; akm-init and
263
+ // writable git installs put `.git` at the bundle root.
264
+ let exposureRoots = [];
262
265
  try {
263
266
  const exposureStashDir = options.stashDir ?? resolveStashDir();
264
- if (options.stashExposureGit || fs.existsSync(path.join(exposureStashDir, ".git"))) {
265
- const stashExposure = collectStashExposureAdvisory(exposureStashDir, options.stashExposureGit);
266
- if (stashExposure)
267
- advisories.push(stashExposure);
268
- }
267
+ exposureRoots = [exposureStashDir];
268
+ exposureRoots = resolveSourceEntries(exposureStashDir).map((source) => source.path);
269
269
  }
270
270
  catch {
271
- // Non-fatal — a git/probe failure must not abort the health report.
271
+ // An unreadable working stash skips the advisory; an unreadable bundle
272
+ // config still checks the working stash. Neither aborts the report.
273
+ }
274
+ for (const root of new Set(exposureRoots.map((dir) => path.resolve(dir)))) {
275
+ try {
276
+ if (options.stashExposureGit || fs.existsSync(path.join(root, ".git"))) {
277
+ const stashExposure = collectStashExposureAdvisory(root, options.stashExposureGit);
278
+ if (stashExposure)
279
+ advisories.push(stashExposure);
280
+ }
281
+ }
282
+ catch {
283
+ // Non-fatal — a git/probe failure must not abort the health report.
284
+ }
272
285
  }
273
286
  // 08 surfaces: the remaining read-only advisory group (binary-config-skew,
274
287
  // egress-endpoints). Best-effort — a filesystem probe failure must not abort
@@ -346,7 +359,24 @@ function detectTypeDirectoryDisagreements(stashRoot) {
346
359
  indexDb = openReadonlyExistingDatabase(getDbPath());
347
360
  if (!indexDb)
348
361
  return undefined;
349
- const entries = getAllEntries(indexDb).map((entry) => ({ filePath: entry.filePath, type: entry.type }));
362
+ // Directory intent is read only from inside the component root that owns the entry.
363
+ // Both spellings of each root: indexed paths may be realpaths (a symlinked
364
+ // stash, macOS /var -> /private/var) while config holds the configured path.
365
+ const roots = resolveSourceEntries(stashRoot).flatMap((source) => {
366
+ const resolved = path.resolve(source.path);
367
+ try {
368
+ return [resolved, fs.realpathSync.native(resolved)];
369
+ }
370
+ catch {
371
+ return [resolved];
372
+ }
373
+ });
374
+ const entries = getAllEntries(indexDb).map((entry) => ({
375
+ filePath: entry.filePath,
376
+ type: entry.type,
377
+ adapterId: entry.adapterId,
378
+ root: roots.filter((root) => entry.filePath.startsWith(root + path.sep)).sort((a, b) => b.length - a.length)[0] ?? "",
379
+ }));
350
380
  return buildTypeDirectoryAdvisory(entries, undefined, (absPath) => absPath.startsWith(stashRoot) ? path.relative(stashRoot, absPath) : absPath);
351
381
  }
352
382
  catch {
@@ -4,6 +4,7 @@
4
4
  import { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
5
5
  import { decodeTaskHistoryMetadata } from "../../storage/repositories/task-history-repository.js";
6
6
  import { computeWallTimeStats } from "../health/improve-metrics.js";
7
+ import { hasTokenUsage } from "../health/llm-usage.js";
7
8
  import { computeValenceScore } from "../improve/feedback-valence.js";
8
9
  const DAY_MS = 86_400_000;
9
10
  /** Utility is a score in [0, 1], reported in ten equal buckets. */
@@ -111,6 +112,8 @@ export function llmRowsFromEvents(events) {
111
112
  completionTokens: 0,
112
113
  totalTokens: 0,
113
114
  reasoningTokens: 0,
115
+ usageCalls: 0,
116
+ reasoningCalls: 0,
114
117
  };
115
118
  rows.set(key, row);
116
119
  }
@@ -121,6 +124,10 @@ export function llmRowsFromEvents(events) {
121
124
  // A call without a total counts as prompt + completion, as the dashboard did per call.
122
125
  row.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
123
126
  row.reasoningTokens += record.reasoningTokens ?? 0;
127
+ if (hasTokenUsage(record))
128
+ row.usageCalls += 1;
129
+ if (record.reasoningTokens !== undefined)
130
+ row.reasoningCalls += 1;
124
131
  }
125
132
  return [...rows.values()];
126
133
  }
@@ -260,10 +267,11 @@ function buildUsage(input, rows) {
260
267
  curates,
261
268
  selects: input.selects.length,
262
269
  zeroResultSearches,
270
+ searchesWithHits,
263
271
  distinctAssets: assets.size,
264
272
  distinctQueries: queries.size,
265
273
  },
266
- selectRate: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
274
+ selectsPerSearch: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
267
275
  searchMedianMs: median(searchMs),
268
276
  daily: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
269
277
  topAssets: [...assets.values()]
@@ -131,7 +131,7 @@ export async function akmMetrics(options = {}) {
131
131
  if (index.note)
132
132
  notes.push(index.note);
133
133
  const filter = { sinceIso, untilIso, source: SOURCE };
134
- notes.push("Selects come from the events stream, which records no source, so they are counted for every source.");
134
+ notes.push("Selects come from the events stream, which records no source or search id, so they are counted for every source and selects per search is a ratio, not a conversion rate.");
135
135
  const input = {
136
136
  window: { since: sinceIso, until: untilIso },
137
137
  source: SOURCE,
@@ -20,18 +20,27 @@ function sourceFacts(counts) {
20
20
  const entries = Object.entries(counts);
21
21
  return entries.length === 0 ? "-" : entries.map(([k, v]) => `${k}=${v}`).join(" ");
22
22
  }
23
+ /** A token sum, or `unknown` when calls were made and none reported usage (never a measured zero). */
24
+ function tokens(n, a) {
25
+ return a.calls > 0 && a.usageCalls === 0 ? "unknown" : int(n);
26
+ }
27
+ /** `reported/calls`; `-` for an aggregate that carries no coverage. */
28
+ function usageCoverage(a) {
29
+ return a.usageCalls === undefined ? "-" : `${a.usageCalls}/${a.calls}`;
30
+ }
23
31
  function llmRows(group) {
24
32
  return Object.entries(group).map(([name, a]) => [
25
33
  name,
26
34
  int(a.calls),
27
35
  int(a.failures),
28
- int(a.promptTokens),
29
- int(a.completionTokens),
30
- int(a.totalTokens),
36
+ tokens(a.promptTokens, a),
37
+ tokens(a.completionTokens, a),
38
+ tokens(a.totalTokens, a),
39
+ usageCoverage(a),
31
40
  ms(a.totalDurationMs),
32
41
  ]);
33
42
  }
34
- const LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "time"];
43
+ const LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "calls with usage", "time"];
35
44
  export function buildMetricsView(r, detail) {
36
45
  const brief = detail === "brief";
37
46
  const cut = (items) => (brief ? items.slice(0, BRIEF_LIST_LIMIT) : items);
@@ -45,7 +54,8 @@ export function buildMetricsView(r, detail) {
45
54
  ["shows", int(usage.totals.shows)],
46
55
  ["curates", int(usage.totals.curates)],
47
56
  ["selects", int(usage.totals.selects)],
48
- ["select rate", pct(usage.selectRate)],
57
+ ["searches with hits", int(usage.totals.searchesWithHits)],
58
+ ["selects per search with hits", fixed(usage.selectsPerSearch, 2)],
49
59
  [
50
60
  "zero-result searches",
51
61
  `${usage.totals.zeroResultSearches} (${pct(searches === 0 ? null : usage.totals.zeroResultSearches / searches)})`,
@@ -154,10 +164,11 @@ export function buildMetricsView(r, detail) {
154
164
  facts: [
155
165
  ["calls", int(llm.calls)],
156
166
  ["failed", int(llm.failures)],
157
- ["prompt tokens", int(llm.promptTokens)],
158
- ["completion tokens", int(llm.completionTokens)],
159
- ["reasoning tokens", int(llm.reasoningTokens)],
160
- ["total tokens", int(llm.totalTokens)],
167
+ ["calls with token usage", usageCoverage(llm)],
168
+ ["prompt tokens", tokens(llm.promptTokens, llm)],
169
+ ["completion tokens", tokens(llm.completionTokens, llm)],
170
+ ["reasoning tokens", llm.calls > 0 && llm.reasoningCalls === 0 ? "unknown" : tokens(llm.reasoningTokens, llm)],
171
+ ["total tokens", tokens(llm.totalTokens, llm)],
161
172
  ["time", ms(llm.totalDurationMs)],
162
173
  ],
163
174
  tables: [
@@ -219,7 +219,9 @@ async function addRegistryStash(ref, stashDir, explicitName, writable, position
219
219
  : [];
220
220
  const synced = await syncFromRef(ref, {
221
221
  writable: effectiveWritable,
222
- ...(effectiveWritable && priorLock?.localRoot ? { writableRoot: priorLock.localRoot } : {}),
222
+ // Reuse a checkout only when the install is already writable; promoting a
223
+ // read-only snapshot (#1296) materializes a fresh checkout instead.
224
+ ...(existingWritable && priorLock?.localRoot ? { writableRoot: priorLock.localRoot } : {}),
223
225
  ...(requiredRoots.length > 0 ? { writableRequiredRoots: requiredRoots } : {}),
224
226
  ...(credentialRef
225
227
  ? { credential: resolveSecret(credentialRef, storeSecretResolver.resolveSecret) }
@@ -253,8 +255,10 @@ async function addRegistryStash(ref, stashDir, explicitName, writable, position
253
255
  localRoot: synced.contentDir,
254
256
  installedAt: synced.syncedAt,
255
257
  });
256
- // Clean up the old materialized root on re-install (moved cache).
257
- if (!effectiveWritable && priorLocalRoot && path.resolve(priorLocalRoot) !== path.resolve(synced.contentDir)) {
258
+ // Clean up the old materialized root on re-install (moved cache). A writable
259
+ // checkout may hold local work and is never removed; a read-only snapshot
260
+ // (including one just promoted to writable, #1296) is.
261
+ if (!existingWritable && priorLocalRoot && path.resolve(priorLocalRoot) !== path.resolve(synced.contentDir)) {
258
262
  try {
259
263
  fs.rmSync(priorLocalRoot, { recursive: true, force: true });
260
264
  }
@@ -495,7 +495,10 @@ export async function fetchWithTimeout(url, opts, timeoutMs = 30_000, signal) {
495
495
  try {
496
496
  if (controller.signal.aborted)
497
497
  throw controller.signal.reason ?? new Error(`Request aborted: ${url}`);
498
- return await fetch(url, { ...opts, signal: controller.signal });
498
+ // `timeout: false` turns off Bun's independent five-minute socket-idle timer,
499
+ // which would otherwise cut a longer AKM deadline short. The AbortController
500
+ // signal (deadline + caller cancel) remains the only bound on the request.
501
+ return await fetch(url, { ...opts, signal: controller.signal, timeout: false });
499
502
  }
500
503
  catch (err) {
501
504
  if (err instanceof DOMException && err.name === "AbortError") {
@@ -194,6 +194,16 @@ function classifyByParentDirHint(ctx) {
194
194
  }
195
195
  return matchDirectoryHint(parentDir, ctx, 15);
196
196
  }
197
+ /**
198
+ * The type a file's location declares, from the same directory rules the
199
+ * indexer applies: parent-directory hint (specificity 15) over the outermost
200
+ * typed ancestor (10), each honouring the rule's extension predicate, the
201
+ * `README.md` documentation exception and nested skill resources. `undefined`
202
+ * when no directory rule claims the file. Path fields only; no bytes are read.
203
+ */
204
+ export function declaredTypeForPath(ctx) {
205
+ return (classifyByParentDirHint(ctx) ?? classifyByDirectory(ctx))?.type;
206
+ }
197
207
  function classifyBySmartMd(ctx) {
198
208
  if (ctx.ext !== ".md")
199
209
  return null;
@@ -37171,12 +37171,24 @@ function keyLooksSecret(key) {
37171
37171
  const lower = key.toLowerCase();
37172
37172
  return SECRET_KEY_HINTS.some((hint) => lower.includes(hint));
37173
37173
  }
37174
+ function segmentLooksSecret(seg) {
37175
+ if (SECRET_VALUE_PREFIX.test(seg))
37176
+ return true;
37177
+ const eq = seg.indexOf("=");
37178
+ if (eq > 0 && keyLooksSecret(seg.slice(0, eq)) && seg.length - eq > 8)
37179
+ return true;
37180
+ if (seg.length >= 32 && !UUID.test(seg) && /^[A-Za-z0-9+_=-]+$/.test(seg) && /\d/.test(seg) && /[a-zA-Z]/.test(seg))
37181
+ return true;
37182
+ return seg.length >= 20 && /^[A-Za-z0-9]+$/.test(seg) && /[a-z]/.test(seg) && /[A-Z]/.test(seg) && /\d/.test(seg);
37183
+ }
37174
37184
  function valueLooksSecret(value) {
37175
37185
  const s = value.trim();
37176
37186
  if (s.length < 20 || /\s/.test(s))
37177
37187
  return false;
37178
37188
  if (SECRET_VALUE_PREFIX.test(s))
37179
37189
  return true;
37190
+ if (PATH_LIKE.test(s))
37191
+ return s.split(/[/\\?&#]/).some(segmentLooksSecret);
37180
37192
  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(s)).length;
37181
37193
  if (s.length >= 24 && classes >= 3)
37182
37194
  return true;
@@ -37218,7 +37230,7 @@ function walkSecretShaped(params, hit) {
37218
37230
  for (const [k, v] of Object.entries(params))
37219
37231
  walk(v, k, k);
37220
37232
  }
37221
- var SECRET_KEY_HINTS, SECRET_VALUE_PREFIX, MOVE_TO_ENV;
37233
+ var SECRET_KEY_HINTS, SECRET_VALUE_PREFIX, PATH_LIKE, UUID, MOVE_TO_ENV;
37222
37234
  var init_param_secrets = __esm(() => {
37223
37235
  SECRET_KEY_HINTS = [
37224
37236
  "secret",
@@ -37239,6 +37251,8 @@ var init_param_secrets = __esm(() => {
37239
37251
  "client_secret"
37240
37252
  ];
37241
37253
  SECRET_VALUE_PREFIX = /^(sk-|rk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[baprs]-|AKIA|ASIA|AIza|ya29\.|-----BEGIN)/;
37254
+ PATH_LIKE = /^(\/|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\)|^[\w.-]+[\\/][^?#]*\.[A-Za-z0-9]{1,8}$/;
37255
+ UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
37242
37256
  MOVE_TO_ENV = "Workflow params are copied verbatim into every native unit execution context and returned in `akm workflow run` and " + "`akm workflow status` output — move secrets to an env binding (`env:` ref), whose value native execution resolves " + "only at dispatch instead of storing it as a run param.";
37243
37257
  });
37244
37258
 
@@ -41924,10 +41938,10 @@ var init_marked_esm = __esm(() => {
41924
41938
 
41925
41939
  // src/core/asset/markdown-link-targets.ts
41926
41940
  function markdownLinkTargets(body, references) {
41927
- const tokens = markdown.lexer(body);
41941
+ const tokens2 = markdown.lexer(body);
41928
41942
  const definitions = new Map;
41929
41943
  if (references) {
41930
- markdown.walkTokens(tokens, (token) => {
41944
+ markdown.walkTokens(tokens2, (token) => {
41931
41945
  if (token.type !== "def")
41932
41946
  return;
41933
41947
  const definition = /^\s*\[([^\]]+)\]:\s*(\S+)/.exec(token.raw);
@@ -41936,7 +41950,7 @@ function markdownLinkTargets(body, references) {
41936
41950
  });
41937
41951
  }
41938
41952
  const targets = [];
41939
- markdown.walkTokens(tokens, (token) => {
41953
+ markdown.walkTokens(tokens2, (token) => {
41940
41954
  if (token.type !== "link" && token.type !== "image")
41941
41955
  return;
41942
41956
  const inline = /^!?\[[\s\S]*\]\(/.test(token.raw);
@@ -62450,7 +62464,7 @@ var require_HTMLParser = __commonJS((exports, module) => {
62450
62464
  }
62451
62465
  }
62452
62466
  htmlparser.testTokenizer = function(input2, initialState, lastStartTag, charbychar) {
62453
- var tokens = [];
62467
+ var tokens2 = [];
62454
62468
  switch (initialState) {
62455
62469
  case "PCDATA state":
62456
62470
  tokenizer = data_state;
@@ -62472,16 +62486,16 @@ var require_HTMLParser = __commonJS((exports, module) => {
62472
62486
  flushText();
62473
62487
  switch (t) {
62474
62488
  case 1:
62475
- if (tokens.length > 0 && tokens[tokens.length - 1][0] === "Character") {
62476
- tokens[tokens.length - 1][1] += value;
62489
+ if (tokens2.length > 0 && tokens2[tokens2.length - 1][0] === "Character") {
62490
+ tokens2[tokens2.length - 1][1] += value;
62477
62491
  } else
62478
- tokens.push(["Character", value]);
62492
+ tokens2.push(["Character", value]);
62479
62493
  break;
62480
62494
  case 4:
62481
- tokens.push(["Comment", value]);
62495
+ tokens2.push(["Comment", value]);
62482
62496
  break;
62483
62497
  case 5:
62484
- tokens.push([
62498
+ tokens2.push([
62485
62499
  "DOCTYPE",
62486
62500
  value,
62487
62501
  arg3 === undefined ? null : arg3,
@@ -62502,10 +62516,10 @@ var require_HTMLParser = __commonJS((exports, module) => {
62502
62516
  var token = ["StartTag", value, attrs];
62503
62517
  if (arg4)
62504
62518
  token.push(true);
62505
- tokens.push(token);
62519
+ tokens2.push(token);
62506
62520
  break;
62507
62521
  case 3:
62508
- tokens.push(["EndTag", value]);
62522
+ tokens2.push(["EndTag", value]);
62509
62523
  break;
62510
62524
  case -1:
62511
62525
  break;
@@ -62519,7 +62533,7 @@ var require_HTMLParser = __commonJS((exports, module) => {
62519
62533
  }
62520
62534
  this.parse("", true);
62521
62535
  }
62522
- return tokens;
62536
+ return tokens2;
62523
62537
  };
62524
62538
  return htmlparser;
62525
62539
  }
@@ -68893,18 +68907,25 @@ function sourceFacts(counts) {
68893
68907
  const entries = Object.entries(counts);
68894
68908
  return entries.length === 0 ? "-" : entries.map(([k, v]) => `${k}=${v}`).join(" ");
68895
68909
  }
68910
+ function tokens(n, a) {
68911
+ return a.calls > 0 && a.usageCalls === 0 ? "unknown" : int(n);
68912
+ }
68913
+ function usageCoverage(a) {
68914
+ return a.usageCalls === undefined ? "-" : `${a.usageCalls}/${a.calls}`;
68915
+ }
68896
68916
  function llmRows(group) {
68897
68917
  return Object.entries(group).map(([name, a]) => [
68898
68918
  name,
68899
68919
  int(a.calls),
68900
68920
  int(a.failures),
68901
- int(a.promptTokens),
68902
- int(a.completionTokens),
68903
- int(a.totalTokens),
68921
+ tokens(a.promptTokens, a),
68922
+ tokens(a.completionTokens, a),
68923
+ tokens(a.totalTokens, a),
68924
+ usageCoverage(a),
68904
68925
  ms(a.totalDurationMs)
68905
68926
  ]);
68906
68927
  }
68907
- var LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "time"];
68928
+ var LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "calls with usage", "time"];
68908
68929
  function buildMetricsView(r, detail) {
68909
68930
  const brief = detail === "brief";
68910
68931
  const cut = (items) => brief ? items.slice(0, BRIEF_LIST_LIMIT) : items;
@@ -68918,7 +68939,8 @@ function buildMetricsView(r, detail) {
68918
68939
  ["shows", int(usage.totals.shows)],
68919
68940
  ["curates", int(usage.totals.curates)],
68920
68941
  ["selects", int(usage.totals.selects)],
68921
- ["select rate", pct(usage.selectRate)],
68942
+ ["searches with hits", int(usage.totals.searchesWithHits)],
68943
+ ["selects per search with hits", fixed(usage.selectsPerSearch, 2)],
68922
68944
  [
68923
68945
  "zero-result searches",
68924
68946
  `${usage.totals.zeroResultSearches} (${pct(searches === 0 ? null : usage.totals.zeroResultSearches / searches)})`
@@ -69025,10 +69047,11 @@ function buildMetricsView(r, detail) {
69025
69047
  facts: [
69026
69048
  ["calls", int(llm.calls)],
69027
69049
  ["failed", int(llm.failures)],
69028
- ["prompt tokens", int(llm.promptTokens)],
69029
- ["completion tokens", int(llm.completionTokens)],
69030
- ["reasoning tokens", int(llm.reasoningTokens)],
69031
- ["total tokens", int(llm.totalTokens)],
69050
+ ["calls with token usage", usageCoverage(llm)],
69051
+ ["prompt tokens", tokens(llm.promptTokens, llm)],
69052
+ ["completion tokens", tokens(llm.completionTokens, llm)],
69053
+ ["reasoning tokens", llm.calls > 0 && llm.reasoningCalls === 0 ? "unknown" : tokens(llm.reasoningTokens, llm)],
69054
+ ["total tokens", tokens(llm.totalTokens, llm)],
69032
69055
  ["time", ms(llm.totalDurationMs)]
69033
69056
  ],
69034
69057
  tables: [
@@ -72919,15 +72942,15 @@ var SHELL_ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
72919
72942
  function shellStableV2Executable(executable) {
72920
72943
  return executable === "akm" || executable.includes("/");
72921
72944
  }
72922
- function skipEnvAssignmentPrefix(tokens) {
72923
- if (tokens[0] !== "env")
72924
- return { tokens, envWrapped: false };
72945
+ function skipEnvAssignmentPrefix(tokens2) {
72946
+ if (tokens2[0] !== "env")
72947
+ return { tokens: tokens2, envWrapped: false };
72925
72948
  let index = 1;
72926
- while (index < tokens.length && SHELL_ASSIGNMENT_WORD.test(tokens[index]))
72949
+ while (index < tokens2.length && SHELL_ASSIGNMENT_WORD.test(tokens2[index]))
72927
72950
  index += 1;
72928
- if (index >= tokens.length)
72929
- return { tokens, envWrapped: false };
72930
- return { tokens: tokens.slice(index), envWrapped: true };
72951
+ if (index >= tokens2.length)
72952
+ return { tokens: tokens2, envWrapped: false };
72953
+ return { tokens: tokens2.slice(index), envWrapped: true };
72931
72954
  }
72932
72955
  var KNOWN_PROMPT_REF_FAMILIES = new Set([
72933
72956
  "agents",
@@ -73359,16 +73382,16 @@ function migratedObject(data) {
73359
73382
  const command = exactString(data.command, "command", true).trim();
73360
73383
  if (/['"\\]/.test(command))
73361
73384
  return "shell-quoting-changes-v2-whitespace-split-semantics";
73362
- const tokens = command.split(/\s+/).filter(Boolean);
73363
- if (tokens.length === 0 || tokens.some((token) => !SAFE_V2_COMMAND_TOKEN.test(token))) {
73385
+ const tokens2 = command.split(/\s+/).filter(Boolean);
73386
+ if (tokens2.length === 0 || tokens2.some((token) => !SAFE_V2_COMMAND_TOKEN.test(token))) {
73364
73387
  return "shell-operators-change-v2-literal-argv-semantics";
73365
73388
  }
73366
- const { tokens: targetTokens, envWrapped } = skipEnvAssignmentPrefix(tokens);
73389
+ const { tokens: targetTokens, envWrapped } = skipEnvAssignmentPrefix(tokens2);
73367
73390
  const executable = targetTokens[0];
73368
73391
  if (SHELL_ASSIGNMENT_WORD.test(executable) || !envWrapped && !shellStableV2Executable(executable)) {
73369
73392
  return "shell-command-resolution-changes-v2-literal-argv-semantics";
73370
73393
  }
73371
- output2.run = tokens.join(" ");
73394
+ output2.run = tokens2.join(" ");
73372
73395
  addSharedNonPromptOverrides(data, akm);
73373
73396
  }
73374
73397
  output2.akm = akm;
@@ -37170,12 +37170,24 @@ function keyLooksSecret(key) {
37170
37170
  const lower = key.toLowerCase();
37171
37171
  return SECRET_KEY_HINTS.some((hint) => lower.includes(hint));
37172
37172
  }
37173
+ function segmentLooksSecret(seg) {
37174
+ if (SECRET_VALUE_PREFIX.test(seg))
37175
+ return true;
37176
+ const eq = seg.indexOf("=");
37177
+ if (eq > 0 && keyLooksSecret(seg.slice(0, eq)) && seg.length - eq > 8)
37178
+ return true;
37179
+ if (seg.length >= 32 && !UUID.test(seg) && /^[A-Za-z0-9+_=-]+$/.test(seg) && /\d/.test(seg) && /[a-zA-Z]/.test(seg))
37180
+ return true;
37181
+ return seg.length >= 20 && /^[A-Za-z0-9]+$/.test(seg) && /[a-z]/.test(seg) && /[A-Z]/.test(seg) && /\d/.test(seg);
37182
+ }
37173
37183
  function valueLooksSecret(value) {
37174
37184
  const s = value.trim();
37175
37185
  if (s.length < 20 || /\s/.test(s))
37176
37186
  return false;
37177
37187
  if (SECRET_VALUE_PREFIX.test(s))
37178
37188
  return true;
37189
+ if (PATH_LIKE.test(s))
37190
+ return s.split(/[/\\?&#]/).some(segmentLooksSecret);
37179
37191
  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(s)).length;
37180
37192
  if (s.length >= 24 && classes >= 3)
37181
37193
  return true;
@@ -37217,7 +37229,7 @@ function walkSecretShaped(params, hit) {
37217
37229
  for (const [k, v] of Object.entries(params))
37218
37230
  walk(v, k, k);
37219
37231
  }
37220
- var SECRET_KEY_HINTS, SECRET_VALUE_PREFIX, MOVE_TO_ENV;
37232
+ var SECRET_KEY_HINTS, SECRET_VALUE_PREFIX, PATH_LIKE, UUID, MOVE_TO_ENV;
37221
37233
  var init_param_secrets = __esm(() => {
37222
37234
  SECRET_KEY_HINTS = [
37223
37235
  "secret",
@@ -37238,6 +37250,8 @@ var init_param_secrets = __esm(() => {
37238
37250
  "client_secret"
37239
37251
  ];
37240
37252
  SECRET_VALUE_PREFIX = /^(sk-|rk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[baprs]-|AKIA|ASIA|AIza|ya29\.|-----BEGIN)/;
37253
+ PATH_LIKE = /^(\/|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\)|^[\w.-]+[\\/][^?#]*\.[A-Za-z0-9]{1,8}$/;
37254
+ UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
37241
37255
  MOVE_TO_ENV = "Workflow params are copied verbatim into every native unit execution context and returned in `akm workflow run` and " + "`akm workflow status` output \u2014 move secrets to an env binding (`env:` ref), whose value native execution resolves " + "only at dispatch instead of storing it as a run param.";
37242
37256
  });
37243
37257
 
@@ -41923,10 +41937,10 @@ var init_marked_esm = __esm(() => {
41923
41937
 
41924
41938
  // src/core/asset/markdown-link-targets.ts
41925
41939
  function markdownLinkTargets(body, references) {
41926
- const tokens = markdown.lexer(body);
41940
+ const tokens2 = markdown.lexer(body);
41927
41941
  const definitions = new Map;
41928
41942
  if (references) {
41929
- markdown.walkTokens(tokens, (token) => {
41943
+ markdown.walkTokens(tokens2, (token) => {
41930
41944
  if (token.type !== "def")
41931
41945
  return;
41932
41946
  const definition = /^\s*\[([^\]]+)\]:\s*(\S+)/.exec(token.raw);
@@ -41935,7 +41949,7 @@ function markdownLinkTargets(body, references) {
41935
41949
  });
41936
41950
  }
41937
41951
  const targets = [];
41938
- markdown.walkTokens(tokens, (token) => {
41952
+ markdown.walkTokens(tokens2, (token) => {
41939
41953
  if (token.type !== "link" && token.type !== "image")
41940
41954
  return;
41941
41955
  const inline = /^!?\[[\s\S]*\]\(/.test(token.raw);
@@ -62449,7 +62463,7 @@ var require_HTMLParser = __commonJS((exports, module) => {
62449
62463
  }
62450
62464
  }
62451
62465
  htmlparser.testTokenizer = function(input2, initialState, lastStartTag, charbychar) {
62452
- var tokens = [];
62466
+ var tokens2 = [];
62453
62467
  switch (initialState) {
62454
62468
  case "PCDATA state":
62455
62469
  tokenizer = data_state;
@@ -62471,16 +62485,16 @@ var require_HTMLParser = __commonJS((exports, module) => {
62471
62485
  flushText();
62472
62486
  switch (t) {
62473
62487
  case 1:
62474
- if (tokens.length > 0 && tokens[tokens.length - 1][0] === "Character") {
62475
- tokens[tokens.length - 1][1] += value;
62488
+ if (tokens2.length > 0 && tokens2[tokens2.length - 1][0] === "Character") {
62489
+ tokens2[tokens2.length - 1][1] += value;
62476
62490
  } else
62477
- tokens.push(["Character", value]);
62491
+ tokens2.push(["Character", value]);
62478
62492
  break;
62479
62493
  case 4:
62480
- tokens.push(["Comment", value]);
62494
+ tokens2.push(["Comment", value]);
62481
62495
  break;
62482
62496
  case 5:
62483
- tokens.push([
62497
+ tokens2.push([
62484
62498
  "DOCTYPE",
62485
62499
  value,
62486
62500
  arg3 === undefined ? null : arg3,
@@ -62501,10 +62515,10 @@ var require_HTMLParser = __commonJS((exports, module) => {
62501
62515
  var token = ["StartTag", value, attrs];
62502
62516
  if (arg4)
62503
62517
  token.push(true);
62504
- tokens.push(token);
62518
+ tokens2.push(token);
62505
62519
  break;
62506
62520
  case 3:
62507
- tokens.push(["EndTag", value]);
62521
+ tokens2.push(["EndTag", value]);
62508
62522
  break;
62509
62523
  case -1:
62510
62524
  break;
@@ -62518,7 +62532,7 @@ var require_HTMLParser = __commonJS((exports, module) => {
62518
62532
  }
62519
62533
  this.parse("", true);
62520
62534
  }
62521
- return tokens;
62535
+ return tokens2;
62522
62536
  };
62523
62537
  return htmlparser;
62524
62538
  }
@@ -68891,18 +68905,25 @@ function sourceFacts(counts) {
68891
68905
  const entries = Object.entries(counts);
68892
68906
  return entries.length === 0 ? "-" : entries.map(([k, v]) => `${k}=${v}`).join(" ");
68893
68907
  }
68908
+ function tokens(n, a) {
68909
+ return a.calls > 0 && a.usageCalls === 0 ? "unknown" : int(n);
68910
+ }
68911
+ function usageCoverage(a) {
68912
+ return a.usageCalls === undefined ? "-" : `${a.usageCalls}/${a.calls}`;
68913
+ }
68894
68914
  function llmRows(group) {
68895
68915
  return Object.entries(group).map(([name, a]) => [
68896
68916
  name,
68897
68917
  int(a.calls),
68898
68918
  int(a.failures),
68899
- int(a.promptTokens),
68900
- int(a.completionTokens),
68901
- int(a.totalTokens),
68919
+ tokens(a.promptTokens, a),
68920
+ tokens(a.completionTokens, a),
68921
+ tokens(a.totalTokens, a),
68922
+ usageCoverage(a),
68902
68923
  ms(a.totalDurationMs)
68903
68924
  ]);
68904
68925
  }
68905
- var LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "time"];
68926
+ var LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "calls with usage", "time"];
68906
68927
  function buildMetricsView(r, detail) {
68907
68928
  const brief = detail === "brief";
68908
68929
  const cut = (items) => brief ? items.slice(0, BRIEF_LIST_LIMIT) : items;
@@ -68916,7 +68937,8 @@ function buildMetricsView(r, detail) {
68916
68937
  ["shows", int(usage.totals.shows)],
68917
68938
  ["curates", int(usage.totals.curates)],
68918
68939
  ["selects", int(usage.totals.selects)],
68919
- ["select rate", pct(usage.selectRate)],
68940
+ ["searches with hits", int(usage.totals.searchesWithHits)],
68941
+ ["selects per search with hits", fixed(usage.selectsPerSearch, 2)],
68920
68942
  [
68921
68943
  "zero-result searches",
68922
68944
  `${usage.totals.zeroResultSearches} (${pct(searches === 0 ? null : usage.totals.zeroResultSearches / searches)})`
@@ -69023,10 +69045,11 @@ function buildMetricsView(r, detail) {
69023
69045
  facts: [
69024
69046
  ["calls", int(llm.calls)],
69025
69047
  ["failed", int(llm.failures)],
69026
- ["prompt tokens", int(llm.promptTokens)],
69027
- ["completion tokens", int(llm.completionTokens)],
69028
- ["reasoning tokens", int(llm.reasoningTokens)],
69029
- ["total tokens", int(llm.totalTokens)],
69048
+ ["calls with token usage", usageCoverage(llm)],
69049
+ ["prompt tokens", tokens(llm.promptTokens, llm)],
69050
+ ["completion tokens", tokens(llm.completionTokens, llm)],
69051
+ ["reasoning tokens", llm.calls > 0 && llm.reasoningCalls === 0 ? "unknown" : tokens(llm.reasoningTokens, llm)],
69052
+ ["total tokens", tokens(llm.totalTokens, llm)],
69030
69053
  ["time", ms(llm.totalDurationMs)]
69031
69054
  ],
69032
69055
  tables: [
@@ -72882,15 +72905,15 @@ var SHELL_ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
72882
72905
  function shellStableV2Executable(executable) {
72883
72906
  return executable === "akm" || executable.includes("/");
72884
72907
  }
72885
- function skipEnvAssignmentPrefix(tokens) {
72886
- if (tokens[0] !== "env")
72887
- return { tokens, envWrapped: false };
72908
+ function skipEnvAssignmentPrefix(tokens2) {
72909
+ if (tokens2[0] !== "env")
72910
+ return { tokens: tokens2, envWrapped: false };
72888
72911
  let index = 1;
72889
- while (index < tokens.length && SHELL_ASSIGNMENT_WORD.test(tokens[index]))
72912
+ while (index < tokens2.length && SHELL_ASSIGNMENT_WORD.test(tokens2[index]))
72890
72913
  index += 1;
72891
- if (index >= tokens.length)
72892
- return { tokens, envWrapped: false };
72893
- return { tokens: tokens.slice(index), envWrapped: true };
72914
+ if (index >= tokens2.length)
72915
+ return { tokens: tokens2, envWrapped: false };
72916
+ return { tokens: tokens2.slice(index), envWrapped: true };
72894
72917
  }
72895
72918
  var KNOWN_PROMPT_REF_FAMILIES = new Set([
72896
72919
  "agents",
@@ -73322,16 +73345,16 @@ function migratedObject(data) {
73322
73345
  const command = exactString(data.command, "command", true).trim();
73323
73346
  if (/['"\\]/.test(command))
73324
73347
  return "shell-quoting-changes-v2-whitespace-split-semantics";
73325
- const tokens = command.split(/\s+/).filter(Boolean);
73326
- if (tokens.length === 0 || tokens.some((token) => !SAFE_V2_COMMAND_TOKEN.test(token))) {
73348
+ const tokens2 = command.split(/\s+/).filter(Boolean);
73349
+ if (tokens2.length === 0 || tokens2.some((token) => !SAFE_V2_COMMAND_TOKEN.test(token))) {
73327
73350
  return "shell-operators-change-v2-literal-argv-semantics";
73328
73351
  }
73329
- const { tokens: targetTokens, envWrapped } = skipEnvAssignmentPrefix(tokens);
73352
+ const { tokens: targetTokens, envWrapped } = skipEnvAssignmentPrefix(tokens2);
73330
73353
  const executable = targetTokens[0];
73331
73354
  if (SHELL_ASSIGNMENT_WORD.test(executable) || !envWrapped && !shellStableV2Executable(executable)) {
73332
73355
  return "shell-command-resolution-changes-v2-literal-argv-semantics";
73333
73356
  }
73334
- output2.run = tokens.join(" ");
73357
+ output2.run = tokens2.join(" ");
73335
73358
  addSharedNonPromptOverrides(data, akm);
73336
73359
  }
73337
73360
  output2.akm = akm;
@@ -38,10 +38,36 @@ function keyLooksSecret(key) {
38
38
  const lower = key.toLowerCase();
39
39
  return SECRET_KEY_HINTS.some((hint) => lower.includes(hint));
40
40
  }
41
+ /**
42
+ * Unix (`/`, `./`, `../`, `~/`) or Windows (`C:\`, `\\server`) filesystem path,
43
+ * or a bare relative path whose last segment carries a file extension
44
+ * (`reports/2026-10-30/summary.md`). Base64 never contains `.`, so the
45
+ * extension keeps slash-bearing base64 blobs on the whole-string check.
46
+ */
47
+ const PATH_LIKE = /^(\/|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\)|^[\w.-]+[\\/][^?#]*\.[A-Za-z0-9]{1,8}$/;
48
+ /** A canonical UUID — an ordinary run/session directory name, not a credential. */
49
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
50
+ /**
51
+ * Whether one path segment or query piece looks like a credential: a known
52
+ * prefix, a secret-named query key (`token=...`), or a long unbroken token
53
+ * (hex/base64 run, or mixed case with digits).
54
+ */
55
+ function segmentLooksSecret(seg) {
56
+ if (SECRET_VALUE_PREFIX.test(seg))
57
+ return true;
58
+ const eq = seg.indexOf("=");
59
+ if (eq > 0 && keyLooksSecret(seg.slice(0, eq)) && seg.length - eq > 8)
60
+ return true;
61
+ if (seg.length >= 32 && !UUID.test(seg) && /^[A-Za-z0-9+_=-]+$/.test(seg) && /\d/.test(seg) && /[a-zA-Z]/.test(seg))
62
+ return true;
63
+ return seg.length >= 20 && /^[A-Za-z0-9]+$/.test(seg) && /[a-z]/.test(seg) && /[A-Z]/.test(seg) && /\d/.test(seg);
64
+ }
41
65
  /**
42
66
  * A string value is secret-shaped when it is long, whitespace-free, and either
43
67
  * carries a known credential prefix or has high character-class diversity /
44
- * base64-hex shape — an entropy proxy, not a real entropy computation.
68
+ * base64-hex shape — an entropy proxy, not a real entropy computation. A
69
+ * filesystem path is judged per segment instead, so separators, dates and
70
+ * digits do not count as diversity.
45
71
  */
46
72
  function valueLooksSecret(value) {
47
73
  const s = value.trim();
@@ -49,6 +75,8 @@ function valueLooksSecret(value) {
49
75
  return false;
50
76
  if (SECRET_VALUE_PREFIX.test(s))
51
77
  return true;
78
+ if (PATH_LIKE.test(s))
79
+ return s.split(/[/\\?&#]/).some(segmentLooksSecret);
52
80
  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(s)).length;
53
81
  if (s.length >= 24 && classes >= 3)
54
82
  return true;
@@ -56,7 +56,7 @@ and `src/commands/health/version-drift.ts`.
56
56
  | `cli-version` | `warn`: a newer akm release exists on GitHub. `unknown`: `--no-probe`, or the release lookup failed. | Run `akm upgrade`. |
57
57
  | `scheduler-binary` | `warn`: the akm binary recorded in the scheduler reports a different version than the running CLI. `unknown`: `--no-probe`, no scheduled task installed, or the recorded binary cannot be run. | Run `akm task sync` to rebind the schedule. |
58
58
  | `index-state-generation` | `warn`: usage links in state.db point at index entries that no longer match (an index and state.db from different generations). | Stop concurrent writers and run `akm index --full`. |
59
- | `stash-git-exposure` | `warn`: `env/` or `secrets/` files are tracked by git in the working bundle and the bundle has a remote. Skipped when the bundle has no `.git`. | Run `git rm --cached` on the listed files and add `env/` and `secrets/` to `.gitignore`. A `.gitignore` rule alone does not untrack files. |
59
+ | `stash-git-exposure` | `warn`, one per bundle: `env/` or `secrets/` files are tracked by git in a configured bundle (the working stash included) and that bundle has a remote. Skipped for a bundle with no `.git` at its root. | Run `git rm --cached` on the listed files and add `env/` and `secrets/` to `.gitignore`. A `.gitignore` rule alone does not untrack files. To keep tracking them on purpose, add `# akm: intentionally track env and secrets` to the stash `.gitignore`; the advisory is then not reported. |
60
60
  | `binary-config-skew` | `warn`: `config.json` has a `configVersion` newer than this binary knows. | Upgrade this install. |
61
61
  | `egress-endpoints` | Every remote destination in the effective config, as one informational entry: enabled registries, git and website bundles, each connection's endpoint and the embedding endpoint (credentials in a URL are masked). Silent when nothing remote is configured. | Eyeball it against what you expect; a destination you did not configure came from an `extends` base or a shared bundle. |
62
62
  | `type-directory-disagreement` | `warn`: an indexed asset's resolved type differs from the type its directory declares. Each line gives the file, both types, and the classifier signal that won (`winner`). | Review the listed files. Intended overrides (for example a `knowledge/` file containing `$ARGUMENTS`) are marked `known-good override`. |
@@ -7,7 +7,6 @@ live one level up in `docs/migration/`.
7
7
 
8
8
  ## Available notes
9
9
 
10
- - [0.10.261020](0.10.261020.md) — Coordinated beta/stable channel switching, native Claude health, and automatic search-projection refresh.
11
10
  - [0.10.261019](0.10.261019.md) — The 0.10 series: 0.9.15 version floor, merged CLI spellings, `default`/`consolidate` strategies only, OpenCode 2, feedback-scaled ranking, and `0.10.YYMMNN` versions.
12
11
  - [0.9.19](0.9.19.md) — Bundle-scoped improve planning, proposal reopen, and consolidate/distill guardrails.
13
12
  - [0.9.17](0.9.17.md) — Retire proposals, declared links replacing the LLM entity graph, and scoped improve planning.
@@ -541,11 +541,11 @@ Result sections (`schemaVersion: 1`):
541
541
  | Field | Description |
542
542
  | --- | --- |
543
543
  | `window`, `filters` | The resolved window and the usage source counted |
544
- | `usage` | Search, show, curate and select totals, `selectRate` (selects over searches that returned a hit), `searchMedianMs`, a daily series, top assets, top and zero-result queries, and rows by source |
544
+ | `usage` | Search, show, curate and select totals, `searchesWithHits` and `selectsPerSearch` (selects divided by searches that returned a hit: a plain ratio that can exceed 1, not a conversion percentage, since selects carry no source or search id), `searchMedianMs`, a daily series, top assets, top and zero-result queries, and rows by source |
545
545
  | `feedback` | Positive and negative totals, per-asset valence, per-tag counts, and the most recent negatives with their reasons |
546
546
  | `utility` | A ten-bucket histogram of `utility_scores`, the lowest and highest assets, and how many indexed entries were never used |
547
547
  | `outcomes` | The assets with the lowest `outcome_score` |
548
- | `llm` | `akm health`'s LLM usage aggregate (`byStage`, `byProcess`, and `byEngine`, keyed by the name of the connection or harness) |
548
+ | `llm` | `akm health`'s LLM usage aggregate (`byStage`, `byProcess`, and `byEngine`, keyed by the name of the connection or harness). `usageCalls` and `reasoningCalls` count the calls whose provider response reported token usage; a call without it adds no tokens and the report shows `unknown`, not zero, when none reported |
549
549
  | `index`, `tasks`, `proposals`, `workflows` | Index runs and median time, task runs and fail rate, proposals by status and accept rate by source, workflow runs and tokens by model |
550
550
  | `rows` | The raw usage and LLM rows of the window; see below |
551
551
  | `notes` | Anything that limits what the numbers mean |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.10.261020-beta",
3
+ "version": "0.10.261021",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [
@@ -1,22 +0,0 @@
1
- # akm v0.10.261020 migration notes
2
-
3
- This beta fixes Claude plugin health reporting, adds coordinated channel
4
- switching, and reduces repeated fenced identifiers in search content.
5
-
6
- The next index pass refreshes existing content projections and changed embedding
7
- inputs automatically. No manual database or configuration migration is required
8
- when upgrading from 0.10.261019.
9
-
10
- After installing this beta, `akm upgrade --channel next` switches the CLI and
11
- installed Claude, Codex and OpenCode plugins together. The existing `--next`
12
- flag keeps its prior behavior. To return to stable, use
13
- `akm upgrade --channel latest`; if the target version is older, add
14
- `--allow-downgrade`. Changed settings receive backups, and the command reports
15
- any harness that could not finish switching. Restart the harness after updates.
16
-
17
- Claude health now uses native installation metadata. It reports conflicting or
18
- incomplete installation selections as unknown instead of choosing a cached
19
- version. This can change a previous warning without changing the installation.
20
-
21
- If upgrading from 0.9, first follow the
22
- [0.9 to 0.10 migration guide](../v0.9-to-v0.10.md).