akm-cli 0.10.261015-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 (33) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/STABILITY.md +2 -1
  3. package/dist/assets/hints/cli-hints-full.md +6 -4
  4. package/dist/assets/hints/cli-hints-short.md +4 -2
  5. package/dist/assets/opencode/akm-plugin.js +16 -8
  6. package/dist/assets/prompts/workflow-unit-preamble.md +3 -1
  7. package/dist/commands/health/plugin-staleness.js +86 -22
  8. package/dist/commands/read/search-cli.js +2 -2
  9. package/dist/commands/read/show.js +15 -3
  10. package/dist/core/asset/markdown.js +9 -0
  11. package/dist/core/config/config-walker.js +35 -32
  12. package/dist/core/config/schema/connections.js +6 -3
  13. package/dist/core/config/schema/harnesses.js +8 -1
  14. package/dist/core/config/schema/primitives.js +12 -1
  15. package/dist/core/config/schema/sources-bundles.js +2 -2
  16. package/dist/core/npm-package.js +48 -13
  17. package/dist/output/renderers.js +2 -2
  18. package/dist/output/shapes/helpers.js +2 -0
  19. package/dist/output/text/show-format.js +7 -0
  20. package/dist/registry/semver.js +12 -0
  21. package/dist/scripts/akm-migrate-node.js +21629 -5738
  22. package/dist/scripts/akm-migrate.js +21632 -5741
  23. package/dist/tasks/backends/cron.js +19 -0
  24. package/docs/architecture/internals/health-advisories.md +1 -1
  25. package/docs/architecture/specs/ref.md +4 -3
  26. package/docs/architecture/testing/testing-workflow.md +7 -8
  27. package/docs/guides/discover-and-load.md +12 -4
  28. package/docs/guides/getting-started.md +3 -2
  29. package/docs/guides/use-with-any-agent.md +4 -1
  30. package/docs/maintainers/release-checklist.md +15 -8
  31. package/docs/reference/cli.md +22 -3
  32. package/package.json +4 -4
  33. package/schemas/akm-config.json +535 -302
package/CHANGELOG.md CHANGED
@@ -6,6 +6,79 @@ 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
+
9
82
  ## [0.10.261015] - 2026-10-27
10
83
 
11
84
  ### Changed
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
@@ -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);
@@ -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();
@@ -27,7 +27,7 @@ import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../
27
27
  import { warnOnce } from "../warn.js";
28
28
  import { AkmConfigBaseSchema, listTopLevelConfigKeys } from "./config-schema.js";
29
29
  import { deepMergeConfig } from "./deep-merge.js";
30
- import { isEnvReference } from "./schema/primitives.js";
30
+ import { isEnvReference, OpaqueFieldSchema } from "./schema/primitives.js";
31
31
  /**
32
32
  * Parse a dotted path into segments. Empty segments are rejected. Bracket
33
33
  * notation (e.g. `sources[0]`) is NOT supported — arrays are set as JSON.
@@ -46,33 +46,39 @@ function parsePath(dotted) {
46
46
  function unwrap(schema) {
47
47
  let current = schema;
48
48
  while (true) {
49
- if (current instanceof z.ZodOptional) {
50
- current = current._def.innerType;
51
- }
52
- else if (current instanceof z.ZodDefault) {
53
- current = current._def.innerType;
54
- }
55
- else if (current instanceof z.ZodNullable) {
56
- current = current._def.innerType;
57
- }
58
- else if (current instanceof z.ZodCatch) {
49
+ if (current instanceof z.ZodOptional ||
50
+ current instanceof z.ZodDefault ||
51
+ current instanceof z.ZodNullable ||
59
52
  // `.catch(...)` wraps an inner schema with a fallback value.
60
- current = current._def.innerType;
53
+ current instanceof z.ZodCatch ||
54
+ current instanceof z.ZodReadonly) {
55
+ current = current.def.innerType;
61
56
  }
62
- else if (current instanceof z.ZodEffects) {
63
- // `.refine()` / `.superRefine()` / `.transform()` / `.preprocess()` —
64
- // descend into the inner schema (`schema` for refine/transform, `out`
65
- // for preprocess — both are exposed at `_def.schema`).
66
- current = current._def.schema;
67
- }
68
- else if (current instanceof z.ZodReadonly) {
69
- current = current._def.innerType;
57
+ else if (current instanceof z.ZodPipe) {
58
+ // `.transform()` pipes the schema into a transform and `z.preprocess()` pipes a transform into it:
59
+ // descend into the side that is a schema. (`.refine()` and `.superRefine()` wrap nothing.)
60
+ const { in: input, out } = current.def;
61
+ current = (input instanceof z.ZodTransform ? out : input);
70
62
  }
71
63
  else {
72
64
  return current;
73
65
  }
74
66
  }
75
67
  }
68
+ /**
69
+ * The schema of the keys an object takes without naming them (`index.<passName>`,
70
+ * a connection's request fields), or `undefined` when its extra keys are not
71
+ * ones akm defines. `.passthrough()` gives an object an `unknown` catchall, so
72
+ * only `OpaqueFieldSchema` itself says "any key, contents left to the endpoint".
73
+ */
74
+ function namedCatchall(object) {
75
+ const catchall = object.def.catchall;
76
+ if (catchall === undefined || catchall instanceof z.ZodNever)
77
+ return undefined;
78
+ if (catchall instanceof z.ZodUnknown && catchall !== OpaqueFieldSchema)
79
+ return undefined;
80
+ return catchall;
81
+ }
76
82
  /** True for a schema that accepts any value (`z.unknown()`): its contents are not keys akm defines. */
