akm-cli 0.9.2-alpha.2 → 0.9.2-alpha.4

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,9 +6,254 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.2-alpha.4] - 2026-08-26
10
+
11
+ ### Added
12
+
13
+ - **`akm health`: flag assets whose resolved type disagrees with their
14
+ directory** (#837). Adds a `type-directory-disagreement` advisory that
15
+ compares every indexed asset's resolved type against the type its
16
+ `DIR_TYPE_MAP` directory declares (`memories/`, `knowledge/`, `commands/`,
17
+ `agents/`, `workflows/`, `facts/`, `lessons/`, `sessions/`,
18
+ `instructions/`, `scripts/`, `env/`, `secrets/`, `tasks/`). This is the
19
+ diagnostic that would have caught #824 (three `memories/` files silently
20
+ indexed as commands) the day it was introduced. Since `knowledge/` +
21
+ `$ARGUMENTS` and `agents/` + `agent:` frontmatter are deliberate command
22
+ overrides, the check never hard-fails: every disagreement is reported as a
23
+ warning naming the winning classifier signal, with a `knownGoodOverride`
24
+ flag so a sanctioned override reads differently from an unexplained one.
25
+ - **`akm health`: report the Claude harness plugin's version and warn when
26
+ it's stale or out of range** (#838). Adds a `plugin-version` advisory that
27
+ reports each installed Claude Code `akm` plugin's version, warns when a
28
+ newer tag is published upstream (naming the update command), and warns
29
+ when the plugin's own declared `AKM_VERSION_RANGE` no longer admits the
30
+ running CLI — meaning the plugin has silently disabled itself. Makes an
31
+ outbound `git ls-remote` when network is available to check for a newer
32
+ tag; per owner decision, this is read-only and degrades to a benign pass
33
+ (no plugin, no marketplace clone, unreadable manifest, malformed range, or
34
+ a failed remote lookup) rather than crashing or blocking offline use.
35
+
36
+ ### Changed
37
+
38
+ - **Extract: LLM prompt is now built from parent-origin events only —
39
+ "harvest-without-prompting hybrid" (#840).** #830 folds a session's
40
+ subagent transcripts into its event stream for hashing and inline-ref
41
+ harvesting; the prompt sent to the extraction LLM previously included that
42
+ folded subagent content too, competing with the parent's own transcript
43
+ for the 80,000-char pre-filter budget. #840's design-determination doc
44
+ (`docs/plans/subagent-extraction-design.md`) measured that this "fold"
45
+ approach evicts up to 28.6% of parent-origin content on real sessions to
46
+ make room for subagent noise that mostly gets evicted anyway, while a
47
+ "harvest-without-prompting hybrid" — keep folding for hashing/inline-ref
48
+ purposes, but filter the prompt down to `data.events` whose `filePath`
49
+ matches the session's own (`data.ref.filePath`) — matches or beats the
50
+ folded prompt's size with zero eviction on every session measured, and
51
+ recovers the exact same inline refs (`akm remember`/`akm feedback` calls
52
+ the agent made inside a subagent), because that harvesting already runs on
53
+ the raw stream independent of what reaches the prompt. Only
54
+ `runPreLlmSessionGates`'s call into `preFilterSession` changed; folding
55
+ (`session-log.ts`) and `buildExtractPrompt` are untouched.
56
+ - **No forced re-extraction wave.** `hashSessionContent` still hashes the
57
+ full folded `data` (parent + subagents), computed before the
58
+ parent-origin view is built — no previously-computed session hash
59
+ changes, so no session already extracted under the fold prompt shape is
60
+ automatically re-processed. Use `--force` to re-process a specific
61
+ session under the new, parent-only prompt shape.
62
+ - **`processes.extract.maxTotalChars` is unchanged in meaning and default**
63
+ — it still caps the single-call prompt built from parent-origin events;
64
+ it simply no longer has to compete against subagent-origin noise for
65
+ that budget.
66
+ - **`minContentChars`** (the raw-size skip gate, #595/#596) is still
67
+ measured on the FULL folded `data.events` (parent + subagents),
68
+ deliberately left unchanged: narrowing it to parent-origin chars would
69
+ newly skip delegation-heavy sessions with a thin parent transcript
70
+ before extraction runs at all, even though their subagent-origin work is
71
+ still fully harvested via inline refs. The full-stream measurement is
72
+ today's existing behavior; the worst case it preserves is an LLM call
73
+ over a small parent-only prompt, not a missed extraction.
74
+ - #839's task-notification dedupe (which stubs a parent's
75
+ `<task-notification>` only when the matching subagent's own event ALSO
76
+ survives into the same kept prompt set) composes safely with this
77
+ change without modification: subagent-origin events never reach
78
+ `preFilterSession` on this path, so the dedupe's own scoping check
79
+ naturally makes it a no-op — the parent's notification (the only
80
+ remaining trace of delegated work in the prompt) survives untouched.
81
+
82
+ ### Fixed
83
+
84
+ - **`akm remember` synthesizes a description when the caller doesn't supply
85
+ one** (#835). Both the zero-flag hot path and the structured-args path
86
+ (e.g. `--tag`-only, with no `--description`/`--enrich`) previously wrote
87
+ memories with no `description:` and no `tags:`. akm's indexer covers only
88
+ synthesized frontmatter/headings, never body prose, so those memories were
89
+ retrievable only by whatever words survived into the auto-generated
90
+ filename — effectively write-only. Verified on a real stash: 272/3169
91
+ memories lacked a description, 100% of those written via `akm remember`.
92
+ The new `synthesizeMemoryDescription` (ported from akm-eval's
93
+ `firstSentencesCapped` rule, which independently arrived at the same fix)
94
+ is deterministic and makes no LLM call: it accumulates whole sentences
95
+ from the body up to `DESCRIPTION_MAX_CHARS`, skipping a leading markdown
96
+ heading so the description doesn't just repeat the title. Wired into both
97
+ write paths as a fallback only — a caller-supplied `--description` (or one
98
+ derived by `--enrich`) is never overwritten. Closes the write-only-memories
99
+ gap on 0.9.1 indexes.
100
+ - **Extract: deduped the doubled subagent conclusion in the extraction prompt**
101
+ (#839). After #830 folded a session's subagent transcripts into its event
102
+ stream, a completed subagent's final report could appear twice in the same
103
+ extraction prompt: once as the subagent's own folded final message, once as
104
+ the parent's `<task-notification>` record of that same call (#836 measured
105
+ ~92-99% textual overlap on a real pair; reproduced here as a byte-identical
106
+ match after decoding the XML entities Claude Code escapes into `<result>`).
107
+ The parent's notification copy is now stubbed to `[subagent <agentId>
108
+ completed: <description>]` when its `<result>` is a near-duplicate
109
+ (Dice-bigram similarity ≥ 0.9) of a folded subagent transcript's own text;
110
+ the subagent's original is untouched, per #839's owner-decided direction
111
+ (the inverse — dropping the subagent's own terminal event — was evaluated
112
+ and rejected in #836 because some subagent transcripts consist only of
113
+ that one event). Matching is scoped by `<task-id>` to the one subagent
114
+ transcript it names and still requires content similarity, so an earlier
115
+ notification for a *resumed* agent (Claude Code re-notifies the same
116
+ task-id on each stop) that carries a genuinely different, intermediate
117
+ result is left alone.
118
+ **Scoped to the final, post-budget kept set — not the raw stream** (#840's
119
+ design-determination doc flagged this as a hazard while this PR was in
120
+ flight): the dedupe only fires when the subagent's own event ALSO survives
121
+ into the same kept set as the notification. #840 measured that today's
122
+ recency-biased 80k budget already evicts one side of nearly every raw
123
+ duplicate pair before dedupe would matter (0 of 89 raw pairs across four
124
+ real sessions had both sides survive); an unconditional raw-stream stub
125
+ would, under that same eviction pattern, sometimes delete a parent's
126
+ notification whose subagent copy never made the cut in the first place —
127
+ and would unconditionally delete the *only* surviving trace of delegated
128
+ work under #840's recommended future design (prompting from parent-origin
129
+ events only). Verified against the real session #836 and #839 both cite
130
+ (`4a0d9e9b…`): under the actual 80,000-char budget, 0 notifications are
131
+ stubbed today (consistent with #840's finding) because the cited pair's
132
+ subagent copy doesn't survive the budget; with the budget cap lifted,
133
+ 1 of 10 raw duplicate pairs in that session both survive AND still exceed
134
+ the 0.9 similarity bar after the pre-filter's independent per-event
135
+ 2000-char truncation (the other 9 exceed that per-event cap and truncate
136
+ down far enough to fall below the bar — a conservative miss, never a wrong
137
+ stub). The fix is real and correct for sessions/pairs small enough to avoid
138
+ both eviction and truncation, and is structurally inert wherever it would
139
+ be unsafe to fire.
140
+ Implemented in the pre-filter (`preFilterSession`), which runs AFTER
141
+ `hashSessionContent` — so **no `contentHash` moves and no re-extraction
142
+ wave is triggered** (unlike #830's own folding change, which changed the
143
+ raw event stream #602's hash covers).
144
+ - **Extract: regression-tested the no-double-extraction guarantee** (#839).
145
+ Discovery-mode extraction over a project with a parent + subagent
146
+ transcripts now has an explicit end-to-end test proving exactly one
147
+ session is processed, that `--session-id agent-<hash>` resolves to the
148
+ not-found result rather than an extraction, and that folded subagent
149
+ content is attributed only to the parent's session/contentHash. Pins
150
+ behavior already true since #830 (`listSessions()` excludes `subagents/`
151
+ dirs for both discovery and `--session-id` lookup); nothing tested it
152
+ end-to-end before.
153
+
154
+ ### Documentation
155
+
156
+ - **Measured whether subagent-transcript folding (#830) duplicates the
157
+ parent's own summary, and disclosed the one-time re-extraction cost
158
+ (#833).** Using the actual reader/pre-filter/prompt-builder code against 3
159
+ real sessions on this machine — no LLM calls; `contentHash`,
160
+ `preFilterSession`, and `buildExtractPrompt` are deterministic:
161
+ - Raw event counts grow 2x-12x once subagent transcripts are folded in
162
+ (measured: 1209 -> 14224; 1583 -> 6738, the exact session cited in
163
+ #829/#833's "1583 -> 6738" figure; 155 -> 2408). `contentHash` is
164
+ computed over that stream, so every previously-extracted session's hash
165
+ changes and the next `--since` run re-extracts all of them once, each
166
+ with a larger prompt (+1.2% to +5.2% prompt chars across the 3 sessions,
167
+ since the 80,000-char pre-filter budget caps how much of the growth
168
+ actually reaches the LLM).
169
+ - The result is a genuine tradeoff, not a clean win or loss. **Benefit:**
170
+ inline `akm remember`/`akm feedback` calls made *by subagents* are
171
+ recovered regardless of the budget cap (inline-ref extraction runs on
172
+ the raw event stream, not the pre-filtered one) — up to 162 refs
173
+ recovered on the largest session measured (was 2 without folding),
174
+ fixing #829's "delegated work is never harvested" defect. **Cost:** on
175
+ sessions whose raw content is near or under the pre-filter's character
176
+ budget, folding evicts a large share of the parent's own kept content to
177
+ make room for subagent tool-call trace — parent-origin kept events
178
+ dropped 27% and 71% respectively on the two smaller sessions measured.
179
+ On the largest session the budget was already saturated by the parent's
180
+ own tail, so folding changed nothing there. Duplication is real, not
181
+ hypothetical: on the smallest session, one subagent's conclusion appears
182
+ twice in the same prompt sent to the extraction LLM — once via its own
183
+ folded final message, once via the parent's own record of that
184
+ delegated call's result, which independently already captured ~92% of
185
+ the same text verbatim.
186
+ - A narrowing that drops a subagent transcript's terminal event (its
187
+ apparent "final report") to avoid this specific duplication was
188
+ considered and rejected: the existing #830 regression fixture has a
189
+ subagent transcript whose *only* event is that terminal turn (a single
190
+ delegated `akm remember` call) — the same rule would drop the only
191
+ content in short single-step delegations, undoing the harvesting #830
192
+ added.
193
+ - **Decision: keep folding as shipped.** The data does not cleanly favor
194
+ removing or narrowing it, and the one narrowing considered would cost
195
+ more than it fixes. #829's phantom-session exclusion is unaffected
196
+ either way.
197
+ - Recorded the fold-vs-link subagent-extraction design determination in
198
+ `docs/plans/subagent-extraction-design.md` (#840). Measured four candidates
199
+ (fold+dedupe as shipped, link-only, a harvest-without-prompting hybrid, and
200
+ chunked map-reduce extraction) on the same real sessions #836 used plus one
201
+ added for scale. Headline: the hybrid recovers 100% of #830's inline-ref
202
+ harvesting (162/162, 38/38, 1/1, 8/8 across the four sessions) with zero
203
+ parent-content eviction (vs 27.5%/28.6% evicted under fold on two of the
204
+ four), and #839's dedupe was measured to have zero effect on the actual
205
+ LLM prompt on all four sessions (the flagged duplicate content is already
206
+ evicted by the recency-biased budget before dedupe would matter). Chunked
207
+ extraction was measured at 9x-229x more LLM calls per session on real
208
+ data and is not recommended. No behavior changes shipped in this PR.
209
+
210
+ ## [0.9.2-alpha.3] - 2026-08-26
211
+
212
+ ### Fixed
213
+
214
+ - **Currency in prose no longer retypes an asset as a command** (#824). The
215
+ smart-Markdown classifier matched `$1`/`$2`/`$3` with a trailing word
216
+ boundary, and that boundary sits between the `2` and the comma in `$2,000` —
217
+ so any note quoting a price was indexed as a `command`, its ref moved to
218
+ `commands/<dir>/<slug>`, and it left its own namespace. Measured on a real
219
+ corpus, 3 of 51 memory documents were affected, and those 3 were exactly the
220
+ 3 whose bodies matched. `$ARGUMENTS` is unambiguous and keeps its existing
221
+ precedence over a directory hint; the numeric placeholders now exclude a
222
+ following digit (or a `.`/`,` followed by one), and where they still
223
+ disagree with a directory that declares a type, the declaration wins. The
224
+ defect is present identically in 0.9.1 — it only became visible once the
225
+ 0.9.2-alpha.2 retrieval work let mistyped assets surface in results.
226
+
227
+ ### Documentation
228
+
229
+ - Recorded the 0.9.2 retrieval measurement in
230
+ `docs/plans/benchmark-tuning-findings.md` §2e (#825). Retrieval-only probes
231
+ with no model in the loop, identical corpora, only the CLI version differing:
232
+ LoCoMo zero-hit 75.0% -> 0.0% and evidence recall@5 0.154 -> 0.590;
233
+ LongMemEval zero-hit 100% -> 0.0% and recall@5 0.000 -> 1.000.
234
+
9
235
  ## [0.9.2-alpha.2] - 2026-08-25
10
236
 
11
- - TODO
237
+ ### Fixed
238
+
239
+ - **Retrieval:** relaxed zero-hit lexical queries centrally, stabilized
240
+ relaxed-retrieval quality, and preserved name quality through relaxed
241
+ ranking. This is the change measured in §2e above — it is what lifted the
242
+ retrieval ceiling that had floored memory-backed evaluation.
243
+ - **Indexing:** verify vec completeness before promotion; compare vec IDs as
244
+ exact sets; materialize vectors for targeted writes; make nested entry
245
+ mutation atomic; reconcile clean before final verification; restore static
246
+ embedding imports.
247
+ - **Markdown projection:** parse nested links safely, parse destination
248
+ phases, and project with stateful delimiters.
249
+ - **Sources:** reconcile local bundle updates and report incomplete filesystem
250
+ reconciliation.
251
+ - **Extract:** keep malformed model output retryable.
252
+
253
+ ### Performance
254
+
255
+ - Keep targeted embedding selection narrow and preserve targeted vec
256
+ degradation.
12
257
 
13
258
  ## [0.9.2-alpha.1] - 2026-08-24
14
259
 
@@ -0,0 +1,219 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
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 the one deliberate exception to "`akm
33
+ * health` makes no network call" (see `./health-advisories.md`): a plugin's
34
+ * local marketplace clone is not proof of what is newest upstream — the
35
+ * incident above involved a clone that hadn't seen the fix's tag at all — so
36
+ * the only way to ever detect drift is to ask the remote what tags exist.
37
+ * That query is a `git ls-remote --tags` (lists refs; fetches nothing,
38
+ * writes nothing) with a short timeout, and any failure (offline, no
39
+ * remote, timeout) degrades to "installed version reported, no staleness
40
+ * claim" rather than a false positive or a hang.
41
+ *
42
+ * Every collector here is best-effort and silent on missing/unreadable
43
+ * input: no Claude plugin installed, no marketplace clone, an unreadable
44
+ * manifest, or a malformed version range must never crash `akm health` and
45
+ * must never produce a false "stale" or "inactive" warning.
46
+ */
47
+ import { spawnSync } from "node:child_process";
48
+ import fs from "node:fs";
49
+ import os from "node:os";
50
+ import path from "node:path";
51
+ import { isExactSemver, isSemverRange, maxSatisfying, satisfiesRange } from "../../registry/semver.js";
52
+ /**
53
+ * Root directory holding Claude Code's plugin cache + marketplace clones.
54
+ * Resolved per call (not memoized) so `AKM_CLAUDE_PLUGINS_DIR` can be set
55
+ * after import — the override exists so tests point this at an empty
56
+ * fixture directory instead of the real `~/.claude/plugins`, matching
57
+ * `AKM_CLAUDE_PROJECTS_DIR` in `../../integrations/harnesses/claude/session-log.ts`.
58
+ */
59
+ function claudePluginsDir() {
60
+ return process.env.AKM_CLAUDE_PLUGINS_DIR ?? path.join(os.homedir(), ".claude", "plugins");
61
+ }
62
+ /**
63
+ * Scan `<pluginsRoot>/cache/<marketplace>/akm/<version>/` for every
64
+ * `akm` plugin cache entry, picking the highest cached version per
65
+ * marketplace when more than one is present. Returns `[]` (never throws)
66
+ * when the cache directory is absent, empty, or unreadable — that is the
67
+ * ordinary "no Claude plugin installed" case, not an error.
68
+ */
69
+ function detectInstalledPlugins(pluginsRoot) {
70
+ const cacheDir = path.join(pluginsRoot, "cache");
71
+ let marketplaces;
72
+ try {
73
+ marketplaces = fs.readdirSync(cacheDir);
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const detected = [];
79
+ for (const marketplace of marketplaces) {
80
+ const pluginDir = path.join(cacheDir, marketplace, "akm");
81
+ let versions;
82
+ try {
83
+ versions = fs.readdirSync(pluginDir).filter(isExactSemver);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (versions.length === 0)
89
+ continue;
90
+ const latest = maxSatisfying(versions, "*") ?? versions.sort().at(-1);
91
+ if (!latest)
92
+ continue;
93
+ const versionDir = path.join(pluginDir, latest);
94
+ const manifestPath = path.join(versionDir, ".claude-plugin", "plugin.json");
95
+ try {
96
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
97
+ if (typeof manifest.version !== "string")
98
+ continue;
99
+ detected.push({
100
+ harness: "claude",
101
+ marketplace,
102
+ pluginName: "akm",
103
+ version: manifest.version,
104
+ pluginDir: versionDir,
105
+ });
106
+ }
107
+ catch {
108
+ // Unreadable/malformed manifest — skip this entry rather than crash.
109
+ }
110
+ }
111
+ return detected;
112
+ }
113
+ /**
114
+ * Extract `AKM_VERSION_RANGE` from the installed plugin's vendored
115
+ * `shared/akm-version.ts`. Returns `undefined` when the file is missing,
116
+ * unreadable, or does not contain the expected declaration — callers must
117
+ * treat that as "compatibility unknown", never as a mismatch.
118
+ */
119
+ function readVersionRange(pluginDir) {
120
+ const versionFilePath = path.join(pluginDir, "shared", "akm-version.ts");
121
+ let text;
122
+ try {
123
+ text = fs.readFileSync(versionFilePath, "utf8");
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ const match = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/);
129
+ return match?.[1];
130
+ }
131
+ const LS_REMOTE_TIMEOUT_MS = 5_000;
132
+ /**
133
+ * `git ls-remote --tags origin` against the marketplace clone's configured
134
+ * remote — lists refs only, fetches no objects, writes no local refs.
135
+ * Returns `undefined` (never throws) when the directory is not a git
136
+ * checkout, has no `origin` remote, or the command fails/times out (e.g.
137
+ * offline) — all "cannot determine availability", not "up to date".
138
+ */
139
+ const realListRemoteTags = (marketplaceDir) => {
140
+ let result;
141
+ try {
142
+ result = spawnSync("git", ["-C", marketplaceDir, "ls-remote", "--tags", "origin"], {
143
+ encoding: "utf8",
144
+ timeout: LS_REMOTE_TIMEOUT_MS,
145
+ });
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ if (result.status !== 0 || !result.stdout)
151
+ return undefined;
152
+ const tags = result.stdout
153
+ .split("\n")
154
+ .map((line) => line.trim())
155
+ .filter(Boolean)
156
+ .map((line) => line.split("\t")[1])
157
+ .filter((ref) => typeof ref === "string" && ref.startsWith("refs/tags/"))
158
+ .map((ref) => ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, ""))
159
+ .map((tag) => tag.replace(/^v/, ""))
160
+ .filter(isExactSemver);
161
+ return [...new Set(tags)];
162
+ };
163
+ /**
164
+ * Build one `plugin-version` advisory per detected `akm` harness plugin.
165
+ * Returns `[]` when no plugin is installed — the benign, common case.
166
+ */
167
+ export function collectPluginStalenessAdvisories(options) {
168
+ const pluginsRoot = options.pluginsRoot ?? claudePluginsDir();
169
+ const listRemoteTags = options.listRemoteTags ?? realListRemoteTags;
170
+ const plugins = detectInstalledPlugins(pluginsRoot);
171
+ return plugins.map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags));
172
+ }
173
+ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags) {
174
+ const pluginRef = `${plugin.pluginName}@${plugin.marketplace}`;
175
+ // Point 2: newest available vs. installed, via the marketplace clone's remote.
176
+ const marketplaceDir = path.join(pluginsRoot, "marketplaces", plugin.marketplace);
177
+ let availableVersion;
178
+ try {
179
+ const tags = fs.existsSync(marketplaceDir) ? listRemoteTags(marketplaceDir) : undefined;
180
+ availableVersion = tags && tags.length > 0 ? maxSatisfying(tags, "*") : undefined;
181
+ }
182
+ catch {
183
+ availableVersion = undefined;
184
+ }
185
+ const stale = availableVersion !== undefined &&
186
+ plugin.version !== availableVersion &&
187
+ maxSatisfying([plugin.version, availableVersion], "*") === availableVersion;
188
+ // Point 3: does the plugin's own declared range admit the running CLI?
189
+ const versionRange = readVersionRange(plugin.pluginDir);
190
+ const rangeKnown = versionRange !== undefined && isSemverRange(versionRange);
191
+ const admitted = rangeKnown ? satisfiesRange(cliVersion, versionRange) : undefined;
192
+ const messageParts = [`${pluginRef}: installed ${plugin.version}`];
193
+ if (availableVersion !== undefined) {
194
+ messageParts.push(stale ? `available ${availableVersion} (STALE)` : `available ${availableVersion} (up to date)`);
195
+ }
196
+ if (stale)
197
+ messageParts.push(`-> claude plugin update ${pluginRef}`);
198
+ if (rangeKnown && admitted === false) {
199
+ messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED (plugin is inactive)`);
200
+ }
201
+ return {
202
+ name: "plugin-version",
203
+ kind: "deterministic",
204
+ status: stale || admitted === false ? "warn" : "pass",
205
+ confidence: "high",
206
+ message: messageParts.join(" — "),
207
+ evidence: {
208
+ harness: plugin.harness,
209
+ marketplace: plugin.marketplace,
210
+ plugin: plugin.pluginName,
211
+ installedVersion: plugin.version,
212
+ availableVersion: availableVersion ?? null,
213
+ stale,
214
+ versionRange: versionRange ?? null,
215
+ cliVersion,
216
+ admitted: admitted ?? null,
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,167 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `type-directory-disagreement` advisory for `akm health` (#831).
6
+ *
7
+ * The invariant "a file's directory declares its type" is load-bearing for
8
+ * refs, namespace listings, and `akm show` paths (see #824: three files
9
+ * written to `memories/` were indexed as `type: command`, moved their refs
10
+ * under `commands/memories/<slug>`, and silently vanished from the
11
+ * `memories/` namespace — nothing on any normal surface said so). This
12
+ * advisory re-checks that invariant against every currently indexed entry.
13
+ *
14
+ * Legitimate disagreements exist by design — a `knowledge/` file containing
15
+ * `$ARGUMENTS` is deliberately a `command`, and an `agents/` file with an
16
+ * `agent:` frontmatter key is deliberately a `command` (both asserted in
17
+ * `tests/integration/commands/show.test.ts`). So this is never a hard
18
+ * failure: every disagreement is reported with a `winner` naming which
19
+ * classifier signal produced the resolved type, so a deliberate override
20
+ * reads differently from an unexplained one.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
25
+ /**
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.
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
+ };
47
+ const realReadFile = (absPath) => {
48
+ try {
49
+ return fs.readFileSync(absPath, "utf8");
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ };
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
+ /**
81
+ * Best-effort explanation for why `classifyBySmartMd` (matchers.ts) would
82
+ * have produced `resolvedType` for this content, in the SAME precedence
83
+ * order the real function checks them. Returns `undefined` when no known
84
+ * override signal is found — that absence is itself the accident signal:
85
+ * nothing in the file explains why its type disagrees with its directory.
86
+ *
87
+ * The numeric-placeholder branch is flagged `knownGoodOverride: false` on
88
+ * purpose: since #826, that heuristic is guarded to never fire when the file
89
+ * sits under a declared-type directory, so seeing it win here would mean the
90
+ * guard regressed, not that this is a sanctioned override.
91
+ */
92
+ function explainOverride(resolvedType, content) {
93
+ const fm = parseFrontmatter(content).data;
94
+ if (fm.type === "workflow" && resolvedType === "workflow") {
95
+ return { winner: "smart-md:workflow-frontmatter", knownGoodOverride: true };
96
+ }
97
+ if ("tools" in fm && resolvedType === "agent") {
98
+ return { winner: "smart-md:tools-frontmatter", knownGoodOverride: true };
99
+ }
100
+ if ("agent" in fm && resolvedType === "command") {
101
+ return { winner: "smart-md:agent-frontmatter", knownGoodOverride: true };
102
+ }
103
+ if (resolvedType === "command" && content.includes("$ARGUMENTS")) {
104
+ return { winner: "smart-md:$ARGUMENTS", knownGoodOverride: true };
105
+ }
106
+ if (resolvedType === "command" && /\$[123](?!\d|[.,]\d)/.test(content)) {
107
+ return { winner: "smart-md:numeric-placeholder", knownGoodOverride: false };
108
+ }
109
+ if ("model" in fm && resolvedType === "agent") {
110
+ return { winner: "smart-md:model-frontmatter", knownGoodOverride: true };
111
+ }
112
+ return undefined;
113
+ }
114
+ /**
115
+ * Compare every indexed entry's resolved type against the type its
116
+ * directory declares (see {@link DECLARED_DIR_TYPES}), and return one
117
+ * {@link TypeDirectoryDisagreement} per mismatch, sorted by path. Entries
118
+ * outside any declared-type directory are not checked — this is only the
119
+ * "directory declares type" invariant.
120
+ */
121
+ export function collectTypeDirectoryDisagreements(entries, readFile = realReadFile) {
122
+ const disagreements = [];
123
+ for (const entry of entries) {
124
+ const declared = declaredTypeForPath(entry.filePath);
125
+ if (!declared || declared.type === entry.type)
126
+ continue;
127
+ const content = readFile(entry.filePath);
128
+ const explanation = content === undefined ? undefined : explainOverride(entry.type, content);
129
+ disagreements.push({
130
+ path: entry.filePath,
131
+ resolved: entry.type,
132
+ expected: declared.type,
133
+ winner: explanation?.winner ?? "unknown",
134
+ knownGoodOverride: explanation?.knownGoodOverride ?? false,
135
+ });
136
+ }
137
+ return disagreements.sort((a, b) => a.path.localeCompare(b.path));
138
+ }
139
+ const MAX_DETAIL_LINES = 10;
140
+ /**
141
+ * Build the `type-directory-disagreement` advisory, or `undefined` when
142
+ * every indexed entry agrees with its directory. Always `status: "warn"`
143
+ * (never `"fail"`) — a deliberate override is still a disagreement worth
144
+ * seeing, just not a gate.
145
+ */
146
+ export function buildTypeDirectoryAdvisory(entries, readFile = realReadFile, displayPath = (p) => p) {
147
+ const disagreements = collectTypeDirectoryDisagreements(entries, readFile);
148
+ if (disagreements.length === 0)
149
+ return undefined;
150
+ const lines = disagreements.slice(0, MAX_DETAIL_LINES).map((d) => {
151
+ const note = d.knownGoodOverride ? " (known-good override)" : "";
152
+ return `${displayPath(d.path)} resolved=${d.resolved} expected=${d.expected} winner=${d.winner}${note}`;
153
+ });
154
+ if (disagreements.length > MAX_DETAIL_LINES) {
155
+ lines.push(`+${disagreements.length - MAX_DETAIL_LINES} more`);
156
+ }
157
+ return {
158
+ name: "type-directory-disagreement",
159
+ kind: "deterministic",
160
+ status: "warn",
161
+ confidence: "high",
162
+ message: `${disagreements.length} indexed asset(s) have a resolved type that disagrees with the type their directory declares: ${lines.join("; ")}`,
163
+ evidence: {
164
+ disagreements: disagreements.map((d) => ({ ...d, path: displayPath(d.path) })),
165
+ },
166
+ };
167
+ }