akm-cli 0.10.261014-beta → 0.10.261016-beta

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +7 -8
  3. package/SECURITY.md +3 -3
  4. package/STABILITY.md +2 -1
  5. package/dist/assets/hints/cli-hints-full.md +6 -4
  6. package/dist/assets/hints/cli-hints-short.md +4 -2
  7. package/dist/assets/opencode/akm-plugin.js +16 -8
  8. package/dist/assets/prompts/workflow-unit-preamble.md +3 -1
  9. package/dist/cli.js +2 -2
  10. package/dist/commands/health/plugin-staleness.js +86 -22
  11. package/dist/commands/health/semantic-search-check.js +2 -2
  12. package/dist/commands/read/search-cli.js +2 -2
  13. package/dist/commands/read/show.js +15 -3
  14. package/dist/core/asset/markdown.js +9 -0
  15. package/dist/core/config/config-walker.js +35 -32
  16. package/dist/core/config/schema/connections.js +6 -3
  17. package/dist/core/config/schema/harnesses.js +8 -1
  18. package/dist/core/config/schema/primitives.js +12 -1
  19. package/dist/core/config/schema/sources-bundles.js +2 -2
  20. package/dist/core/npm-package.js +124 -0
  21. package/dist/{integrations/harnesses/opencode/runtime → core}/tar.js +25 -16
  22. package/dist/integrations/harnesses/opencode/runtime/binary.js +8 -75
  23. package/dist/integrations/harnesses/opencode/runtime/index.js +5 -1
  24. package/dist/llm/embedders/local.js +23 -58
  25. package/dist/llm/embedders/onnx-runtime.js +121 -0
  26. package/dist/output/renderers.js +2 -2
  27. package/dist/output/shapes/helpers.js +2 -0
  28. package/dist/output/text/show-format.js +7 -0
  29. package/dist/registry/semver.js +12 -0
  30. package/dist/scripts/akm-migrate-node.js +21638 -5760
  31. package/dist/scripts/akm-migrate.js +21645 -5767
  32. package/dist/setup/semantic-assets.js +9 -6
  33. package/dist/setup/setup.js +56 -2
  34. package/dist/setup/steps/embeddings.js +21 -4
  35. package/dist/storage/database.js +17 -57
  36. package/dist/tasks/backends/cron.js +19 -0
  37. package/docs/architecture/akm-core-principles.md +3 -2
  38. package/docs/architecture/internals/health-advisories.md +2 -2
  39. package/docs/architecture/internals/storage-locations.md +1 -0
  40. package/docs/architecture/specs/ref.md +4 -3
  41. package/docs/architecture/testing/testing-workflow.md +28 -20
  42. package/docs/guides/discover-and-load.md +12 -4
  43. package/docs/guides/getting-started.md +23 -20
  44. package/docs/guides/recipes/headless-install.md +13 -8
  45. package/docs/guides/use-with-any-agent.md +4 -1
  46. package/docs/maintainers/release-checklist.md +15 -8
  47. package/docs/migration/v0.9-to-v0.10.md +17 -0
  48. package/docs/reference/cli.md +42 -12
  49. package/docs/reference/configuration.md +37 -15
  50. package/package.json +6 -9
  51. package/schemas/akm-config.json +535 -302
  52. package/dist/standalone/onnxruntime-library.js +0 -52
package/CHANGELOG.md CHANGED
@@ -6,6 +6,124 @@ 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.261016] - 2026-10-27
10
+
11
+ ### Added
12
+
13
+ - `akm show` returns a Markdown document's outline as `toc` (#1259): one
14
+ `{ level, text, slug, startLine }` entry per heading, at every `--detail`
15
+ level. `akm show <ref> --detail brief` is the outline without the body, and
16
+ every `slug` selects its section with `akm show <ref>#<slug>`, repeated
17
+ headings included. The outline is read from the file the section is cut
18
+ from. Fragment shows, non-Markdown assets and documents with no headings
19
+ carry no `toc`. The knowledge `action`, `akm show --help`, the unmatched
20
+ fragment error, `akm hints` and the OpenCode runtime's `akm_show` tool (which
21
+ takes `detail: "brief"`) now point agents at the outline first, then the
22
+ sections they need.
23
+
24
+ ### Changed
25
+
26
+ - The Release workflow waits for CI on the commit it publishes, up to 25 minutes,
27
+ instead of failing when that run has not finished, so Release can be dispatched
28
+ right after the release commit is pushed. It still fails when CI failed or was
29
+ cancelled, and only the newest push or manual CI run counts: a pull request's
30
+ run tests a merge and, on a draft, skips the upgrade rehearsal.
31
+ `tests/release-check.sh` no longer repeats the upgrade rehearsal that CI runs on
32
+ the same commit.
33
+ - `bun run check:ci` runs the pre-push check the way CI does (in a Docker
34
+ container with the Bun and Node versions `ci.yml` pins, a clean frozen install,
35
+ `CI=true` and four test shards); `.github/CONTRIBUTING.md` says when to run it.
36
+
37
+ ### Fixed
38
+
39
+ - Two processes fetching the OpenCode binary akm's own runtime runs (or the local
40
+ embedder's ONNX Runtime files) into an empty cache at once no longer rename
41
+ their copies over each other (#1266). Each downloaded the package and renamed
42
+ its file into place, which unlinks a binary the other process is already
43
+ running (it then fails to start it again). One fetch now runs per cache
44
+ directory at a time, under a `fetch.lock` file in it (a dead holder's lock is
45
+ reclaimed); a process that finds the files in place once it holds the lock
46
+ downloads nothing, and a rename never replaces a file that is already whole.
47
+ - `akm health`'s `plugin-version` advisory no longer reports a Claude Code
48
+ plugin on the `next` channel as stale against the 0.9 stable release. It read
49
+ the newest version with a semver rule that skips every prerelease. It now reads
50
+ the channel the marketplace clone follows: on `next`, the release tag on that
51
+ branch's head; on the default branch, the newest stable tag; on a marketplace
52
+ added at a tag, that tag.
53
+ - When `crontab -l` cannot read the crontab spool (permission denied) in a
54
+ process started with no-new-privileges (an agent sandbox, a container, systemd
55
+ `NoNewPrivileges=`), akm says why: `crontab` reads the spool through its setgid
56
+ (or setuid) bit, which such a process does not grant. `akm health` and
57
+ `akm task` commands run from such a process used to repeat a bare
58
+ `fopen: Permission denied`. A refusal without no-new-privileges is reported as
59
+ before.
60
+ - `akm health`'s `opencode-plugin-version` advisory reads the OpenCode 1 plugin
61
+ cache of the spec the global OpenCode config names (#1265). It read only
62
+ `packages/akm-opencode`, but OpenCode 1 caches each spec in its own folder, so
63
+ a host with `"plugin": ["akm-opencode@next"]` (cached in
64
+ `packages/akm-opencode@next`) showed no row at all. It now reads the one folder
65
+ of the spec under `plugin`: a bare name is `akm-opencode@latest`, as
66
+ `akm upgrade` reads it, and `akm-opencode@next` is its own folder. A version
67
+ pin (`akm-opencode@<version>`) is reported as pinned, with no staleness
68
+ verdict. With no entry, or no folder for it, the check reads
69
+ `packages/akm-opencode` as before; other cached specs are not scanned.
70
+
71
+ ### Security
72
+
73
+ - Zod moves from 3.25.76 to 4.6.7 (`zod` `^4.6.6`), closing GHSA-mrhx-c7xc-m3w5 /
74
+ CVE-2023-54404 (high): Zod before 4.6.6 can exhaust memory validating a large array,
75
+ and no Zod 3 release has the fix. akm validates its config files with Zod. Visible
76
+ changes: an integer above 2^53 - 1 in a config file is now rejected; validation
77
+ messages are Zod 4's (an unknown enum value reads `Invalid option: expected one of ...`);
78
+ `schemas/akm-config.json` is generated with `z.toJSONSchema` (the `zod-to-json-schema`
79
+ dev dependency is gone): `additionalProperties: true` is written `{}` (the same), integers
80
+ state the new `maximum: 9007199254740991`, and every map states its `propertyNames`.
81
+
82
+ ## [0.10.261015] - 2026-10-27
83
+
84
+ ### Changed
85
+
86
+ - `akm setup` downloads what the configuration it saves needs (#1254). Choosing the
87
+ built-in embedder (`--embedding local`, or the wizard's choice) downloads ONNX
88
+ Runtime 1.24.3 for this platform once from the npm registry (about 86 MB,
89
+ checked against the registry's sha512 integrity before anything is extracted)
90
+ into `<cache dir>/onnxruntime/<version>-<platform>-<arch>/`, and the model
91
+ (about 127 MB), then loads both. The wizard asks first, as before; `--yes`
92
+ and `--from` do it once the config is saved and report `semanticAssets`
93
+ (`ready`, `blocked: <reason>`, or `skipped`). `--no-probe` now means nothing
94
+ is contacted or downloaded; the first `akm index` downloads what setup did
95
+ not, which is also how an install upgraded from an earlier release gets the
96
+ runtime: its next `akm index` downloads it once. The vectors are unchanged,
97
+ so no re-index is needed.
98
+ - Local embeddings are offered on Linux x64 (glibc) and Apple-silicon
99
+ macOS (#1256). Every other platform uses keyword search or an embedding endpoint:
100
+ setup offers only those, with one sentence saying why, `--embedding local`
101
+ warns and saves keyword search, `--from` keeps the file's values, the index
102
+ reports semantic search unavailable for the platform, and `akm health` warns
103
+ with the setup arguments for keyword search or an endpoint.
104
+ - When agent work runs on akm's own runtime, the setup wizard offers to download
105
+ its OpenCode binary now rather than on the first agent run. `--yes` and
106
+ `--from` leave it to the first run.
107
+ - The npm package installs with no install script anywhere in its tree (#1255), so it
108
+ works as installed where npm runs none (npm 12 blocks dependency scripts by
109
+ default). On Node, the SQLite driver is `better-sqlite3` 13.0.3, which ships
110
+ prebuilt N-API bindings for Linux (glibc and musl) and macOS on x64 and arm64
111
+ and for Windows on x64 and arm64: a Node upgrade no longer needs
112
+ `npm rebuild`. 32-bit ARM Linux has no SQLite binding on Node.
113
+ - Standalone binaries carry no native code and are up to 36 MB smaller (Linux
114
+ x64: 123.6 MB to 88.0 MB). A `noexec` temp directory no longer affects
115
+ semantic search (#1257); akm's cache directory must still allow executing files.
116
+
117
+ ### Removed
118
+
119
+ - The `onnxruntime-node` dependency, and with it the install script that
120
+ downloaded about 317 MB of CUDA providers on every Linux x64 `npm install`
121
+ (akm uses the CPU only; #1254). akm loads that package's binding from its cache
122
+ instead.
123
+ - The `preinstall` Node.js version check (#1255). Both launchers and the CLI still
124
+ refuse Node.js older than 22 when akm starts, and npm warns from
125
+ `engines.node` at install.
126
+
9
127
  ## [0.10.261014] - 2026-10-27
10
128
 
11
129
  ### Added
package/README.md CHANGED
@@ -40,14 +40,13 @@ prefers Bun for execution; old, unusable, or absent Bun installations fall
40
40
  back to Node.js. Node.js remains required for the npm package. The standalone
41
41
  binaries are runtime-free.
42
42
 
43
- Local semantic search runs its embedding model on ONNX Runtime
44
- (`onnxruntime-node`). A standalone binary carries the library for its platform
45
- and copies it once into akm's cache (18 to 34 MB); a `noexec` temp directory
46
- blocks a standalone binary's semantic search (keyword search still works), so
47
- set `TMPDIR` to a writable, executable one.
48
- Alpine/musl and Intel macOS have no native ONNX Runtime, so semantic search is
49
- unavailable there and keyword search works. See
50
- [Getting Started](https://akm.fwdslsh.dev/docs/guides/getting-started/#standalone-binary-and-semantic-search).
43
+ Local semantic search runs its embedding model on ONNX Runtime and is offered on
44
+ Linux x64 (glibc) and Apple-silicon macOS; every other platform uses
45
+ keyword search or an embedding endpoint. Choosing it in `akm setup`
46
+ (`--embedding local`) downloads the runtime (about 86 MB) and the model (about
47
+ 127 MB) once into akm's cache: installing the package runs no install script and
48
+ downloads nothing native. See
49
+ [Getting Started](https://akm.fwdslsh.dev/docs/guides/getting-started/#local-semantic-search).
51
50
 
52
51
  With a connection and no harness, agent work runs on akm's own runtime, a pinned
53
52
  OpenCode 2 binary. The first time it runs, akm downloads that binary from the npm
package/SECURITY.md CHANGED
@@ -104,6 +104,6 @@ for assets containing secrets or private notes.
104
104
  runtime-free. This is a compatibility limitation, not a security risk.
105
105
  - **Workflows can read any file the akm process can read.** This is not a
106
106
  bug — see "Threat model" above.
107
- - **Installing `akm-cli` runs the preinstall hook.** The hook only validates
108
- the runtime version and exits non-zero when it is unsupported; it does not
109
- phone home or write outside the install directory.
107
+ - **Installing `akm-cli` runs no install script.** Neither `akm-cli` nor any
108
+ package it depends on declares one. The Node.js version is checked when akm
109
+ starts, not when it installs.
package/STABILITY.md CHANGED
@@ -201,7 +201,8 @@ enumeration of the whole `proposal` noun group.
201
201
  - `#fragment` is **input-only** and never stored. On markdown-document items
202
202
  the core resolves it as a section selector — `akm show <ref>#<heading-slug>`
203
203
  returns that one section, no fragment returns the whole item, and an
204
- unmatched fragment lists the available slugs. Elsewhere it is an
204
+ unmatched fragment lists the available slugs. A whole document's `show`
205
+ carries `toc`, every heading with the slug that selects it. Elsewhere it is an
205
206
  adapter-owned selector opaque to the core. See
206
207
  [`docs/architecture/specs/ref.md`](https://github.com/itlackey/akm/blob/main/docs/architecture/specs/ref.md).
207
208
  - **Refs in prose** must be fully qualified (`bundle//conceptId`) or a native
@@ -49,7 +49,9 @@ akm curate "review architecture" --type workflow # Restrict to one asset type
49
49
  ## Show
50
50
 
51
51
  Display an asset by ref from the local index and materialized bundle files only.
52
- On a markdown document `#fragment` selects one section by heading slug.
52
+ On a markdown document `#fragment` selects one section by heading slug. For a
53
+ large document, `akm show <ref> --detail brief` returns its outline (`toc`, no
54
+ body); then `akm show <ref>#<slug>` reads only the sections you need.
53
55
 
54
56
  ```sh
55
57
  akm show scripts/deploy.sh # Show script (returns run command)
@@ -57,9 +59,9 @@ akm show skills/code-review # Show skill (returns full content
57
59
  akm show commands/release # Show command (returns template)
58
60
  akm show agents/architect # Show agent (returns system prompt)
59
61
  akm show workflows/ship-release # Show parsed workflow steps
60
- akm show knowledge/guide # Whole document
62
+ akm show knowledge/guide # Whole document, plus its `toc`
63
+ akm show knowledge/guide --detail brief # Outline only: `toc` (level, text, slug, startLine), no body
61
64
  akm show knowledge/guide#auth # Just the "Auth" section
62
- akm show knowledge/guide#nope # Lists the available fragment slugs
63
65
  akm show knowledge/my-doc # Show materialized local content
64
66
  ```
65
67
 
@@ -69,7 +71,7 @@ akm show knowledge/my-doc # Show materialized local content
69
71
  | skill | `content` (full SKILL.md) |
70
72
  | command | `template`, `description`, `parameters` |
71
73
  | agent | `prompt`, `description`, `modelHint` |
72
- | knowledge | `content` (whole document, or one section via `#fragment`) |
74
+ | knowledge | `content` (whole document, or one section via `#fragment`); `toc` (each heading's `slug`) on a whole document |
73
75
  | workflow | `workflowTitle`, `workflowParameters`, `steps` |
74
76
  | memory | `content` (recalled context) |
75
77
  | env | `keys` (key names only — values and comment text never returned) |
@@ -6,7 +6,7 @@ You have access to a searchable library of scripts, skills, commands, agents, kn
6
6
 
7
7
  For any task, follow this loop:
8
8
  1. `akm curate "<task>"` — find the best matching asset
9
- 2. `akm show <ref>` — read the schema (field names and structure)
9
+ 2. `akm show <ref>` — read the schema (field names and structure). For a large document, `akm show <ref> --detail brief` returns its outline (`toc`, no body); then `akm show <ref>#<slug>` reads only the sections you need.
10
10
  3. Edit the workspace file using schema field names + task-specific values from your README
11
11
  4. `akm feedback <ref> --positive` — record that the asset helped (it raises its ranking and does not trigger a rewrite). Record negative feedback only when the asset's content is wrong or stale, and say what is wrong and what it should say. `akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags it: the next improve run may repair its description, title or `when_to_use` from your reason, but not its text. A note that simply did not fit your task is not negative feedback: record nothing for it. Once you have verified the correct fact, add the exact fix: `--replace "<exact current text>" --with "<corrected text>" --source "<URL, command or file>"`. A note that is outdated can be marked with `--outdated`, or `--superseded-by <ref>` when another note replaces it. A failed akm command (e.g. `akm show` erroring) is not feedback on the asset — don't record it.
12
12
 
@@ -27,6 +27,8 @@ akm search "<query>" --type workflow # Filter to workflow assets
27
27
  akm search "memories/projectA/" # List a subtree (conceptId prefix; trailing slash required)
28
28
  akm search "<query>" --from all # Also search registries
29
29
  akm show <ref> # View asset details
30
+ akm show <ref> --detail brief # Outline only: a document's `toc` (each heading's slug), no body
31
+ akm show <ref>#<slug> # One section of a document
30
32
  akm help <command> # Show a command's options and subcommands
31
33
  akm workflow run <ref> # Start a new run and execute it
32
34
  akm remember "Deployment needs VPN access" # Record a memory in your bundle
@@ -58,7 +60,7 @@ akm search "<query>" --from registry # Search all registries (registry
58
60
  | skill | Instructions to follow (read the full content) |
59
61
  | command | A prompt template with placeholders to fill in |
60
62
  | agent | A system prompt with model and tool hints |
61
- | knowledge | A reference doc (append `#<heading-slug>` to the ref to read one section) |
63
+ | knowledge | A reference doc; `toc` lists its sections and `<ref>#<slug>` reads one |
62
64
  | workflow | Parsed steps plus workflow-specific execution commands |
63
65
  | memory | Recalled context (read the content for background information) |
64
66
  | env | A `.env` file of configuration an app loads together (many vars; sensitive or not — all protected); key names only. Inject with `akm env run <ref> -- <cmd>` (the agent-safe path — values stay on disk). |
@@ -6,11 +6,11 @@
6
6
  // it in the inline config's `plugins` list (src/integrations/harnesses/opencode/runtime/). It runs inside OpenCode, so
7
7
  // it imports no package: only Node's own child_process, to call back into akm.
8
8
  //
9
- // - akm's three read tools, each a run of the akm that started OpenCode, with `--detail agent --format json`. akm
10
- // passes the argv that runs it in `AKM_OPENCODE_CLI`, a JSON array: the executable alone for a compiled akm, the
11
- // runtime and the script for one run as a script (Node and `dist/akm`, say), so no shebang or shell is needed on
12
- // any platform. The run's environment is the call's, so a stamped run's reads (`AKM_EVENT_SOURCE`) are recorded as
13
- // that run's.
9
+ // - akm's three read tools, each a run of the akm that started OpenCode, with `--detail agent --format json`
10
+ // (`--detail brief` when akm_show asks for a document's outline). akm passes the argv that runs it in
11
+ // `AKM_OPENCODE_CLI`, a JSON array: the executable alone for a compiled akm, the runtime and the script for one
12
+ // run as a script (Node and `dist/akm`, say), so no shebang or shell is needed on any platform. The run's
13
+ // environment is the call's, so a stamped run's reads (`AKM_EVENT_SOURCE`) are recorded as that run's.
14
14
  // - The environment of the commands the agent runs: the variables akm set for the OpenCode process (its roots and
15
15
  // its OpenCode settings) are put back to what akm runs in, or removed, so the agent's shell sees the environment a
16
16
  // harness's child would.
@@ -59,8 +59,16 @@ const TOOLS = [
59
59
  name: "akm_show",
60
60
  command: "show",
61
61
  positional: "ref",
62
- description: "Read one akm asset by its ref, as returned by akm_curate or akm_search.",
63
- input: { type: "object", properties: { ref: { type: "string" } }, required: ["ref"] },
62
+ description:
63
+ 'Read one akm asset by its ref, as returned by akm_curate or akm_search. For a large document, call it with detail "brief" for its outline (toc, no body), then with ref#<slug> for only the sections you need.',
64
+ input: {
65
+ type: "object",
66
+ properties: {
67
+ ref: { type: "string" },
68
+ detail: { type: "string", enum: ["brief"], description: "brief: the outline (toc) without the body" },
69
+ },
70
+ required: ["ref"],
71
+ },
64
72
  },
65
73
  ];
66
74
 
@@ -78,7 +86,7 @@ function cliArguments(tool, input) {
78
86
  ...(type && tool.positional !== "ref" ? ["--type", type] : []),
79
87
  ...(limit && tool.positional !== "ref" ? ["--limit", limit] : []),
80
88
  "--detail",
81
- "agent",
89
+ tool.positional === "ref" && input?.detail === "brief" ? "brief" : "agent",
82
90
  "--format",
83
91
  "json",
84
92
  ];
@@ -9,7 +9,9 @@ Ground rules for this unit:
9
9
 
10
10
  1. Pull knowledge on demand instead of guessing: `akm search '<query>'` to find
11
11
  relevant assets, `akm show <ref>` to read one, `akm curate '<query>'` to let
12
- akm select the best match. Only pull what this unit actually needs.
12
+ akm select the best match. Only pull what this unit actually needs: for a
13
+ large document, `akm show <ref> --detail brief` returns its outline (`toc`,
14
+ no body), then `akm show <ref>#<slug>` reads one section.
13
15
  2. Environment values and secrets are provided through your process
14
16
  environment when the workflow declares them. Never print secret values to
15
17
  stdout or embed them in your answer. If you need an env file path, use
package/dist/cli.js CHANGED
@@ -178,7 +178,7 @@ const setupCommand = defineCommand({
178
178
  },
179
179
  embedding: {
180
180
  type: "string",
181
- description: "Embeddings: a model the connection serves, local (the built-in embedder), or off",
181
+ description: "Embeddings: a model the connection serves, local (the built-in embedder, on the platforms that have it; its runtime and model are downloaded now), or off",
182
182
  },
183
183
  "embedding-endpoint": {
184
184
  type: "string",
@@ -218,7 +218,7 @@ const setupCommand = defineCommand({
218
218
  // declaring `no-probe` is what puts the flag in `--help` and keeps the unknown-flag check from refusing it.
219
219
  "no-probe": {
220
220
  type: "boolean",
221
- description: "Offline run: the endpoint is not contacted and its models are not listed. A connection added this way has no model until one is named with `akm models set`; the picks it already has are kept.",
221
+ description: "Offline run: nothing is contacted or downloaded. The endpoint's models are not listed, so a connection added this way has no model until one is named with `akm models set` (the picks it already has are kept), and the local embedder's runtime and model are fetched by the first `akm index`.",
222
222
  },
223
223
  },
224
224
  async run({ args }) {
@@ -35,7 +35,7 @@
35
35
  * local marketplace clone is not proof of what is newest upstream — the
36
36
  * incident above involved a clone that hadn't seen the fix's tag at all — so
37
37
  * the only way to ever detect drift is to ask the remote what tags exist.
38
- * That query is a `git ls-remote --tags` (lists refs; fetches nothing,
38
+ * That query is a `git ls-remote --symref` (lists refs; fetches nothing,
39
39
  * writes nothing) with a short timeout, and any failure (offline, no
40
40
  * remote, timeout) degrades to "installed version reported, no staleness
41
41
  * claim" rather than a false positive or a hang. Like `cli-version`, this
@@ -54,7 +54,8 @@ import os from "node:os";
54
54
  import path from "node:path";
55
55
  import { OPENCODE_V1, OPENCODE_V2, readPackageVersion, } from "../../integrations/harnesses/opencode/plugin-layout.js";
56
56
  import { opencodeCacheDir } from "../../integrations/harnesses/opencode/roots.js";
57
- import { isExactSemver, isSemverRange, maxSatisfying, satisfiesRange } from "../../registry/semver.js";
57
+ import { highestVersion, isExactSemver, isPrerelease, isSemverRange, satisfiesRange } from "../../registry/semver.js";
58
+ import { openCodeConfigSpec } from "../sources/plugin-upgrade.js";
58
59
  /**
59
60
  * Root directory holding Claude Code's plugin cache + marketplace clones.
60
61
  * Resolved per call (not memoized) so `AKM_CLAUDE_PLUGINS_DIR` can be set
@@ -78,12 +79,26 @@ function defaultOpencodeCacheRoot() {
78
79
  }
79
80
  const OPENCODE_PLUGIN_LOCATIONS = [
80
81
  {
81
- // OpenCode 1: <cache>/packages/akm-opencode/node_modules/akm-cli (the folder read since this check began).
82
+ // OpenCode 1: <cache>/packages/<spec>/node_modules/akm-cli, the one spec the global config names (#1265).
82
83
  flavor: OPENCODE_V1,
83
84
  advisory: "opencode-plugin-version",
84
85
  label: "OpenCode's",
85
86
  remedy: "update the OpenCode plugin",
86
- folders: (cacheRoot) => [{ folder: path.join(cacheRoot, OPENCODE_V1.cacheFolder(OPENCODE_V1.pkg)) }],
87
+ folders: (cacheRoot) => {
88
+ // OpenCode caches each spec in its own folder, and a bare name is its `@latest`, as `akm upgrade` reads it. With
89
+ // no config entry, or no folder for the spec, the bare `packages/akm-opencode` is read, as this check always has.
90
+ const { pkg } = OPENCODE_V1;
91
+ const bare = { folder: path.join(cacheRoot, OPENCODE_V1.cacheFolder(pkg)) };
92
+ const named = openCodeConfigSpec(OPENCODE_V1)?.spec;
93
+ if (named === undefined)
94
+ return [bare];
95
+ const spec = named === pkg ? `${pkg}@latest` : named;
96
+ const folder = path.join(cacheRoot, OPENCODE_V1.cacheFolder(spec));
97
+ if (!fs.existsSync(folder))
98
+ return [bare];
99
+ const version = spec.slice(pkg.length + 1);
100
+ return [{ spec, folder, ...(isExactSemver(version) ? { pinned: version } : {}) }];
101
+ },
87
102
  },
88
103
  {
89
104
  // OpenCode 2: <cache>/npm/akm-opencode-v2@<spec>/<epoch-ms>/node_modules/akm-cli, newest build per spec.
@@ -110,11 +125,11 @@ const OPENCODE_PLUGIN_LOCATIONS = [
110
125
  */
111
126
  function findBundledAkm(plugin, cacheRoot) {
112
127
  const found = [];
113
- for (const { spec, folder } of plugin.folders(cacheRoot)) {
128
+ for (const { spec, pinned, folder } of plugin.folders(cacheRoot)) {
114
129
  const nodeModules = plugin.flavor.nodeModulesDir(folder);
115
130
  const version = nodeModules && readPackageVersion(path.join(nodeModules, "akm-cli", "package.json"));
116
131
  if (version !== undefined)
117
- found.push(spec ? { spec, version } : { version });
132
+ found.push({ ...(spec ? { spec } : {}), ...(pinned ? { pinned } : {}), version });
118
133
  }
119
134
  return found;
120
135
  }
@@ -122,6 +137,17 @@ function buildOpencodeBundledAdvisory(plugin, bundled, cliVersion) {
122
137
  const bundledVersion = bundled.version;
123
138
  const stale = bundledVersion !== cliVersion;
124
139
  const where = bundled.spec ? ` (${bundled.spec})` : "";
140
+ if (bundled.pinned) {
141
+ // The config chose this build: there is nothing to update it to, so no staleness verdict.
142
+ return {
143
+ name: plugin.advisory,
144
+ kind: "deterministic",
145
+ status: "pass",
146
+ confidence: "high",
147
+ message: `${plugin.label} plugin is pinned to v${bundled.pinned}${where}; its bundled akm-cli is v${bundledVersion}, and a pinned plugin is not compared with the running CLI v${cliVersion}.`,
148
+ evidence: { bundledVersion, cliVersion, spec: bundled.spec, pinnedVersion: bundled.pinned },
149
+ };
150
+ }
125
151
  return {
126
152
  name: plugin.advisory,
127
153
  kind: "deterministic",
@@ -161,7 +187,7 @@ function detectInstalledPlugins(pluginsRoot) {
161
187
  }
162
188
  if (versions.length === 0)
163
189
  continue;
164
- const latest = maxSatisfying(versions, "*") ?? versions.sort().at(-1);
190
+ const latest = highestVersion(versions);
165
191
  if (!latest)
166
192
  continue;
167
193
  const versionDir = path.join(pluginDir, latest);
@@ -204,16 +230,31 @@ function readVersionRange(pluginDir) {
204
230
  }
205
231
  const LS_REMOTE_TIMEOUT_MS = 5_000;
206
232
  /**
207
- * `git ls-remote --tags origin` against the marketplace clone's configured
208
- * remote — lists refs only, fetches no objects, writes no local refs.
209
- * Returns `undefined` (never throws) when the directory is not a git
210
- * checkout, has no `origin` remote, or the command fails/times out (e.g.
211
- * offline) — all "cannot determine availability", not "up to date".
233
+ * `git ls-remote --symref origin` against the marketplace clone's configured remote (lists refs only, fetches no
234
+ * objects, writes no local refs), read for the channel the clone follows:
235
+ *
236
+ * - **The remote's default branch** (stable): every stable release tag; the newest is the highest of them.
237
+ * - **Any other branch** (a channel such as `next`): the release tags on that branch's head. A channel branch is
238
+ * reset to each build it publishes, so its head IS the newest build of the channel, whatever semver order says
239
+ * about other tags (retired build schemes can sort above current builds).
240
+ * - **No branch** (a marketplace added at a tag ref, which Claude Code clones detached): the release tag the clone
241
+ * is pinned to. A pinned marketplace never offers anything newer, so `claude plugin update` could not act on a
242
+ * newer tag elsewhere. A detached clone at an untagged commit reads like the default branch.
243
+ *
244
+ * Returns `undefined` (never throws) when the directory is not a git checkout, has no `origin` remote, or the
245
+ * command fails/times out (e.g. offline): all "cannot determine availability", not "up to date". A channel head
246
+ * with no release tag returns `[]`, which reads the same way.
212
247
  */
213
248
  const realListRemoteTags = (marketplaceDir) => {
214
249
  let result;
250
+ let branch;
215
251
  try {
216
- result = spawnSync("git", ["-C", marketplaceDir, "ls-remote", "--tags", "origin"], {
252
+ branch =
253
+ spawnSync("git", ["-C", marketplaceDir, "branch", "--show-current"], {
254
+ encoding: "utf8",
255
+ timeout: LS_REMOTE_TIMEOUT_MS,
256
+ }).stdout?.trim() ?? "";
257
+ result = spawnSync("git", ["-C", marketplaceDir, "ls-remote", "--symref", "origin"], {
217
258
  encoding: "utf8",
218
259
  timeout: LS_REMOTE_TIMEOUT_MS,
219
260
  });
@@ -223,16 +264,39 @@ const realListRemoteTags = (marketplaceDir) => {
223
264
  }
224
265
  if (result.status !== 0 || !result.stdout)
225
266
  return undefined;
226
- const tags = result.stdout
267
+ const refs = result.stdout
227
268
  .split("\n")
228
269
  .map((line) => line.trim())
229
270
  .filter(Boolean)
230
- .map((line) => line.split("\t")[1])
231
- .filter((ref) => typeof ref === "string" && ref.startsWith("refs/tags/"))
232
- .map((ref) => ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, ""))
233
- .map((tag) => tag.replace(/^v/, ""))
234
- .filter(isExactSemver);
235
- return [...new Set(tags)];
271
+ .map((line) => line.split("\t"));
272
+ const defaultBranch = refs
273
+ .find(([value, name]) => name === "HEAD" && value?.startsWith("ref: refs/heads/"))?.[0]
274
+ ?.replace(/^ref: refs\/heads\//, "");
275
+ const tags = refs
276
+ .filter(([, name]) => typeof name === "string" && name.startsWith("refs/tags/"))
277
+ .map(([sha, name]) => ({
278
+ sha: sha,
279
+ version: name
280
+ .replace(/^refs\/tags\//, "")
281
+ .replace(/\^\{\}$/, "")
282
+ .replace(/^v/, ""),
283
+ }))
284
+ .filter((tag) => isExactSemver(tag.version));
285
+ const tagsAt = (sha) => [
286
+ ...new Set(tags.filter((tag) => tag.sha === sha).map((tag) => tag.version)),
287
+ ];
288
+ const onChannelBranch = branch !== "" && defaultBranch !== undefined && branch !== defaultBranch;
289
+ if (onChannelBranch)
290
+ return tagsAt(refs.find(([, name]) => name === `refs/heads/${branch}`)?.[0]);
291
+ if (branch === "") {
292
+ const pinned = tagsAt(spawnSync("git", ["-C", marketplaceDir, "rev-parse", "HEAD"], {
293
+ encoding: "utf8",
294
+ timeout: LS_REMOTE_TIMEOUT_MS,
295
+ }).stdout?.trim());
296
+ if (pinned.length > 0)
297
+ return pinned;
298
+ }
299
+ return [...new Set(tags.map((tag) => tag.version).filter((version) => !isPrerelease(version)))];
236
300
  };
237
301
  /**
238
302
  * Build one `plugin-version` advisory per detected `akm` harness plugin,
@@ -263,14 +327,14 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
263
327
  let availableVersion;
264
328
  try {
265
329
  const tags = probe && fs.existsSync(marketplaceDir) ? listRemoteTags(marketplaceDir) : undefined;
266
- availableVersion = tags && tags.length > 0 ? maxSatisfying(tags, "*") : undefined;
330
+ availableVersion = tags && tags.length > 0 ? highestVersion(tags) : undefined;
267
331
  }
268
332
  catch {
269
333
  availableVersion = undefined;
270
334
  }
271
335
  const stale = availableVersion !== undefined &&
272
336
  plugin.version !== availableVersion &&
273
- maxSatisfying([plugin.version, availableVersion], "*") === availableVersion;
337
+ highestVersion([plugin.version, availableVersion]) === availableVersion;
274
338
  // Point 3: does the plugin's own declared range admit the running CLI?
275
339
  const versionRange = readVersionRange(plugin.pluginDir);
276
340
  const rangeKnown = versionRange !== undefined && isSemverRange(versionRange);
@@ -62,10 +62,10 @@ export function semanticSearchCheck(state) {
62
62
  const asOf = builtAt ? ` at the last index (${builtAt})` : " at the last index";
63
63
  switch (record.status) {
64
64
  case "unavailable":
65
- // No native library for this platform is not something to fix: keyword search is the healthy state there.
65
+ // No local embeddings on this platform is not something to fix: keyword search or a remote endpoint is the default there.
66
66
  return record.unavailable === "platform"
67
67
  ? result("warn", `Semantic search is unavailable on this platform: ${record.reason} ` +
68
- 'Set semanticSearchMode to "off", or embedding.endpoint to a remote embedding service.', evidence)
68
+ "For keyword search run `akm setup --embedding off`; for a remote endpoint, `akm setup --embedding <model> --embedding-endpoint <url>`.", evidence)
69
69
  : result("fail", `Semantic search was unavailable on this machine${asOf}: ${record.reason}`, evidence);
70
70
  case "blocked":
71
71
  return result("fail", `Semantic search was blocked${asOf}: ${record.reason}`, evidence);
@@ -177,7 +177,7 @@ function rejectExtraShowPositionals(positionals, ref) {
177
177
  if (extra.length === 0)
178
178
  return;
179
179
  throw new UsageError(`akm show takes a single ref, but got ${extra.map((token) => `"${token}"`).join(" ")} after "${ref}". ` +
180
- `Use \`akm show ${ref}#<heading-slug>\` to read one section, or \`akm show ${ref}\` for the whole item.`, "INVALID_FLAG_VALUE", "An unmatched #fragment lists the available slugs.");
180
+ `Use \`akm show ${ref}#<heading-slug>\` to read one section, or \`akm show ${ref}\` for the whole item.`, "INVALID_FLAG_VALUE", `Run \`akm show ${ref} --detail brief\` for the document's outline (\`toc\`, no body).`);
181
181
  }
182
182
  export const showCommand = defineJsonCommand({
183
183
  meta: {
@@ -187,7 +187,7 @@ export const showCommand = defineJsonCommand({
187
187
  args: {
188
188
  ref: {
189
189
  type: "positional",
190
- description: "Asset ref ([bundle//]conceptId[#fragment]). On a markdown document `#fragment` selects one section by heading slug, and an unmatched fragment lists the available slugs. Example: `akm show knowledge/guide.md#auth`.",
190
+ description: "Asset ref ([bundle//]conceptId[#fragment]). On a markdown document `#fragment` selects one section by heading slug; `--detail brief` returns the outline (`toc`, each heading's slug) without the body. Example: `akm show knowledge/guide.md#auth`.",
191
191
  required: true,
192
192
  },
193
193
  filter: {
@@ -20,7 +20,7 @@ import { recognizeMatch } from "../../core/adapter/recognize-match.js";
20
20
  import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
21
21
  import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
22
22
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
23
- import { extractSection, markdownFragmentSlugs } from "../../core/asset/markdown.js";
23
+ import { extractSection, markdownFragmentSlugs, markdownToc } from "../../core/asset/markdown.js";
24
24
  import { buildMarkdownLeadContext, fragmentForSelector, MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS, } from "../../core/asset/markdown-fragments.js";
25
25
  import { displayRef, displayRefForConceptId, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
26
26
  import { META_DIR, parseMetaRef, readMetaFile } from "../../core/asset/stash-meta.js";
@@ -297,8 +297,10 @@ export async function showLocal(input) {
297
297
  const indexedFragmentContent = opaqueFragmentSelector ? indexedFragment?.content : undefined;
298
298
  const indexedRenderer = rendererForIndexedEntry(indexedEntry, fileCtx);
299
299
  let response;
300
+ let markdown = false;
300
301
  try {
301
302
  if (indexedRenderer === null) {
303
+ markdown = path.extname(assetPath).toLowerCase() === ".md";
302
304
  response = buildIndexedProjectionResponse(indexedEntry, assetPath, parsed.fragment, indexedFragmentContent);
303
305
  }
304
306
  else {
@@ -315,8 +317,9 @@ export async function showLocal(input) {
315
317
  const renderDefaultBundle = config.defaultBundle ?? (source?.isDefault === true ? renderBundle : undefined);
316
318
  const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, renderBundle, renderDefaultBundle);
317
319
  response = renderer.buildShowResponse(renderCtx);
320
+ markdown = match.renderer.endsWith("-md");
318
321
  if (parsed.fragment !== undefined) {
319
- if (!match.renderer.endsWith("-md")) {
322
+ if (!markdown) {
320
323
  warn(`Fragment "#${parsed.fragment}" was ignored: ${makeBundleRef(parsed.bundle, parsed.conceptId)} is not a Markdown document, so heading fragments do not apply. Showing the whole asset.`);
321
324
  }
322
325
  else {
@@ -324,6 +327,13 @@ export async function showLocal(input) {
324
327
  }
325
328
  }
326
329
  }
330
+ // The outline is read from the bytes `#<slug>` resolves against, so every
331
+ // listed slug selects its section even when the index is stale.
332
+ if (markdown && parsed.fragment === undefined) {
333
+ const toc = markdownToc(fileCtx.content());
334
+ if (toc.length > 0)
335
+ response.toc = toc;
336
+ }
327
337
  }
328
338
  catch (error) {
329
339
  throwIndexedPathNotFound(error, input.ref);
@@ -598,6 +608,7 @@ function applyMarkdownResponsePayload(response, content) {
598
608
  else
599
609
  response.content = content;
600
610
  }
611
+ const SHOW_OUTLINE_HINT = "Run `akm show <ref> --detail brief` for the document's outline (`toc`, no body), then `akm show <ref>#<slug>` for one section.";
601
612
  function requireMarkdownSection(content, fragment, name) {
602
613
  const section = extractSection(content, fragment);
603
614
  if (section)
@@ -609,7 +620,7 @@ function requireMarkdownSection(content, fragment, name) {
609
620
  }
610
621
  const available = markdownFragmentSlugs(content);
611
622
  throw new NotFoundError(`Fragment "#${fragment}" not found in ${name}.` +
612
- (available.length > 0 ? ` Available fragments: ${available.map((slug) => `#${slug}`).join(", ")}.` : ""));
623
+ (available.length > 0 ? ` Available fragments: ${available.map((slug) => `#${slug}`).join(", ")}.` : ""), "ASSET_NOT_FOUND", SHOW_OUTLINE_HINT);
613
624
  }
614
625
  /**
615
626
  * Build a reduced brief response from a full ShowResponse.
@@ -625,6 +636,7 @@ function buildBriefResponse(full, assetPath) {
625
636
  ...(summary.ref ? { ref: summary.ref } : {}),
626
637
  ...(summary.description ? { description: summary.description } : {}),
627
638
  ...(summary.action ? { action: summary.action } : {}),
639
+ ...(full.toc ? { toc: full.toc } : {}),
628
640
  ...(summary.run ? { run: summary.run } : {}),
629
641
  ...(summary.origin !== undefined ? { origin: summary.origin } : {}),
630
642
  ...(full.editable !== undefined ? { editable: full.editable } : {}),
@@ -16,6 +16,15 @@ export function markdownHeadingSlug(heading) {
16
16
  export function markdownFragmentSlugs(content) {
17
17
  return uniqueHeadingSlugs(parseMarkdownToc(content).headings).filter(Boolean);
18
18
  }
19
+ /**
20
+ * The headings `extractSection` can select, with the slug it resolves each by.
21
+ * A heading with no sluggable text has no selector, so it is left out.
22
+ */
23
+ export function markdownToc(content) {
24
+ const headings = parseMarkdownToc(content).headings;
25
+ const slugs = uniqueHeadingSlugs(headings);
26
+ return headings.flatMap((heading, index) => slugs[index] ? [{ level: heading.level, text: heading.text, slug: slugs[index], startLine: heading.line }] : []);
27
+ }
19
28
  /** One slug per heading, in order; a heading with no sluggable text gets "". */
20
29
  export function uniqueHeadingSlugs(headings) {
21
30
  const used = new Set();