77
83
  export function isOpaqueSchema(schema) {
78
84
  return unwrap(schema) instanceof z.ZodUnknown;
@@ -91,13 +97,10 @@ export function resolveSchemaAt(path, config, raw) {
91
97
  const next = schema.shape[segment];
92
98
  if (!next) {
93
99
  // Catchall (e.g. index.<passName>) — descend into the catchall schema.
94
- const catchall = schema._def.catchall;
95
- if (catchall && !(catchall instanceof z.ZodNever)) {
96
- schema = catchall;
97
- }
98
- else {
100
+ const catchall = namedCatchall(schema);
101
+ if (!catchall)
99
102
  return undefined;
100
- }
103
+ schema = catchall;
101
104
  }
102
105
  else {
103
106
  schema = next;
@@ -106,13 +109,13 @@ export function resolveSchemaAt(path, config, raw) {
106
109
  else if (schema instanceof z.ZodRecord) {
107
110
  // Named records (connections, harnesses, improve.strategies, sources, etc.)
108
111
  // accept any string key — descend into the value schema.
109
- schema = schema._def.valueType;
112
+ schema = schema.def.valueType;
110
113
  }
111
114
  else if (schema instanceof z.ZodUnion) {
112
- const option = selectUnionObjectOption(schema._def.options, segment, existing, index === path.length - 1 ? raw : undefined);
115
+ const option = selectUnionObjectOption(schema.options, segment, existing, index === path.length - 1 ? raw : undefined);
113
116
  if (!option)
114
117
  return undefined;
115
- schema = (option.shape[segment] ?? option._def.catchall);
118
+ schema = (option.shape[segment] ?? namedCatchall(option));
116
119
  }
117
120
  else {
118
121
  // Cannot descend into a non-object schema.
@@ -145,7 +148,7 @@ function selectUnionObjectOption(candidates, segment, existing, raw) {
145
148
  }
146
149
  return (options.find((option) => segment in option.shape) ??
147
150
  // A model entry such as `{ model, reasoning_effort }` holds request fields the object does not name.
148
- options.find((option) => !(option._def.catchall instanceof z.ZodNever)));
151
+ options.find((option) => namedCatchall(option) !== undefined));
149
152
  }
150
153
  /**
151
154
  * Get the value at the dotted path from a config-shaped object. Returns
@@ -350,7 +353,7 @@ function coerceForSchema(schema, raw, key) {
350
353
  return raw;
351
354
  }
352
355
  if (target instanceof z.ZodLiteral) {
353
- return typeof target._def.value === "string" ? raw : target._def.value;
356
+ return typeof target.value === "string" ? raw : target.value;
354
357
  }
355
358
  if (target instanceof z.ZodArray || target instanceof z.ZodObject || target instanceof z.ZodRecord) {
356
359
  if (raw === "" || raw === "null")
@@ -366,7 +369,7 @@ function coerceForSchema(schema, raw, key) {
366
369
  if (target instanceof z.ZodUnion) {
367
370
  // Try each option in order. Use the first that the schema accepts after
368
371
  // coercion. Falls through to JSON parsing if all fail.
369
- const options = target._def.options;
372
+ const options = target.options;
370
373
  let lastErr;
371
374
  for (const opt of options) {
372
375
  try {