akm-cli 0.10.261015-beta → 0.10.261017-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 (124) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +8 -10
  3. package/SECURITY.md +7 -99
  4. package/STABILITY.md +58 -98
  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/assets/stash-skeleton/facts/conventions/backlinks.md +2 -2
  10. package/dist/assets/stash-skeleton/facts/conventions/organization.md +11 -17
  11. package/dist/cli.js +32 -41
  12. package/dist/commands/feedback-cli.js +5 -4
  13. package/dist/commands/health/plugin-staleness.js +86 -22
  14. package/dist/commands/health.js +15 -16
  15. package/dist/commands/proposal/proposal-cli.js +1 -1
  16. package/dist/commands/read/registry-search.js +4 -1
  17. package/dist/commands/read/search-cli.js +2 -2
  18. package/dist/commands/read/search.js +6 -6
  19. package/dist/commands/read/show.js +15 -3
  20. package/dist/commands/sources/self-update.js +9 -5
  21. package/dist/commands/tasks/tasks.js +12 -13
  22. package/dist/core/adapter/adapters/agent-skills-adapter.js +7 -7
  23. package/dist/core/adapter/adapters/akm-task-adapter.js +1 -1
  24. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  25. package/dist/core/adapter/adapters/claude-adapter.js +1 -1
  26. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
  27. package/dist/core/adapter/adapters/generic-files-adapter.js +1 -1
  28. package/dist/core/adapter/adapters/llm-wiki-adapter.js +1 -1
  29. package/dist/core/adapter/adapters/opencode-adapter.js +1 -1
  30. package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -1
  31. package/dist/core/asset/markdown.js +9 -0
  32. package/dist/core/bundle-rename.js +17 -7
  33. package/dist/core/config/config-walker.js +35 -32
  34. package/dist/core/config/schema/connections.js +6 -3
  35. package/dist/core/config/schema/harnesses.js +8 -1
  36. package/dist/core/config/schema/primitives.js +12 -1
  37. package/dist/core/config/schema/sources-bundles.js +2 -2
  38. package/dist/core/npm-package.js +48 -13
  39. package/dist/indexer/bundle-identity-guard.js +6 -6
  40. package/dist/indexer/index-written-assets.js +3 -3
  41. package/dist/indexer/indexer.js +12 -8
  42. package/dist/indexer/materialize-embeddings.js +12 -2
  43. package/dist/indexer/passes/metadata.js +103 -46
  44. package/dist/indexer/search/ranking.js +7 -3
  45. package/dist/indexer/search/search-fields.js +1 -1
  46. package/dist/indexer/usage/usage-events.js +30 -8
  47. package/dist/indexer/walk/walker.js +1 -1
  48. package/dist/output/renderers.js +2 -2
  49. package/dist/output/shapes/helpers.js +5 -3
  50. package/dist/output/text/command-format.js +5 -6
  51. package/dist/output/text/show-format.js +7 -0
  52. package/dist/registry/semver.js +12 -0
  53. package/dist/scripts/akm-migrate-node.js +21653 -5734
  54. package/dist/scripts/akm-migrate.js +21661 -5742
  55. package/dist/sources/providers/git-provider.js +2 -4
  56. package/dist/sources/providers/provider-utils.js +8 -13
  57. package/dist/sources/snapshot-fetchers/website-ingest.js +6 -7
  58. package/dist/storage/repositories/events-repository.js +15 -0
  59. package/dist/storage/repositories/improve-ledger-repository.js +13 -0
  60. package/dist/storage/repositories/index-entries-repository.js +43 -5
  61. package/dist/storage/repositories/outcome-repository.js +12 -0
  62. package/dist/storage/repositories/proposals-repository.js +5 -5
  63. package/dist/storage/repositories/salience-repository.js +13 -0
  64. package/dist/storage/repositories/task-history-repository.js +8 -7
  65. package/dist/storage/repositories/workflow-runs-repository.js +10 -8
  66. package/dist/tasks/backends/cron.js +19 -0
  67. package/docs/README.md +24 -60
  68. package/docs/agents/README.md +3 -8
  69. package/docs/architecture/README.md +18 -20
  70. package/docs/architecture/adapters.md +40 -43
  71. package/docs/architecture/architecture.md +171 -151
  72. package/docs/architecture/improvement.md +95 -91
  73. package/docs/architecture/internals/classification.md +101 -119
  74. package/docs/architecture/internals/fresh-host-rebuild-runbook.md +33 -35
  75. package/docs/architecture/internals/health-advisories.md +59 -46
  76. package/docs/architecture/internals/improve-workflow.md +166 -693
  77. package/docs/architecture/internals/indexing.md +65 -465
  78. package/docs/architecture/internals/storage-locations.md +301 -848
  79. package/docs/architecture/persisted-data-compat.md +6 -6
  80. package/docs/architecture/specs/okf-support.md +67 -95
  81. package/docs/architecture/specs/ref.md +67 -124
  82. package/docs/architecture/testing/testing-workflow.md +114 -175
  83. package/docs/architecture/workflow-engine.md +34 -33
  84. package/docs/guides/README.md +5 -5
  85. package/docs/guides/author-bundles.md +72 -98
  86. package/docs/guides/author-workflows.md +11 -18
  87. package/docs/guides/bundles.md +91 -123
  88. package/docs/guides/capture-knowledge.md +10 -9
  89. package/docs/guides/concepts.md +45 -30
  90. package/docs/guides/discover-and-load.md +51 -36
  91. package/docs/guides/getting-started.md +27 -26
  92. package/docs/guides/improve-the-library.md +98 -80
  93. package/docs/guides/recipes/headless-install.md +4 -4
  94. package/docs/guides/recipes/website-source.md +9 -5
  95. package/docs/guides/run-workflows.md +10 -11
  96. package/docs/guides/scheduling.md +11 -17
  97. package/docs/guides/use-with-any-agent.md +15 -11
  98. package/docs/guides/wikis.md +6 -9
  99. package/docs/integration/bundling-akm.md +21 -26
  100. package/docs/maintainers/README.md +2 -2
  101. package/docs/maintainers/local-development.md +3 -16
  102. package/docs/maintainers/release-checklist.md +15 -8
  103. package/docs/migration/README.md +2 -1
  104. package/docs/migration/release-notes/0.10.0.md +167 -22
  105. package/docs/migration/release-notes/README.md +2 -2
  106. package/docs/migration/v0.9-to-v0.10.md +47 -7
  107. package/docs/product-surface.md +10 -8
  108. package/docs/reference/README.md +26 -15
  109. package/docs/reference/asset-types.md +11 -12
  110. package/docs/reference/bundle-types.md +6 -5
  111. package/docs/reference/cli.md +74 -31
  112. package/docs/reference/configuration.md +46 -60
  113. package/docs/reference/data-and-telemetry.md +204 -421
  114. package/docs/reference/env-and-secrets.md +80 -83
  115. package/docs/reference/memory.md +1 -1
  116. package/docs/reference/refs.md +23 -36
  117. package/docs/reference/registry.md +138 -348
  118. package/docs/reference/supported-formats.md +8 -8
  119. package/docs/reference/tasks.md +13 -19
  120. package/docs/reference/website-sources.md +23 -15
  121. package/docs/reference/workflow-schema.md +28 -47
  122. package/docs/reference/workflows.md +33 -37
  123. package/package.json +4 -4
  124. package/schemas/akm-config.json +535 -302
package/dist/cli.js CHANGED
@@ -112,9 +112,6 @@ function applyEarlyStderrFlags(argv) {
112
112
  setVerbose(true);
113
113
  }
114
114
  }
115
- // resolveHelpMigrateVersionArg moved to ./cli/invocation (chunk-9 WI-9.9
116
- // argv-normalization fold — it re-scanned process.argv, same as
117
- // findCittyTopLevelCommand and parseAllFlagValues below).
118
115
  /**
119
116
  * Stderr-only human-friendly hint after a non-interactive `setup` invocation.
120
117
  * Default --format is `json`, so a CI or piped consumer sees only the JSON on
@@ -155,10 +152,9 @@ const setupCommand = defineCommand({
155
152
  description: "Interactive configuration wizard. Enables agent programs (Claude Code, Codex, OpenCode, ...), sets up a model connection and embeddings, and picks bundle sources and registries. Flags answer individual questions; --yes takes the detected default for the rest, for scripting.",
156
153
  },
157
154
  args: {
158
- // R-051/S11: `setup` is a raw `defineCommand` (not `defineJsonCommand`),
159
- // so it does not get `GLOBAL_OUTPUT_ARGS` for free — same gap `health`
160
- // already had. Three of its four run branches call `output()`; spreading
161
- // this in is a `--help` visibility fix, not a behavior change.
155
+ // `setup` is a raw `defineCommand` (not `defineJsonCommand`), so it does
156
+ // not get `GLOBAL_OUTPUT_ARGS` for free. Three of its four run branches
157
+ // call `output()`; spreading this in is a `--help` visibility fix.
162
158
  ...GLOBAL_OUTPUT_ARGS,
163
159
  endpoint: {
164
160
  type: "string",
@@ -285,11 +281,11 @@ const healthCommand = defineCommand({
285
281
  description: "Check akm runtime health, artifacts, and improve metrics",
286
282
  },
287
283
  args: {
288
- // R-051: `health` is a raw `defineCommand` (not `defineJsonCommand`), so
289
- // it does not get `GLOBAL_OUTPUT_ARGS` for free. `--format`/`--detail`/
290
- // `--output` already parsed correctly here (this command has
284
+ // `health` is a raw `defineCommand` (not `defineJsonCommand`), so it does
285
+ // not get `GLOBAL_OUTPUT_ARGS` for free. `--format`/`--detail`/
286
+ // `--output` parse correctly here regardless (this command has
291
287
  // no positional for a stray value to fall into), so this is purely a
292
- // `--help` visibility / consistency fix, not a behavior change.
288
+ // `--help` visibility / consistency measure.
293
289
  ...GLOBAL_OUTPUT_ARGS,
294
290
  since: {
295
291
  type: "string",
@@ -389,8 +385,8 @@ const healthCommand = defineCommand({
389
385
  }
390
386
  output("health", base);
391
387
  });
392
- // R-067: `emitJsonError` (src/cli/shared.ts) no longer force-exits on the
393
- // error path — it sets `process.exitCode` and returns, so a `--report`
388
+ // `emitJsonError` (src/cli/shared.ts) does not force-exit on the error
389
+ // path — it sets `process.exitCode` and returns, so a `--report`
394
390
  // failure thrown AFTER `resultStatus` was already assigned (e.g. the
395
391
  // proposal-queue read above) would otherwise leave `resultStatus`
396
392
  // populated here too. Skip the status-derived exit entirely once
@@ -443,14 +439,12 @@ const completionsCommand = defineCommand({
443
439
  },
444
440
  },
445
441
  run({ args }) {
446
- // R-052(b): this was a bare `run()` throwing directly, so an unsupported
447
- // `--shell` value escaped straight to citty's top-level error handling
448
- // instead of the standard JSON envelope — exit 1 with a raw stack trace
449
- // instead of the classified exit-2 usage error every other command
450
- // produces (`completions` is format-exempt, so it stays a raw
451
- // `defineCommand` rather than `defineJsonCommand`, but still needs the
452
- // same error-classification wrapper other bare `defineCommand`s in this
453
- // file use, e.g. `help migrate` below).
442
+ // An unsupported `--shell` value must reach the standard JSON envelope as
443
+ // the classified exit-2 usage error, not escape to citty's top-level error
444
+ // handling as a raw stack trace (`completions` is format-exempt, so it
445
+ // stays a raw `defineCommand` rather than `defineJsonCommand`, but still
446
+ // needs the same error-classification wrapper other bare `defineCommand`s
447
+ // in this file use, e.g. `help migrate` below).
454
448
  return runWithJsonErrors(() => {
455
449
  if (args.shell !== "bash") {
456
450
  throw new UsageError(`Unsupported shell: ${args.shell}. Only bash is supported.`);
@@ -678,7 +672,7 @@ const EXIT_GENERAL = EXIT_CODES.GENERAL;
678
672
  const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
679
673
  // ── Top-level driver (replaces citty's `runMain`) ───────────────────────────
680
674
  //
681
- // R-032: citty's own `runMain` catches EVERY error escaping `runCommand` —
675
+ // citty's own `runMain` catches EVERY error escaping `runCommand` —
682
676
  // including its unexported `CLIError`, thrown for "Unknown command …", "No
683
677
  // command specified.", "Missing required argument/positional …", and invalid
684
678
  // enum values — and unconditionally calls `process.exit(1)`, regardless of
@@ -692,7 +686,7 @@ const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
692
686
  // of `runMain`, replicating `runMain`'s `--help` / `--version`
693
687
  // short-circuits and its CLIError → usage-banner rendering, but classifying
694
688
  // a CLIError as USAGE (2) instead of GENERAL (1). Every other error escaping
695
- // this boundary keeps the previous GENERAL (1) mapping — this only
689
+ // this boundary keeps the GENERAL (1) mapping — this only
696
690
  // reclassifies the one error family citty itself throws before any of our
697
691
  // own command bodies (and their `runWithJsonErrors` / `emitJsonError`
698
692
  // classification) ever run.
@@ -762,7 +756,7 @@ function resolveCittyCommandPath(cmd, rawArgs, path = []) {
762
756
  * dropping the `akm ` root prefix every top-level command's `--help` already
763
757
  * shows. Building a synthetic parent whose `meta.name` is the full prefix
764
758
  * (`akm task`) fixes it for any depth without patching the vendored
765
- * dependency (S11 item 4).
759
+ * dependency.
766
760
  */
767
761
  function buildUsageParentForPath(path) {
768
762
  // `version` carries through too — citty's renderUsage falls back to
@@ -776,7 +770,7 @@ function buildUsageParentForPath(path) {
776
770
  },
777
771
  };
778
772
  }
779
- // ── Sectioned root help (S11) ────────────────────────────────────────────────
773
+ // ── Sectioned root help ────────────────────────────────────────────────
780
774
  //
781
775
  // citty's own generated COMMANDS list is a flat, unordered dump of every
782
776
  // top-level command — fine for a handful of commands, not for the ~28 this
@@ -853,7 +847,7 @@ function renderCommandSections() {
853
847
  return HELP_SECTIONS.map(({ title, commands }) => `${title}\n${formatCommandRows(commands)}`).join("\n\n");
854
848
  }
855
849
  /**
856
- * Sectioned replacement for citty's root COMMANDS list (S11 item 1). Reuses
850
+ * Sectioned replacement for citty's root COMMANDS list. Reuses
857
851
  * `renderUsage(main)` for the banner + exit-code table (kept verbatim) +
858
852
  * USAGE line + global OPTIONS, then cuts the string before citty's own
859
853
  * "COMMANDS" heading (present because `main` has `subCommands`) and appends
@@ -938,7 +932,7 @@ function cittyCliErrorUsageCode(error) {
938
932
  }
939
933
  /**
940
934
  * Route a citty `CLIError` through the standard JSON envelope instead of
941
- * citty's own usage-banner + raw `console.error(message)` (S11 item 3): the
935
+ * citty's own usage-banner + raw `console.error(message)`: the
942
936
  * one-line diagnosis is the error's own message (e.g. "Unknown command
943
937
  * foo"), and the hint is a short pointer — with a did-you-mean suggestion
944
938
  * prepended when the unresolved token is close to a real sibling command —
@@ -962,12 +956,10 @@ export function normalizeCittyCliError(error, rawArgs) {
962
956
  }
963
957
  /**
964
958
  * The CLI's real startup sequence, extracted into a function so error paths
965
- * can `return` early — top-level `return` is a syntax error in an ES module,
966
- * and this used to rely on `emitJsonError`'s `never` return type (a
967
- * synchronous `process.exit`) to stop execution instead. Now that
968
- * `emitJsonError` (src/cli/shared.ts, R-067) only records `process.exitCode`
969
- * and returns, every direct call site here needs its own explicit `return;`
970
- * to stop the rest of startup from running after a fatal early error.
959
+ * can `return` early — top-level `return` is a syntax error in an ES module.
960
+ * `emitJsonError` (src/cli/shared.ts) only records `process.exitCode` and
961
+ * returns, so every direct call site here needs its own explicit `return;` to
962
+ * stop the rest of startup from running after a fatal early error.
971
963
  */
972
964
  export async function runCli() {
973
965
  try {
@@ -978,7 +970,7 @@ export async function runCli() {
978
970
  return;
979
971
  }
980
972
  // Mint the ParsedInvocation singleton from the (normalized) argv — the ONE
981
- // place argv is parsed for the whole process (plan §10.7 / chunk-9 WI-9.9).
973
+ // place argv is parsed for the whole process.
982
974
  // Every out-of-cli.ts command module reads argv state through
983
975
  // `getParsedInvocation()` from here on instead of re-scanning process.argv.
984
976
  setParsedInvocation(process.argv);
@@ -1024,11 +1016,10 @@ export async function runCli() {
1024
1016
  return;
1025
1017
  }
1026
1018
  const commandPath = resolveCittyCommandPath(main, process.argv.slice(2));
1027
- // D7 — every command that renders through output() honours all six --format
1019
+ // Every command that renders through output() honours all six --format
1028
1020
  // values. The declared exempt set (src/output/format-exempt.ts) does not
1029
1021
  // render an envelope at all, so warn rather than pretend: silently ignoring
1030
- // the flag is what made the old md/html behaviour so hard to discover. A
1031
- // warning, not an error, because the flag is harmless here and scripts that
1022
+ // the flag makes the behaviour hard to discover. A warning, not an error, because the flag is harmless here and scripts that
1032
1023
  // pass --format globally to a mixed batch of commands should still work.
1033
1024
  const invocation = getParsedInvocation();
1034
1025
  if ((invocation.hasFlag("--format") || invocation.getFlagValue("--format") !== undefined) &&
@@ -1082,7 +1073,7 @@ export async function runCli() {
1082
1073
  if (HELP_FLAGS.some((flag) => ownArgs.includes(flag))) {
1083
1074
  const resolved = resolveDeepestCittyCommand(main, rawArgs);
1084
1075
  if (resolved === main) {
1085
- // Root `--help` (S11 item 1): the sectioned overview, not citty's
1076
+ // Root `--help`: the sectioned overview, not citty's
1086
1077
  // flat COMMANDS dump.
1087
1078
  process.stdout.write(`${await renderSectionedRootHelp()}\n`);
1088
1079
  }
@@ -1097,14 +1088,14 @@ export async function runCli() {
1097
1088
  return;
1098
1089
  }
1099
1090
  // citty's parseArgs (strict: false) silently ignores undeclared flags, so
1100
- // a typo used to run the command with defaults and exit 0. Checked here, after --help and
1101
- // --version, so those keep working on any command.
1091
+ // a typo would otherwise run the command with defaults and exit 0. Checked
1092
+ // here, after --help and --version, so those keep working on any command.
1102
1093
  assertKnownFlags(main, rawArgs);
1103
1094
  await runCommand(main, { rawArgs });
1104
1095
  }
1105
1096
  catch (error) {
1106
1097
  if (isCittyCliError(error)) {
1107
- // R-032/S11: reclassify citty's own "unknown command" / "no command
1098
+ // Reclassify citty's own "unknown command" / "no command
1108
1099
  // specified" / "missing required argument" family as USAGE (2) and
1109
1100
  // route it through the SAME JSON envelope every other command's
1110
1101
  // failure uses (`emitJsonError`), rather than citty's own usage-banner
@@ -435,10 +435,11 @@ export const feedbackCommand = defineJsonCommand({
435
435
  "next search; no reindex is needed. Each recorded positive raises the asset's\n" +
436
436
  "score and each negative lowers it, the same way for every asset: the more\n" +
437
437
  "positives, the higher it ranks (5 outrank 2), and each further vote counts\n" +
438
- "for a little less. The effect is small, so it reorders close matches and\n" +
439
- "does not override relevance. Only feedback recorded as the user counts, not\n" +
440
- "feedback from improve, task or audit runs. Feedback also moves the utility\n" +
441
- "score that improve and `akm metrics` read.",
438
+ "for a little less. The effect is small: in keyword-only search one vote\n" +
439
+ "moves a hit one place, and with embeddings it breaks only near-ties. Only\n" +
440
+ "feedback recorded as the user counts, not feedback from improve, task or\n" +
441
+ "audit runs. Feedback also moves the utility score that improve and\n" +
442
+ "`akm metrics` read.",
442
443
  },
443
444
  args: {
444
445
  // Optional in citty so run() is invoked even when omitted; we re-validate
@@ -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);
@@ -72,21 +72,21 @@ function validateAkmHealthOptions(options) {
72
72
  }
73
73
  }
74
74
  }
75
- // ── akmHealth phase helpers (chunk-9 WI-9.5b; file-level decompose following
76
- // the function's natural gather/advise/check/assemble phases) ───────────────
75
+ // ── akmHealth phase helpers (the function's natural
76
+ // gather/advise/check/assemble phases) ──────────────────────────────────────
77
77
  /** Event type appended + read back by the state.db round-trip probe. */
78
78
  const HEALTH_PROBE_EVENT = "health_probe";
79
- /** Synthetic sentinel ref (ref-grammar decision D-R3): a colon-free
79
+ /** Synthetic sentinel ref: a colon-free
80
80
  * `<subsystem>/_<marker>` label. `health` has no asset stash-subdir, so
81
81
  * `health/_probe` names the subsystem. */
82
82
  const HEALTH_PROBE_REF = "health/_probe";
83
83
  /**
84
84
  * Verify state.db can accept a write and read it back — WITHOUT leaving any
85
- * permanent trace (R-030). Earlier versions appended a `health_probe` event
86
- * on every `akm health` invocation and never removed it: a read-only health
87
- * check ran on a cron would grow state.db without bound (the only purge is
88
- * `improve`'s retention pass, which a health-only user never runs). The probe
89
- * row inserted here is deleted again inside the SAME connection once the
85
+ * permanent trace. Leaving the `health_probe` event in place after every
86
+ * `akm health` invocation would grow state.db without bound for a read-only
87
+ * health check run on a cron (the only purge is `improve`'s retention pass,
88
+ * which a health-only user never runs). The probe row inserted here is
89
+ * deleted again inside the SAME connection once the
90
90
  * round trip is confirmed, so the net effect on the `events` table is always
91
91
  * zero rows — the round trip still genuinely exercises append + read against
92
92
  * the real table, it just doesn't accumulate.
@@ -132,7 +132,7 @@ export function probeStateDbRoundTrip(stateDbPath) {
132
132
  }
133
133
  }
134
134
  /**
135
- * Item 7: dedupe stuck-active rows by task_id (keeping the oldest/largest age
135
+ * Dedupe stuck-active rows by task_id (keeping the oldest/largest age
136
136
  * per id) so the `active-runs` check can name WHICH tasks are stuck instead of
137
137
  * just a count. No pid/liveness probing — purely a projection of the rows
138
138
  * already read.
@@ -148,7 +148,7 @@ function dedupeStuckActiveTasks(rows, now) {
148
148
  return [...byTask.entries()].map(([taskId, ageMs]) => ({ taskId, ageMs }));
149
149
  }
150
150
  /**
151
- * Item 6: group task_history rows by task_id and return the one with the
151
+ * Group task_history rows by task_id and return the one with the
152
152
  * highest fail rate among tasks with at least MIN_ROWS_FOR_WORST_TASK_FAIL_RATE
153
153
  * rows in the window — surfaces a consistently-failing task that a large,
154
154
  * mostly-healthy population would otherwise hide behind the aggregate rate.
@@ -184,7 +184,7 @@ function gatherTaskHistoryPhase(db, since, stateDbPath, now, readOnly) {
184
184
  const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
185
185
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
186
186
  const probe = readOnly ? { ok: true, durationMs: null, skipped: true } : probeStateDbRoundTrip(stateDbPath);
187
- // R0: read-only, independent of the round-trip probe above — integrity_check
187
+ // Read-only, independent of the round-trip probe above — integrity_check
188
188
  // catches corruption a successful append/read cannot (out-of-order rowids,
189
189
  // bad index entry counts), and the freelist reading is purely informational.
190
190
  const stateDbIntegrity = runStateDbIntegrityCheck(stateDbPath);
@@ -251,7 +251,7 @@ function gatherAncillaryAdvisories(db, options, egressConfigView) {
251
251
  const indexStateMismatch = detectIndexStateGenerationMismatch(db);
252
252
  if (indexStateMismatch)
253
253
  advisories.push(indexStateMismatch);
254
- // 08-F1: surface a `stash-git-exposure` advisory when env/secret assets are
254
+ // Surface a `stash-git-exposure` advisory when env/secret assets are
255
255
  // git-tracked AND a remote is configured (the leak moment). Best-effort.
256
256
  // Cheap guard: only shell out to git when the stash has its OWN `.git` (or a
257
257
  // test injected a fake seam), so the hot path never spawns for a non-git
@@ -295,8 +295,7 @@ function gatherAncillaryAdvisories(db, options, egressConfigView) {
295
295
  catch {
296
296
  // Non-fatal.
297
297
  }
298
- // Item 4 (alpha.9 plan §5.4/§8 step 8): a bundle with no git history of its
299
- // own never gets the purge sweep, so its memory-cleanup archive only ever
298
+ // A bundle with no git history of its own never gets the purge sweep, so its memory-cleanup archive only ever
300
299
  // grows — report its size and file count instead. Best-effort — an
301
300
  // unreadable archive must not abort the health report.
302
301
  try {
@@ -425,7 +424,7 @@ function detectIndexStateGenerationMismatch(stateDb) {
425
424
  }
426
425
  }
427
426
  /**
428
- * Phase 3 — window-compare mode. Resolves `--window-compare`/`--windows` into
427
+ * Window-compare mode. Resolves `--window-compare`/`--windows` into
429
428
  * per-window bundles and computes deltas between the earliest and latest
430
429
  * window. Top-level metrics retain the primary `--since` query.
431
430
  */
@@ -655,7 +654,7 @@ export async function akmHealth(options = {}) {
655
654
  const hardFailure = hardChecks.some((check) => check.status === "fail");
656
655
  const deterministicWarnings = [...hardChecks, ...advisories].some((check) => check.status === "warn" && check.kind === "deterministic");
657
656
  const status = hardFailure ? "fail" : deterministicWarnings ? "warn" : "pass";
658
- // ── Per-run mode (Phase 2) ────────────────────────────────────────────
657
+ // ── Per-run mode ────────────────────────────────────────────
659
658
  const runs = options.groupBy === "run" ? perRunSummaries : undefined;
660
659
  return {
661
660
  schemaVersion: 3,
@@ -390,7 +390,7 @@ const proposalDrainCommand = defineJsonCommand({
390
390
  },
391
391
  promote: {
392
392
  type: "boolean",
393
- description: "Promote (accept) matching proposals. Default is queue mode (stage only, no writes to assets).",
393
+ description: "Promote (accept) matching proposals. Without it, the strategy's apply mode decides, and the default strategy's is promote (akm#1274).",
394
394
  default: false,
395
395
  },
396
396
  judgment: {
@@ -14,8 +14,11 @@ export async function searchRegistry(query, options) {
14
14
  return { query: "", hits: [], warnings: [] };
15
15
  }
16
16
  const limit = clampLimit(options?.limit);
17
+ // AKM_REGISTRY_URL outranks the caller's list: `akm search` passes
18
+ // `config.registries`, which would otherwise shadow the override.
19
+ const envOverride = process.env.AKM_REGISTRY_URL?.trim() ? resolveRegistries() : undefined;
17
20
  // resolveRegistries() already filters by enabled; explicit registries are filtered here
18
- const raw = options?.registries ?? resolveRegistries();
21
+ const raw = envOverride ?? options?.registries ?? resolveRegistries();
19
22
  const warnings = [];
20
23
  const entries = (options?.registries ? raw.filter((r) => r.enabled !== false) : raw).filter((entry) => {
21
24
  if (!hasRegistryUrlCredentials(entry.url))
@@ -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: {
@@ -5,7 +5,7 @@
5
5
  * `akm search` — entry point.
6
6
  *
7
7
  * Search consults the local FTS5 index. There is one query path
8
- * because there is one data store. Provider fan-out is gone.
8
+ * because there is one data store; there is no provider fan-out.
9
9
  *
10
10
  * The orchestration here is thin: build the FTS query, optionally interleave
11
11
  * a registry search behind `--from registry|all`, and log a usage event.
@@ -214,9 +214,9 @@ function resolveEntryIds(db, hits) {
214
214
  try {
215
215
  const entryId = getEntryIdByFilePath(db, hit.path);
216
216
  if (entryId !== undefined) {
217
- // F4c: persist the DURABLE fully-qualified `bundle//conceptId` spelling,
218
- // derived from the resolved entry row's `item_ref` (D-R3: durable keys
219
- // come from the resolved item, never raw input).
217
+ // Persist the DURABLE fully-qualified `bundle//conceptId` spelling,
218
+ // derived from the resolved entry row's `item_ref` (durable keys come
219
+ // from the resolved item, never raw input).
220
220
  const itemRef = getItemRefById(db, entryId);
221
221
  if (itemRef !== null) {
222
222
  results.push({ entryId, ref: itemRef, hit });
@@ -254,7 +254,7 @@ function logSearchEvent(rawQuery, response, mode = "keyword", eventSource = "use
254
254
  // Emit a structured event to events.jsonl so workflow-trace consumers
255
255
  // detect akm search invocations without relying on stdout scraping.
256
256
  const stashHits = response.hits.filter((h) => h.type !== "registry");
257
- // D8: include registry hit refs so a show following a registry-only search generates a select event
257
+ // Include registry hit refs so a show following a registry-only search generates a select event
258
258
  const registryHitRefs = (response.registryHits ?? []).map((h) => `registry:${h.id}`);
259
259
  const allResultRefs = [...stashHits.map((h) => h.ref), ...registryHitRefs];
260
260
  appendEvent({
@@ -267,7 +267,7 @@ function logSearchEvent(rawQuery, response, mode = "keyword", eventSource = "use
267
267
  // Under contention these usage hints are skipped, not waited for.
268
268
  withIndexDb((db) => {
269
269
  const resolved = resolveEntryIds(db, stashHits.slice(0, 50));
270
- // usage_events telemetry now writes to state.db (Chunk-8 WI-8.3);
270
+ // usage_events telemetry writes to state.db;
271
271
  // entry_id/entry_ref are resolved from index.db above and carried across.
272
272
  const stashHitCount = response.hits.length;
273
273
  const registryHitCount = Array.isArray(response.registryHits) ? response.registryHits.length : 0;