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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.28-alpha.4] - 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - **`akm upgrade` also updates the akm plugin of each installed harness (#1007).** After the CLI step
14
+ it refreshes Claude Code (`claude plugin marketplace update`, then `claude plugin update`) and
15
+ Codex (`codex plugin marketplace upgrade`) when the `akm-plugins` marketplace is configured and the
16
+ plugin is installed, and replaces a stale cached `akm-opencode` (moved to the trash, then re-fetched
17
+ by `opencode debug config`) unless OpenCode is running, in which case it is `deferred`. It only
18
+ updates: a missing plugin is never installed. The result gains `plugins` (one `outcome` per harness:
19
+ `updated`, `current`, `skipped`, `deferred`, `failed`; under `--check`, which changes nothing,
20
+ `pending` for OpenCode and `unknown` for Claude Code and Codex, whose check needs a fetch) and a failed plugin exits 1. With the OpenCode plugin present the CLI moves to the akm-cli
21
+ that `akm-opencode@latest` pins instead of the newest release, reported under `lockstep`; if the pin
22
+ cannot be read the CLI is held where it is rather than moved ahead of the plugin. The
23
+ Containers entrypoint and the Codex hook trust entries are documented under
24
+ [`akm upgrade`](docs/reference/cli.md#upgrade).
25
+
26
+ ### Fixed
27
+
28
+ - **A skill's reference file with a shell `$1` is no longer indexed as a command (#1063).** A file under
29
+ `skills/**` other than `SKILL.md` that showed `local var="$1"` in a code block was retyped to
30
+ `commands/skills/<name>/...`. A skill's folder now counts as a declared context, as `memories/` and the other typed
31
+ directories already did, so the file stays a skill resource (`knowledge/skills/<name>/...`). A `$1` under
32
+ `commands/` or in a loose file is still a command.
33
+
34
+ ## [0.9.28-alpha.3] - 2026-10-08
35
+
36
+ ### Changed
37
+
38
+ - A consolidate pair-pass retire proposal now records the judge's claim lists in its `retirement` metadata as `onlyInRetired` and `onlyInSuccessor`, named by role rather than by the judge's A/B, so what the judge found only on each side survives past the run. Proposals minted before this lack both fields; no row is added for a pair akm keeps.
39
+
40
+ - A distill lesson the quality gate rejects or sends to review now keeps its text: the `distill_invoked` event and the distill result carry `rejectedContent`, cut to 2000 characters (the judge prompt's cap). Before, a rejected lesson left only its score and reason. It stays local in `state.db`.
41
+
9
42
  ## [0.9.28-alpha.2] - 2026-10-08
10
43
 
11
44
  ### Added
@@ -329,8 +329,8 @@ akm bundle list # List all sources
329
329
  akm lint # Structural lint over the bundle; exits 0 regardless of findings
330
330
  akm lint --fix # Auto-fix Tier 1 issues
331
331
  akm lint --fail-on-flagged # Exit non-zero when summary.flagged > 0 (CI-friendly)
332
- akm upgrade # Upgrade akm using its install method
333
- akm upgrade --check # Check for updates
332
+ akm upgrade # Upgrade akm using its install method, then update installed harness plugins
333
+ akm upgrade --check # Report pending CLI and plugin updates, changing nothing
334
334
  akm help migrate 0.6.0 # Print migration notes for a release (or: latest)
335
335
  akm help bundle # Print options and subcommands for one command
336
336
  akm help agents --full # Print this reference
@@ -637,6 +637,9 @@ async function judgeOne(ctx, candidate) {
637
637
  cosine: candidate.cosine,
638
638
  judgeLabel: verdict.relation,
639
639
  judgeReason: verdict.reason,
640
+ // The judge's A is the older side and B the newer (see orderByAge); record the lists by role.
641
+ onlyInRetired: retired === older ? verdict.onlyInA : verdict.onlyInB,
642
+ onlyInSuccessor: retired === older ? verdict.onlyInB : verdict.onlyInA,
640
643
  retiredContentHash: retiredHash,
641
644
  successorContentHash: successorHash,
642
645
  reason,
@@ -638,6 +638,8 @@ function rejectDistilled(run, proposalRef, content, score, reason, meta) {
638
638
  ledgerRef: run.ledgerRef,
639
639
  });
640
640
  }
641
+ /** Longest lesson text kept on a rejection, the same cap the quality judge's prompt reads (stage.ts). */
642
+ const REJECTED_CONTENT_MAX_CHARS = 2000;
641
643
  /**
642
644
  * Record a distill quality-gate outcome and return its envelope.
643
645
  * `quality_rejected` lands in the improve ledger under the input's key (its
@@ -673,6 +675,8 @@ export function writeQualityRejection(args) {
673
675
  }
674
676
  }
675
677
  const eligMeta = args.eligibilitySource ? { eligibilitySource: args.eligibilitySource } : {};
678
+ // The text the gate turned away, kept in the local event and the result so a rejection can be audited.
679
+ const rejectedContent = args.content.slice(0, REJECTED_CONTENT_MAX_CHARS);
676
680
  appendEvent({
677
681
  eventType: "distill_invoked",
678
682
  ref: ledgerRef,
@@ -681,6 +685,7 @@ export function writeQualityRejection(args) {
681
685
  proposalRef: args.proposalRef,
682
686
  score: args.score,
683
687
  reason: args.reason,
688
+ rejectedContent,
684
689
  ...meta,
685
690
  ...eligMeta,
686
691
  },
@@ -693,6 +698,7 @@ export function writeQualityRejection(args) {
693
698
  proposalRef: args.proposalRef,
694
699
  score: args.score,
695
700
  reason: args.reason,
701
+ rejectedContent,
696
702
  ...(proposal ? { proposalId: proposal.id, proposal } : {}),
697
703
  ...meta,
698
704
  };
@@ -0,0 +1,328 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The plugin half of `akm upgrade` (#1007): refresh the akm plugin of each
6
+ * installed agent harness, and keep the CLI in version lockstep with the
7
+ * OpenCode plugin. Update only: a plugin that is not installed is never
8
+ * installed, and a harness without one is skipped. Every external command
9
+ * runs with a timeout and its failure is captured on that harness's entry,
10
+ * so a broken harness never aborts the CLI upgrade.
11
+ */
12
+ import * as childProcess from "node:child_process";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { IS_WINDOWS } from "../../core/common.js";
17
+ import { moveToTrash } from "../../core/trash.js";
18
+ import { semverOrder } from "../../runtime.js";
19
+ const MARKETPLACE = "akm-plugins";
20
+ const PLUGIN_ID = `akm@${MARKETPLACE}`;
21
+ const OPENCODE_PACKAGE = "akm-opencode";
22
+ const READ_TIMEOUT_MS = 30_000;
23
+ const REFRESH_TIMEOUT_MS = 120_000;
24
+ const PREFETCH_TIMEOUT_MS = 180_000;
25
+ function runCommand(command, args, timeoutMs, opts) {
26
+ const result = childProcess.spawnSync(command, args, {
27
+ encoding: "utf8",
28
+ env: process.env,
29
+ stdio: "pipe",
30
+ timeout: timeoutMs,
31
+ killSignal: "SIGKILL",
32
+ cwd: opts?.cwd,
33
+ });
34
+ const label = `${command} ${args.join(" ")}`;
35
+ if (result.error) {
36
+ const code = result.error.code;
37
+ if (code === "ETIMEDOUT")
38
+ return { ok: false, missing: false, error: `\`${label}\` timed out after ${timeoutMs / 1000}s` };
39
+ return { ok: false, missing: code === "ENOENT", error: `\`${label}\` could not run: ${result.error.message}` };
40
+ }
41
+ if (result.status !== 0) {
42
+ const detail = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
43
+ return { ok: false, missing: false, error: `\`${label}\` failed: ${detail}` };
44
+ }
45
+ return { ok: true, stdout: result.stdout ?? "" };
46
+ }
47
+ function parseJson(text) {
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ function skipped(harness, message) {
56
+ return { harness, outcome: "skipped", message };
57
+ }
58
+ function failed(harness, message) {
59
+ return { harness, outcome: "failed", message };
60
+ }
61
+ /** An entry for a refresh that ran: `updated` when the installed version moved, else `current`. */
62
+ function refreshed(harness, from, to) {
63
+ const moved = from !== undefined && to !== undefined && from !== to;
64
+ return { harness, outcome: moved ? "updated" : "current", from, to };
65
+ }
66
+ /** Unknown, in `--check`: knowing whether a newer build exists needs a marketplace fetch, which `--check` does not do. */
67
+ function unknownRefresh(harness, from) {
68
+ return {
69
+ harness,
70
+ outcome: "unknown",
71
+ from,
72
+ message: "checking needs a fetch of the akm-plugins marketplace, which --check does not do; `akm upgrade` refreshes it",
73
+ };
74
+ }
75
+ function claudePluginVersion() {
76
+ const listed = runCommand("claude", ["plugin", "list", "--json"], READ_TIMEOUT_MS);
77
+ if (!listed.ok)
78
+ return { error: listed };
79
+ const plugins = parseJson(listed.stdout);
80
+ const plugin = Array.isArray(plugins) ? plugins.find((p) => p?.id === PLUGIN_ID) : undefined;
81
+ return { version: plugin ? String(plugin.version ?? "") : undefined };
82
+ }
83
+ function detectClaudeCode() {
84
+ const marketplaces = runCommand("claude", ["plugin", "marketplace", "list", "--json"], READ_TIMEOUT_MS);
85
+ if (!marketplaces.ok) {
86
+ return { entry: skipped("claude-code", marketplaces.missing ? "claude is not on PATH" : marketplaces.error) };
87
+ }
88
+ const list = parseJson(marketplaces.stdout);
89
+ if (!Array.isArray(list) || !list.some((m) => m?.name === MARKETPLACE)) {
90
+ return { entry: skipped("claude-code", `the ${MARKETPLACE} marketplace is not configured`) };
91
+ }
92
+ const plugin = claudePluginVersion();
93
+ if ("error" in plugin)
94
+ return { entry: skipped("claude-code", plugin.error.error) };
95
+ if (plugin.version === undefined)
96
+ return { entry: skipped("claude-code", "the akm plugin is not installed") };
97
+ return { version: plugin.version };
98
+ }
99
+ function upgradeClaudeCode(dryRun) {
100
+ const detected = detectClaudeCode();
101
+ if ("entry" in detected)
102
+ return detected.entry;
103
+ if (dryRun)
104
+ return unknownRefresh("claude-code", detected.version);
105
+ const marketplace = runCommand("claude", ["plugin", "marketplace", "update", MARKETPLACE], REFRESH_TIMEOUT_MS);
106
+ if (!marketplace.ok)
107
+ return failed("claude-code", marketplace.error);
108
+ const update = runCommand("claude", ["plugin", "update", PLUGIN_ID], REFRESH_TIMEOUT_MS);
109
+ if (!update.ok)
110
+ return failed("claude-code", update.error);
111
+ const after = claudePluginVersion();
112
+ return refreshed("claude-code", detected.version, "version" in after ? after.version : undefined);
113
+ }
114
+ // ── Codex ───────────────────────────────────────────────────────────────────
115
+ function codexPluginVersion() {
116
+ const listed = runCommand("codex", ["plugin", "list", "--marketplace", MARKETPLACE, "--json"], READ_TIMEOUT_MS);
117
+ if (!listed.ok)
118
+ return { error: listed };
119
+ const parsed = parseJson(listed.stdout);
120
+ const plugin = parsed?.installed?.find((p) => p?.pluginId === PLUGIN_ID);
121
+ return { version: plugin ? String(plugin.version ?? "") : undefined };
122
+ }
123
+ function upgradeCodex(dryRun) {
124
+ const before = codexPluginVersion();
125
+ if ("error" in before)
126
+ return skipped("codex", before.error.missing ? "codex is not on PATH" : before.error.error);
127
+ if (before.version === undefined) {
128
+ return skipped("codex", `the akm plugin is not installed from the ${MARKETPLACE} marketplace`);
129
+ }
130
+ if (dryRun)
131
+ return unknownRefresh("codex", before.version);
132
+ // Also refreshes the installed plugin's cache, so no re-add is needed.
133
+ const upgrade = runCommand("codex", ["plugin", "marketplace", "upgrade", MARKETPLACE], REFRESH_TIMEOUT_MS);
134
+ if (!upgrade.ok)
135
+ return failed("codex", upgrade.error);
136
+ const after = codexPluginVersion();
137
+ return refreshed("codex", before.version, "version" in after ? after.version : undefined);
138
+ }
139
+ /** The cached `akm-opencode` OpenCode installed on first use, or undefined when there is none. */
140
+ export function detectOpenCodeCache() {
141
+ const cacheHome = process.env.XDG_CACHE_HOME?.trim() ||
142
+ path.join(process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir(), ".cache");
143
+ const dir = path.join(cacheHome, "opencode", "packages", `${OPENCODE_PACKAGE}@latest`);
144
+ if (!fs.existsSync(dir))
145
+ return undefined;
146
+ const readVersion = (file) => {
147
+ try {
148
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
149
+ return typeof pkg.version === "string" ? pkg.version : undefined;
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ };
155
+ return { dir, version: readVersion(path.join(dir, "node_modules", OPENCODE_PACKAGE, "package.json")) };
156
+ }
157
+ /** What npm's `akm-opencode@latest` is, and which akm-cli it pins. */
158
+ export function lookupOpenCodeLatest() {
159
+ const view = runCommand(IS_WINDOWS ? "npm.cmd" : "npm", ["view", `${OPENCODE_PACKAGE}@latest`, "version", "dependencies.akm-cli", "--json"], READ_TIMEOUT_MS);
160
+ if (!view.ok)
161
+ return { error: view.error };
162
+ const parsed = parseJson(view.stdout);
163
+ if (typeof parsed?.version !== "string")
164
+ return { error: "npm did not report a version for akm-opencode@latest" };
165
+ const akmCli = parsed["dependencies.akm-cli"];
166
+ return { version: parsed.version, akmCli: typeof akmCli === "string" ? akmCli : undefined };
167
+ }
168
+ /** Whether an OpenCode process is running (its prefetch would be replaced under it). `undefined` when that cannot be told. */
169
+ function openCodeRunning() {
170
+ if (IS_WINDOWS) {
171
+ const tasks = runCommand("tasklist", ["/FI", "IMAGENAME eq opencode.exe", "/NH"], READ_TIMEOUT_MS);
172
+ return tasks.ok ? /opencode\.exe/i.test(tasks.stdout) : undefined;
173
+ }
174
+ // `-f` because a Node-wrapped opencode shows up as `node …/opencode`; the
175
+ // pattern needs `opencode` to be a whole path segment so `akm-opencode`
176
+ // in some other command's arguments does not match.
177
+ const pgrep = runCommand("pgrep", ["-f", "(^|/)opencode( |$)"], READ_TIMEOUT_MS);
178
+ if (pgrep.ok)
179
+ return true;
180
+ if (!pgrep.missing)
181
+ return false; // pgrep exits 1 when nothing matched
182
+ if (process.platform !== "linux")
183
+ return undefined;
184
+ try {
185
+ return fs.readdirSync("/proc").some((pid) => {
186
+ if (!/^\d+$/.test(pid) || Number(pid) === process.pid)
187
+ return false;
188
+ try {
189
+ const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
190
+ return argv.slice(0, 2).some((arg) => path.basename(arg) === "opencode");
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ });
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ }
201
+ function upgradeOpenCode(dryRun, cache, latest) {
202
+ if (!cache || !latest)
203
+ return skipped("opencode", "no cached akm-opencode plugin");
204
+ if ("error" in latest)
205
+ return failed("opencode", latest.error);
206
+ if (cache.version === latest.version)
207
+ return { harness: "opencode", outcome: "current", from: cache.version, to: latest.version };
208
+ const base = { harness: "opencode", from: cache.version, to: latest.version };
209
+ const running = openCodeRunning();
210
+ if (running !== false) {
211
+ return {
212
+ ...base,
213
+ outcome: "deferred",
214
+ message: running === true
215
+ ? "OpenCode is running; the plugin cache is replaced by a later `akm upgrade` once it has exited"
216
+ : "could not tell whether OpenCode is running; the plugin cache is replaced by a later `akm upgrade`",
217
+ };
218
+ }
219
+ if (dryRun)
220
+ return { ...base, outcome: "pending" };
221
+ // OpenCode must be runnable before the cache goes: it does the prefetch.
222
+ const probe = runCommand("opencode", ["--version"], READ_TIMEOUT_MS);
223
+ if (!probe.ok)
224
+ return skipped("opencode", probe.missing ? "opencode is not on PATH" : probe.error);
225
+ try {
226
+ moveToTrash(cache.dir);
227
+ }
228
+ catch (error) {
229
+ return failed("opencode", `could not move ${cache.dir} to the trash: ${error instanceof Error ? error.message : String(error)}`);
230
+ }
231
+ // Any OpenCode command that resolves the config installs the plugin again;
232
+ // from a temp dir, so no project's opencode.json is read.
233
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-opencode-prefetch-"));
234
+ try {
235
+ const prefetch = runCommand("opencode", ["debug", "config"], PREFETCH_TIMEOUT_MS, { cwd: workDir });
236
+ if (!prefetch.ok)
237
+ return failed("opencode", `${prefetch.error} (the old cache is in the trash)`);
238
+ }
239
+ finally {
240
+ fs.rmSync(workDir, { recursive: true, force: true });
241
+ }
242
+ const after = detectOpenCodeCache();
243
+ if (!after)
244
+ return failed("opencode", "opencode did not re-create the plugin cache (the old cache is in the trash)");
245
+ return { ...base, outcome: "updated", to: after.version ?? latest.version };
246
+ }
247
+ // ── Orchestration ───────────────────────────────────────────────────────────
248
+ export function upgradePlugins(opts) {
249
+ return [
250
+ upgradeClaudeCode(opts.dryRun),
251
+ upgradeCodex(opts.dryRun),
252
+ upgradeOpenCode(opts.dryRun, opts.openCode.cache, opts.openCode.latest),
253
+ ];
254
+ }
255
+ /**
256
+ * When the OpenCode plugin is present the CLI target is the akm-cli that
257
+ * `akm-opencode@latest` pins, not the newest release: the plugin runs that
258
+ * exact akm in-process, against databases a newer CLI may already have
259
+ * migrated. The CLI never moves backwards to meet the pin.
260
+ *
261
+ * It fails closed: when the plugin is present but its pin cannot be read (the
262
+ * npm lookup failed, or the package declares no akm-cli), the CLI is held where
263
+ * it is (`updateAvailable: false`, `latestVersion` = the current version) and
264
+ * `lockstep.reason` says why. Moving the CLI ahead of a pin nobody could read is
265
+ * what lockstep exists to prevent; the OpenCode entry reports the failure.
266
+ */
267
+ export function applyLockstep(check, latest) {
268
+ if (!latest)
269
+ return check;
270
+ // Held back only when there is a release this upgrade would otherwise install.
271
+ const wouldInstall = semverOrder(check.currentVersion, check.latestVersion) < 0;
272
+ const pinned = "error" in latest ? undefined : latest.akmCli;
273
+ if (!pinned) {
274
+ const reason = "error" in latest
275
+ ? `could not read the akm-cli pin of ${OPENCODE_PACKAGE}@latest: ${latest.error}`
276
+ : `${OPENCODE_PACKAGE}@latest declares no akm-cli dependency`;
277
+ return {
278
+ ...check,
279
+ latestVersion: check.currentVersion,
280
+ updateAvailable: false,
281
+ lockstep: {
282
+ plugin: OPENCODE_PACKAGE,
283
+ pinnedVersion: null,
284
+ newestVersion: check.latestVersion,
285
+ heldBack: wouldInstall,
286
+ reason,
287
+ },
288
+ };
289
+ }
290
+ const heldBack = semverOrder(pinned, check.latestVersion) < 0 && wouldInstall;
291
+ const lockstep = {
292
+ plugin: OPENCODE_PACKAGE,
293
+ pinnedVersion: pinned,
294
+ newestVersion: check.latestVersion,
295
+ heldBack,
296
+ };
297
+ if (!heldBack)
298
+ return { ...check, lockstep };
299
+ return {
300
+ ...check,
301
+ latestVersion: pinned,
302
+ updateAvailable: semverOrder(check.currentVersion, pinned) < 0,
303
+ lockstep,
304
+ };
305
+ }
306
+ /** `akm upgrade`: the CLI step (held to the OpenCode plugin's akm), then the plugins. */
307
+ export async function runUpgrade(args, currentVersion, deps) {
308
+ const cache = detectOpenCodeCache();
309
+ const latest = cache ? lookupOpenCodeLatest() : undefined;
310
+ const check = applyLockstep(await deps.checkForUpdate(currentVersion), latest);
311
+ const openCode = { cache, latest };
312
+ if (args.check) {
313
+ return { mode: "check", result: { ...check, plugins: upgradePlugins({ dryRun: true, openCode }) } };
314
+ }
315
+ const upgraded = await deps.performUpgrade(check, {
316
+ force: args.force,
317
+ skipPostUpgrade: args.skipPostUpgrade,
318
+ // A package manager install must name the version, or `@latest` goes past the pin.
319
+ ...(check.lockstep?.heldBack ? { targetVersion: check.latestVersion } : {}),
320
+ });
321
+ const plugins = upgradePlugins({ dryRun: false, openCode });
322
+ const result = { ...upgraded, ...(check.lockstep ? { lockstep: check.lockstep } : {}), plugins };
323
+ // The install may have succeeded, but an upgrade whose migration is
324
+ // blocked or could not run is not done, and neither is one whose plugin
325
+ // step failed.
326
+ const migrationFailed = upgraded.migration?.status === "blocked" || upgraded.migration?.status === "failed";
327
+ return { mode: "upgrade", result, failed: migrationFailed || plugins.some((p) => p.outcome === "failed") };
328
+ }
@@ -237,7 +237,7 @@ export async function performUpgrade(check, opts, dependencies) {
237
237
  migration: await runMigrationStep(runTool),
238
238
  };
239
239
  }
240
- const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
240
+ const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod, undefined, opts?.targetVersion);
241
241
  if (packageManagerCommand) {
242
242
  return runPackageManagerUpgrade({
243
243
  packageManagerCommand,
@@ -574,8 +574,8 @@ function resolveNodePackageManagerCommand(name) {
574
574
  const adjacent = path.join(path.dirname(process.execPath), `${name}${extension}`);
575
575
  return fs.existsSync(adjacent) ? adjacent : name;
576
576
  }
577
- export function getPackageManagerUpgradeCommand(installMethod, packageName = getInstalledPackageName()) {
578
- const pkgRef = `${packageName}@latest`;
577
+ export function getPackageManagerUpgradeCommand(installMethod, packageName = getInstalledPackageName(), version = "latest") {
578
+ const pkgRef = `${packageName}@${version}`;
579
579
  if (installMethod === "bun") {
580
580
  return {
581
581
  command: "bun",
@@ -33,12 +33,21 @@ import { UsageError } from "../../core/errors.js";
33
33
  import { appendEvent } from "../../core/events.js";
34
34
  import { resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
35
35
  import { pkgVersion } from "../../version.js";
36
+ import { runUpgrade } from "./plugin-upgrade.js";
36
37
  import { checkForUpdate, performUpgrade } from "./self-update.js";
37
38
  import { akmClone } from "./source-clone.js";
38
39
  export const upgradeCommand = defineJsonCommand({
39
- meta: { name: "upgrade", description: "Upgrade akm to the latest release" },
40
+ meta: {
41
+ name: "upgrade",
42
+ description: "Upgrade akm to the latest release, then update the akm plugin of each installed harness " +
43
+ "(Claude Code, Codex, OpenCode). With the OpenCode plugin installed, akm moves to the version that plugin pins.",
44
+ },
40
45
  args: {
41
- check: { type: "boolean", description: "Check for updates without installing", default: false },
46
+ check: {
47
+ type: "boolean",
48
+ description: "Report pending updates, CLI and per-harness plugins, without changing anything",
49
+ default: false,
50
+ },
42
51
  force: { type: "boolean", description: "Force upgrade even if on latest", default: false },
43
52
  "skip-post-upgrade": {
44
53
  type: "boolean",
@@ -47,17 +56,12 @@ export const upgradeCommand = defineJsonCommand({
47
56
  },
48
57
  },
49
58
  async run({ args }) {
50
- const check = await checkForUpdate(pkgVersion);
51
- if (args.check) {
52
- output("upgrade", check);
59
+ const run = await runUpgrade({ check: args.check, force: args.force, skipPostUpgrade: args["skip-post-upgrade"] }, pkgVersion, { checkForUpdate, performUpgrade: (check, opts) => performUpgrade(check, opts) });
60
+ if (run.mode === "check") {
61
+ output("upgrade", run.result);
53
62
  return;
54
63
  }
55
- const skipPostUpgrade = args["skip-post-upgrade"];
56
- const result = await performUpgrade(check, { force: args.force, skipPostUpgrade });
57
- // The install may have succeeded, but an upgrade whose migration is
58
- // blocked or could not run is not done: exit like `akm migrate apply` does.
59
- const migrationFailed = result.migration?.status === "blocked" || result.migration?.status === "failed";
60
- outputWithExitCode("upgrade", result, migrationFailed ? EXIT_CODES.GENERAL : undefined);
64
+ outputWithExitCode("upgrade", run.result, run.failed ? EXIT_CODES.GENERAL : undefined);
61
65
  },
62
66
  });
63
67
  // `sync` body, standalone so the git-commit/push logic stays in one place.
@@ -0,0 +1,73 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import * as childProcess from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ const TRASH_TOOL_TIMEOUT_MS = 30_000;
9
+ function homeDir() {
10
+ return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
11
+ }
12
+ /** `[command, ...args]` trash tools tried in order on Linux; the target path is appended. */
13
+ const LINUX_TRASH_TOOLS = [["trash-put"], ["gio", "trash"]];
14
+ /**
15
+ * FreeDesktop trash without a helper tool: move `target` into
16
+ * `$XDG_DATA_HOME/Trash/files` and write the `.trashinfo` that lets a file
17
+ * manager restore it. A plain rename, so it fails (rather than copying) when
18
+ * the trash is on another filesystem.
19
+ */
20
+ function moveToXdgTrash(target) {
21
+ const dataHome = process.env.XDG_DATA_HOME?.trim() || path.join(homeDir(), ".local", "share");
22
+ const filesDir = path.join(dataHome, "Trash", "files");
23
+ const infoDir = path.join(dataHome, "Trash", "info");
24
+ fs.mkdirSync(filesDir, { recursive: true });
25
+ fs.mkdirSync(infoDir, { recursive: true });
26
+ const base = path.basename(target);
27
+ let name = base;
28
+ for (let n = 1; fs.existsSync(path.join(filesDir, name)) || fs.existsSync(path.join(infoDir, `${name}.trashinfo`)); n++) {
29
+ name = `${base}.${n}`;
30
+ }
31
+ const deletedAt = new Date().toISOString().slice(0, 19);
32
+ fs.writeFileSync(path.join(infoDir, `${name}.trashinfo`), `[Trash Info]\nPath=${encodeURI(path.resolve(target))}\nDeletionDate=${deletedAt}\n`);
33
+ try {
34
+ fs.renameSync(target, path.join(filesDir, name));
35
+ }
36
+ catch (error) {
37
+ fs.unlinkSync(path.join(infoDir, `${name}.trashinfo`));
38
+ throw error;
39
+ }
40
+ }
41
+ /**
42
+ * Move `target` to the operating system's trash. Never deletes permanently:
43
+ * when no trash is available the target is left where it is and this throws.
44
+ * Linux tries `trash-put`, then `gio trash`, then the FreeDesktop trash
45
+ * directory; macOS moves into `~/.Trash`; Windows has no supported trash here.
46
+ */
47
+ export function moveToTrash(target, tools = LINUX_TRASH_TOOLS) {
48
+ if (!fs.existsSync(target))
49
+ return;
50
+ if (process.platform === "win32") {
51
+ throw new Error("moving to the Recycle Bin is not supported on Windows; the directory was left in place");
52
+ }
53
+ if (process.platform === "darwin") {
54
+ const trashDir = path.join(homeDir(), ".Trash");
55
+ fs.mkdirSync(trashDir, { recursive: true });
56
+ fs.renameSync(target, path.join(trashDir, `${path.basename(target)}.${Date.now()}`));
57
+ return;
58
+ }
59
+ for (const [command, ...args] of tools) {
60
+ const result = childProcess.spawnSync(command, [...args, target], {
61
+ // The live environment, as the harness commands get it: Bun's default is the env the process started with.
62
+ env: process.env,
63
+ encoding: "utf8",
64
+ stdio: "pipe",
65
+ timeout: TRASH_TOOL_TIMEOUT_MS,
66
+ });
67
+ if (!result.error && result.status === 0 && !fs.existsSync(target))
68
+ return;
69
+ // A tool that is missing falls through to the next; one that ran and
70
+ // failed does too, because the target is still in place.
71
+ }
72
+ moveToXdgTrash(target);
73
+ }
@@ -159,11 +159,12 @@ function matchDirectoryHint(dirName, ctx, specificity) {
159
159
  * True when some ancestor directory already DECLARES this file's type via
160
160
  * `DIR_TYPE_MAP` — the same walk `classifyByDirectory` performs. Derived from
161
161
  * path fields alone, so `smartMdPathCandidates` can apply it without reading
162
- * bytes.
162
+ * bytes. A skill's folder is a declared context too: a file in it is a skill
163
+ * resource, so a shell `$1` in one of its snippets must not retype it (#1063).
163
164
  */
164
165
  function hasDeclaredDirType(ctx) {
165
166
  if (isNestedSkillResource(ctx))
166
- return false;
167
+ return true;
167
168
  return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
168
169
  }
169
170
  function classifyByExtension(ctx) {
@@ -662,7 +662,33 @@ export function formatUpdatePlain(r) {
662
662
  }
663
663
  return lines.length > 0 ? lines.join("\n") : `update: nothing to update`;
664
664
  }
665
+ const HARNESS_LABELS = { "claude-code": "Claude Code", codex: "Codex", opencode: "OpenCode" };
666
+ /** The plugin lines worth printing: nothing for a plugin that is current or has no plugin to update. */
667
+ function formatUpgradePluginLines(r) {
668
+ const lines = [];
669
+ const lockstep = r.lockstep;
670
+ if (lockstep?.heldBack) {
671
+ lines.push(lockstep.pinnedVersion
672
+ ? `akm is held at v${lockstep.pinnedVersion} (v${lockstep.newestVersion} is out): the OpenCode plugin pins that version`
673
+ : `akm is not upgraded to v${lockstep.newestVersion}: ${lockstep.reason}`);
674
+ }
675
+ const plugins = Array.isArray(r.plugins) ? r.plugins : [];
676
+ for (const p of plugins) {
677
+ if (p.outcome === "current" || p.outcome === "skipped")
678
+ continue;
679
+ const versions = p.from && p.to && p.from !== p.to ? ` v${p.from} → v${p.to}` : "";
680
+ const note = p.message ? ` (${p.message})` : "";
681
+ lines.push(`${HARNESS_LABELS[String(p.harness)] ?? p.harness} plugin ${p.outcome}${versions}${note}`);
682
+ }
683
+ return lines;
684
+ }
665
685
  export function formatUpgradePlain(r) {
686
+ const lines = formatUpgradePluginLines(r);
687
+ const head = formatUpgradeHead(r);
688
+ const all = head === null ? lines : [head, ...lines];
689
+ return all.length > 0 ? all.join("\n") : null;
690
+ }
691
+ function formatUpgradeHead(r) {
666
692
  if (r.upgraded === true) {
667
693
  return `akm upgraded: v${r.currentVersion} → v${r.newVersion}`;
668
694
  }
@@ -30464,7 +30464,31 @@ function formatUpdatePlain(r) {
30464
30464
  return lines.length > 0 ? lines.join(`
30465
30465
  `) : `update: nothing to update`;
30466
30466
  }
30467
+ var HARNESS_LABELS = { "claude-code": "Claude Code", codex: "Codex", opencode: "OpenCode" };
30468
+ function formatUpgradePluginLines(r) {
30469
+ const lines = [];
30470
+ const lockstep = r.lockstep;
30471
+ if (lockstep?.heldBack) {
30472
+ lines.push(lockstep.pinnedVersion ? `akm is held at v${lockstep.pinnedVersion} (v${lockstep.newestVersion} is out): the OpenCode plugin pins that version` : `akm is not upgraded to v${lockstep.newestVersion}: ${lockstep.reason}`);
30473
+ }
30474
+ const plugins = Array.isArray(r.plugins) ? r.plugins : [];
30475
+ for (const p of plugins) {
30476
+ if (p.outcome === "current" || p.outcome === "skipped")
30477
+ continue;
30478
+ const versions = p.from && p.to && p.from !== p.to ? ` v${p.from} → v${p.to}` : "";
30479
+ const note = p.message ? ` (${p.message})` : "";
30480
+ lines.push(`${HARNESS_LABELS[String(p.harness)] ?? p.harness} plugin ${p.outcome}${versions}${note}`);
30481
+ }
30482
+ return lines;
30483
+ }
30467
30484
  function formatUpgradePlain(r) {
30485
+ const lines = formatUpgradePluginLines(r);
30486
+ const head = formatUpgradeHead(r);
30487
+ const all = head === null ? lines : [head, ...lines];
30488
+ return all.length > 0 ? all.join(`
30489
+ `) : null;
30490
+ }
30491
+ function formatUpgradeHead(r) {
30468
30492
  if (r.upgraded === true) {
30469
30493
  return `akm upgraded: v${r.currentVersion} → v${r.newVersion}`;
30470
30494
  }
@@ -40449,7 +40473,7 @@ function matchDirectoryHint(dirName, ctx, specificity) {
40449
40473
  }
40450
40474
  function hasDeclaredDirType(ctx) {
40451
40475
  if (isNestedSkillResource(ctx))
40452
- return false;
40476
+ return true;
40453
40477
  return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
40454
40478
  }
40455
40479
  function classifyByExtension(ctx) {
@@ -29792,7 +29792,31 @@ function formatUpdatePlain(r) {
29792
29792
  return lines.length > 0 ? lines.join(`
29793
29793
  `) : `update: nothing to update`;
29794
29794
  }
29795
+ var HARNESS_LABELS = { "claude-code": "Claude Code", codex: "Codex", opencode: "OpenCode" };
29796
+ function formatUpgradePluginLines(r) {
29797
+ const lines = [];
29798
+ const lockstep = r.lockstep;
29799
+ if (lockstep?.heldBack) {
29800
+ lines.push(lockstep.pinnedVersion ? `akm is held at v${lockstep.pinnedVersion} (v${lockstep.newestVersion} is out): the OpenCode plugin pins that version` : `akm is not upgraded to v${lockstep.newestVersion}: ${lockstep.reason}`);
29801
+ }
29802
+ const plugins = Array.isArray(r.plugins) ? r.plugins : [];
29803
+ for (const p of plugins) {
29804
+ if (p.outcome === "current" || p.outcome === "skipped")
29805
+ continue;
29806
+ const versions = p.from && p.to && p.from !== p.to ? ` v${p.from} \u2192 v${p.to}` : "";
29807
+ const note = p.message ? ` (${p.message})` : "";
29808
+ lines.push(`${HARNESS_LABELS[String(p.harness)] ?? p.harness} plugin ${p.outcome}${versions}${note}`);
29809
+ }
29810
+ return lines;
29811
+ }
29795
29812
  function formatUpgradePlain(r) {
29813
+ const lines = formatUpgradePluginLines(r);
29814
+ const head = formatUpgradeHead(r);
29815
+ const all = head === null ? lines : [head, ...lines];
29816
+ return all.length > 0 ? all.join(`
29817
+ `) : null;
29818
+ }
29819
+ function formatUpgradeHead(r) {
29796
29820
  if (r.upgraded === true) {
29797
29821
  return `akm upgraded: v${r.currentVersion} \u2192 v${r.newVersion}`;
29798
29822
  }
@@ -39777,7 +39801,7 @@ function matchDirectoryHint(dirName, ctx, specificity) {
39777
39801
  }
39778
39802
  function hasDeclaredDirType(ctx) {
39779
39803
  if (isNestedSkillResource(ctx))
39780
- return false;
39804
+ return true;
39781
39805
  return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
39782
39806
  }
39783
39807
  function classifyByExtension(ctx) {
@@ -186,8 +186,13 @@ migration didn't finish is not done. This makes plain `akm upgrade` (no
186
186
  flags) a safe, idempotent container entrypoint step on every boot: on an
187
187
  already-current install with nothing pending it is a fast no-op that exits 0.
188
188
 
189
+ After the install step it also updates the akm plugin of each agent harness
190
+ present in the image (Claude Code, Codex, OpenCode), reported under `plugins`;
191
+ see [`akm upgrade`](../reference/cli.md#upgrade) for the rules, the OpenCode
192
+ version lockstep and the Codex hook-trust entries a headless image needs.
193
+
189
194
  **`--check`** skips the migration step entirely — it only compares versions
190
- and reports `updateAvailable`. Use it for a version-drift alert, not as your
195
+ and reports `updateAvailable` (plus the pending per-harness plugin updates). Use it for a version-drift alert, not as your
191
196
  boot check.
192
197
 
193
198
  ## `akm health`
@@ -1336,20 +1336,96 @@ computed, with a 256 MiB binary limit. Release/checksum metadata is capped at
1336
1336
  1 MiB; an oversized response is cancelled and the staged file is removed.
1337
1337
 
1338
1338
  ```sh
1339
- akm upgrade # Install a newer release if there is one, then run every pending migration
1340
- akm upgrade --check # Check for updates without installing (no migration step)
1339
+ akm upgrade # Install a newer release if there is one, run every pending migration, then update the harness plugins
1340
+ akm upgrade --check # Report pending updates, CLI and plugins, without changing anything (no migration step)
1341
1341
  akm upgrade --force # Force the install even if already on latest
1342
1342
  ```
1343
1343
 
1344
1344
  | Flag | Description |
1345
1345
  | --- | --- |
1346
- | `--check` | Check for updates without installing |
1346
+ | `--check` | Report pending updates (CLI and per-harness plugins) without changing anything |
1347
1347
  | `--force` | Force upgrade even if on latest version |
1348
1348
  | `--skip-post-upgrade` | Skip the post-upgrade index rebuild |
1349
1349
 
1350
1350
  Offline, or to migrate without a release check, run `akm migrate apply`
1351
1351
  directly: it is the same step.
1352
1352
 
1353
+ #### Harness plugins
1354
+
1355
+ After the CLI step, `akm upgrade` updates the akm plugin of each agent harness
1356
+ it finds. It only updates: a plugin that is not installed is never installed,
1357
+ and a harness without the akm plugin is skipped. Each external command runs
1358
+ with a timeout, and a failure is recorded on that harness's entry without
1359
+ stopping the CLI upgrade.
1360
+
1361
+ | Harness | Updated when | What runs |
1362
+ | --- | --- | --- |
1363
+ | Claude Code | `claude` is on `PATH`, the `akm-plugins` marketplace is configured and `akm@akm-plugins` is installed | `claude plugin marketplace update akm-plugins`, then `claude plugin update akm@akm-plugins` |
1364
+ | Codex | `codex` is on `PATH` and `akm@akm-plugins` is installed from the `akm-plugins` marketplace | `codex plugin marketplace upgrade akm-plugins` (this also refreshes the installed plugin cache) |
1365
+ | OpenCode | `akm-opencode` is cached at `$XDG_CACHE_HOME/opencode/packages/akm-opencode@latest` (default `~/.cache/opencode/...`) and `opencode` is on `PATH` | If npm's `akm-opencode@latest` differs from the cached version and no OpenCode process is running: the cache directory is moved to the trash (never deleted), then `opencode debug config` is run from a temporary directory to fetch the new version. While OpenCode runs the update is `deferred` to a later `akm upgrade`. |
1366
+
1367
+ Claude Code does not update third-party marketplaces on its own (the Claude
1368
+ desktop app turns plugin updates off), and OpenCode never re-checks a cached
1369
+ plugin, so neither moves without this step. Codex refreshes its git
1370
+ marketplaces at every start.
1371
+
1372
+ The result gains a `plugins` array, one entry per harness:
1373
+ `{ "harness": "claude-code" | "codex" | "opencode", "outcome": ..., "from"?, "to"?, "message"? }`.
1374
+ `outcome` is `updated`, `current`, `skipped`, `deferred` or `failed`; under
1375
+ `--check` it can also be `pending` or `unknown`. `--check` does not fetch the
1376
+ Claude Code or Codex marketplaces, so for them it reports `unknown` (whether a
1377
+ newer build exists needs a fetch it does not do) rather than guessing; for
1378
+ OpenCode it compares the cached version with npm, so it reports `pending` when
1379
+ an update is due. A `failed` plugin makes `akm upgrade` exit `1` (not
1380
+ `--check`). On an up-to-date machine the text output prints nothing for
1381
+ plugins.
1382
+
1383
+ **Version lockstep.** The OpenCode plugin runs its own exact-pinned akm-cli
1384
+ in-process, so a CLI newer than the plugin's pin would run the plugin against
1385
+ databases it did not migrate. When the OpenCode plugin is present, the CLI
1386
+ target is the akm-cli that `akm-opencode@latest` depends on
1387
+ (`npm view akm-opencode@latest dependencies.akm-cli`), not the newest release.
1388
+ The result then carries `lockstep: { plugin, pinnedVersion, newestVersion, heldBack }`,
1389
+ `latestVersion` is the pinned version, the install names that exact version
1390
+ rather than `@latest`, and the text output says the CLI is held back. The CLI
1391
+ is never moved backwards to meet the pin. Claude Code and Codex have no
1392
+ in-process copy and are not part of this rule.
1393
+
1394
+ Lockstep fails closed. When the OpenCode plugin is cached but the pin cannot
1395
+ be read (the npm lookup failed, or `akm-opencode@latest` declares no
1396
+ `akm-cli`), the CLI is not upgraded: `updateAvailable` is `false`,
1397
+ `latestVersion` is the current version, and `lockstep` is
1398
+ `{ plugin, pinnedVersion: null, newestVersion, heldBack: true, reason }`. The
1399
+ OpenCode entry is `failed` and the run exits `1`; the next `akm upgrade`
1400
+ retries.
1401
+
1402
+ #### Containers
1403
+
1404
+ With the plugin step, a container entrypoint needs only:
1405
+
1406
+ ```sh
1407
+ akm upgrade -q || true
1408
+ ( while sleep 86400; do akm upgrade -q; done ) & # long-running containers only
1409
+ exec "$@"
1410
+ ```
1411
+
1412
+ Codex hooks need one-time trust, and nobody can open `/hooks` in a container.
1413
+ Bake the trust entries into the image's `~/.codex/config.toml`, one per hook the
1414
+ plugin declares (today `session_start` and `user_prompt_submit`):
1415
+
1416
+ ```toml
1417
+ [hooks.state."akm@akm-plugins:plugin.json#hooks[0]:session_start:0:0"]
1418
+ trusted_hash = "sha256:<hash>"
1419
+
1420
+ [hooks.state."akm@akm-plugins:plugin.json#hooks[0]:user_prompt_submit:0:0"]
1421
+ trusted_hash = "sha256:<hash>"
1422
+ ```
1423
+
1424
+ Copy the two `trusted_hash` values from a machine where you have trusted the
1425
+ hooks in `/hooks` (they are in that machine's `~/.codex/config.toml`). The
1426
+ hashes stay the same across plugin version bumps and change only when a hook's
1427
+ command changes, so an upgraded plugin keeps running without a prompt.
1428
+
1353
1429
  Checksum verification is not optional and has no flag. If a release's
1354
1430
  `checksums.txt` is genuinely unreachable, the recovery hatch is the
1355
1431
  `AKM_UPGRADE_SKIP_CHECKSUM=1` environment variable (Internal — deliberately
@@ -196,7 +196,7 @@ the set of types the code actually emits at HEAD (verified against every
196
196
  | `reflect_completed` | Reflect phase produced a proposal | `ref` |
197
197
  | `improve_reflect_outcome` | Per-asset reflect result | `ref`, `ok`, `durationMs`, `reason` |
198
198
  | `propose_invoked` | `akm proposal new` | `ref` |
199
- | `distill_invoked` | Distill phase inside the `akm improve`/`akm proposal new` pipeline. **`akm distill` is not a CLI command** — there is no standalone verb by that name | `ref`, outcome (`queued`, `skipped` with a `skipReason` such as `lesson_exists`, `nothing_reusable` or `conflict_noop`, `llm_failed`, `validation_failed`, `quality_rejected`, `review_needed`) |
199
+ | `distill_invoked` | Distill phase inside the `akm improve`/`akm proposal new` pipeline. **`akm distill` is not a CLI command** — there is no standalone verb by that name | `ref`, outcome (`queued`, `skipped` with a `skipReason` such as `lesson_exists`, `nothing_reusable` or `conflict_noop`, `llm_failed`, `validation_failed`, `quality_rejected`, `review_needed`). A `quality_rejected` or `review_needed` event also carries `score`, `reason`, the judge's per-criterion `criteria` when it ran, and `rejectedContent`, the turned-away lesson text cut to 2000 characters (the judge prompt's cap), kept locally in `state.db` |
200
200
  | `extract_invoked` | `akm proposal extract --type <harness>` / `--auto`, or improve-stage session extraction | `outcome`, `sessionId`, `harness` |
201
201
  | `extract_triaged` | The pre-LLM extract triage gate evaluated at least one session | `evaluated`, `passed`, `triagedOut`, `sourceRun` (aggregated) |
202
202
  | `schema_repair_invoked` | The schema-repair pass inside `akm improve` (`runSchemaRepairPass`) attempts to patch missing frontmatter on an asset that failed schema validation. **There is no `akm lint --repair` flag** — `lint` has `--fix`/`--auto-fix`, unrelated to this event | `ref`, outcome |
@@ -313,6 +313,7 @@ Contents:
313
313
  - Source (which process generated it — e.g. `reflect`, `distill`)
314
314
  - Full proposal content (Markdown text)
315
315
  - Created/updated timestamps
316
+ - For a `consolidate-pair` retire proposal, the pair judge's verdict in its `retirement` metadata: label, reason, cosine, and the claims only the retired side holds (`onlyInRetired`) and only the kept side holds (`onlyInSuccessor`), each at most 20 entries of 200 characters
316
317
 
317
318
  Beside it, the `improve_ledger` table records what each improve stage last did
318
319
  with each asset — one row per bundle, asset ref and stage: the outcome
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.28-alpha.2",
3
+ "version": "0.9.28-alpha.4",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [