akm-cli 0.9.28-alpha.10 → 0.9.28-alpha.11

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,29 @@ 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.11] - 2026-10-08
10
+
11
+ ### Fixed
12
+
13
+ - **Skill resources that document `$ARGUMENTS` command templates no longer index as commands.** The command
14
+ placeholder matcher now respects a skill folder as declared context, and the indexer re-files existing entries
15
+ on the next incremental index (#1084).
16
+ - **A proposal drain judge now sees overlapping promotions accepted earlier in the same drain (#1085).**
17
+ - **`akm proposal drain --strategy <name>` now honors enabled triage judgment.** A named strategy with
18
+ `processes.triage.judgment.enabled: true` runs its configured judge without `--judgment`; the flag still
19
+ forces judgment for disabled strategies, and drains without `--strategy` keep their existing behavior (#1086).
20
+ - **`akm upgrade` no longer defers the OpenCode plugin refresh for OpenCode servers running in containers
21
+ (#1099).** The "is OpenCode running" check counted an `opencode serve` inside a Docker container, which
22
+ keeps its own cache, so the host's refresh was deferred for as long as the container ran. On Linux a matching
23
+ process now counts only when it shares akm's mount namespace (or, where that is unreadable, when its cgroup
24
+ is not a docker/containerd/podman/lxc scope); a process that cannot be inspected still defers.
25
+ - **Five test suites no longer leak their temp folders on a direct `bun test` (#1088).** The outcome-loop,
26
+ outcome-invariance, memory-improve-archive, plan-flag-cli and require-engines-cli suites created `akm-*`
27
+ directories under the OS temp dir and never removed them; `scripts/sweep-test-tmp.ts` swept them only for
28
+ `test-unit.sh`/`test-integration.sh` runs, so running a file straight with `bun test` left a folder per test
29
+ behind. Each suite now registers its temp dirs with the shared test sandbox helper (`makeSandboxDir`, drained
30
+ by an `afterEach`), the pattern the other suites already use.
31
+
9
32
  ## [0.9.28-alpha.10] - 2026-10-08
10
33
 
11
34
  ### Fixed
@@ -125,7 +125,7 @@ export function findCoveringKnowledge(db, bundleId, filePath, body) {
125
125
  }
126
126
  /** Knowledge docs a reviewer is shown for a promotion, nearest first. */
127
127
  export const NEIGHBOUR_NOTE_COUNT = 5;
128
- const NEIGHBOUR_EXCERPT_CHARS = 300;
128
+ export const NEIGHBOUR_EXCERPT_CHARS = 300;
129
129
  /**
130
130
  * The knowledge notes nearest to the memory at `memoryPath`, for the drain's
131
131
  * judge to compare a promotion against: the nearest {@link NEIGHBOUR_NOTE_COUNT}
@@ -28,7 +28,7 @@ import { info, warn } from "../../core/warn.js";
28
28
  import { DEFAULT_LLM_TIMEOUT_MS } from "../../integrations/agent/config.js";
29
29
  import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
30
30
  import { assertRunnerCredentials, runExecution, } from "../../integrations/agent/runner-dispatch.js";
31
- import { nearestKnowledgeNotes } from "../improve/consolidate/coverage.js";
31
+ import { NEIGHBOUR_EXCERPT_CHARS, NEIGHBOUR_NOTE_COUNT, nearestKnowledgeNotes, shingleContainment, wordShingles, } from "../improve/consolidate/coverage.js";
32
32
  import { errMessage, noticeSet } from "../improve/stage.js";
33
33
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
34
34
  import { isRetireProposal, PAIR_PASS_GATE, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
@@ -242,6 +242,7 @@ async function runJudgmentTier(opts, result, pending, acceptBudget, promoteFn, r
242
242
  const byId = new Map(pending.map((p) => [p.id, p]));
243
243
  const notices = noticeSet();
244
244
  const stillDeferred = [];
245
+ const acceptedPromotions = [];
245
246
  const cappedBefore = result.skippedByCap.length;
246
247
  for (const item of result.deferred) {
247
248
  const proposal = byId.get(item.id);
@@ -254,7 +255,9 @@ async function runJudgmentTier(opts, result, pending, acceptBudget, promoteFn, r
254
255
  liveAsset,
255
256
  siblings: pending.filter((p) => p.ref === proposal.ref && p.id !== proposal.id),
256
257
  // A create the model otherwise judges blind: the model never sees knowledge/.
257
- ...(liveAsset === undefined ? { neighbours: promotionNeighbours(opts.stashDir, proposal) } : {}),
258
+ ...(liveAsset === undefined
259
+ ? { neighbours: promotionNeighbours(opts.stashDir, proposal, acceptedPromotions) }
260
+ : {}),
258
261
  });
259
262
  const dispatch = await dispatchJudgment(opts.judgment, prompt, seams);
260
263
  notices.add(dispatch.notices);
@@ -314,6 +317,8 @@ async function runJudgmentTier(opts, result, pending, acceptBudget, promoteFn, r
314
317
  const outcome = await acceptProposal(opts, proposal, item.id, "judgment-accept", promoteFn, rejectFn, verdict.reason);
315
318
  if (outcome === "promoted") {
316
319
  result.promoted.push(item.id);
320
+ if (proposal.source === "consolidate" && proposal.promotionSource !== undefined)
321
+ acceptedPromotions.push(proposal);
317
322
  acceptBudget -= 1;
318
323
  }
319
324
  else if (outcome === "rejected") {
@@ -333,7 +338,7 @@ async function runJudgmentTier(opts, result, pending, acceptBudget, promoteFn, r
333
338
  result.deferred = stillDeferred;
334
339
  }
335
340
  /** The knowledge notes nearest to a consolidate promotion's source memory; none for any other proposal. */
336
- function promotionNeighbours(stashDir, proposal) {
341
+ function promotionNeighbours(stashDir, proposal, acceptedPromotions) {
337
342
  if (proposal.source !== "consolidate" || proposal.promotionSource === undefined)
338
343
  return [];
339
344
  try {
@@ -341,7 +346,27 @@ function promotionNeighbours(stashDir, proposal) {
341
346
  const typeDir = stashDirFor(parsed.type);
342
347
  if (!typeDir)
343
348
  return [];
344
- return nearestKnowledgeNotes(assetPathForName(parsed.type, path.join(stashDir, typeDir), parsed.name));
349
+ const memoryPath = assetPathForName(parsed.type, path.join(stashDir, typeDir), parsed.name);
350
+ const existing = nearestKnowledgeNotes(memoryPath);
351
+ const source = fs.readFileSync(memoryPath, "utf8");
352
+ const shingles = wordShingles(source);
353
+ // Promotions accepted earlier in this drain are not in the index yet; the
354
+ // ones sharing text with this memory join its neighbours (#1085).
355
+ const inDrain = acceptedPromotions
356
+ .map((accepted) => {
357
+ const parsedContent = parseFrontmatter(proposalContent(accepted));
358
+ const body = parsedContent.content;
359
+ return {
360
+ ref: accepted.ref,
361
+ description: typeof parsedContent.data.description === "string" ? parsedContent.data.description : "",
362
+ excerpt: body.length > NEIGHBOUR_EXCERPT_CHARS ? `${body.slice(0, NEIGHBOUR_EXCERPT_CHARS)}...` : body,
363
+ containment: shingleContainment(shingles, body),
364
+ };
365
+ })
366
+ .filter((note) => note.containment > 0)
367
+ .sort((a, b) => b.containment - a.containment)
368
+ .map(({ containment: _, ...note }) => note);
369
+ return [...inDrain, ...existing].slice(0, NEIGHBOUR_NOTE_COUNT);
345
370
  }
346
371
  catch {
347
372
  return [];
@@ -401,12 +401,12 @@ const proposalDrainCommand = defineJsonCommand({
401
401
  },
402
402
  judgment: {
403
403
  type: "boolean",
404
- description: "Explicitly enable the judgment tier for this drain (overrides judgment.enabled=false; agent/sdk per config). No-op with a logged triage_deferred summary when no runner is configured.",
404
+ description: "Enable the judgment tier for this drain (overrides judgment.enabled=false; agent/sdk per config). No-op with a logged triage_deferred summary when no runner is configured.",
405
405
  default: false,
406
406
  },
407
407
  strategy: {
408
408
  type: "string",
409
- description: "Read the triage block (applyMode, ceilings, judgment) from this improve strategy.",
409
+ description: "Read the triage block (applyMode, ceilings, judgment) from this improve strategy; enabled judgment runs the judge.",
410
410
  },
411
411
  },
412
412
  async run({ args, rawArgs }) {
@@ -455,12 +455,11 @@ const proposalDrainCommand = defineJsonCommand({
455
455
  })
456
456
  .map((proposal) => proposal.id));
457
457
  }
458
- // Phase 3: --judgment is an invocation-level opt-in. It deliberately
459
- // overrides judgment.enabled=false for this standalone drain while still
460
- // reusing that block's execution overrides. Without the flag, configured
461
- // judgment enablement is owned only by `akm improve`. A missing runner is
462
- // a documented standalone no-op that leaves deferred items unresolved.
463
- const judgmentResolution = args.judgment === true
458
+ // Phase 3: --judgment is an invocation-level opt-in that overrides a
459
+ // disabled block. An explicitly selected strategy may opt in through its
460
+ // judgment block; the implicit default strategy keeps standalone drain's
461
+ // historical no-judge behavior.
462
+ const judgmentResolution = args.judgment === true || (args.strategy !== undefined && triageConfig?.judgment?.enabled === true)
464
463
  ? resolveImproveExecution({
465
464
  config: cfg,
466
465
  profile: selectedStrategy.config,
@@ -237,21 +237,43 @@ export function lookupOpenCodeNext() {
237
237
  }
238
238
  return next;
239
239
  }
240
+ const CONTAINER_CGROUP = /docker|containerd|podman|libpod|lxc/;
241
+ /**
242
+ * Whether the process could use this host's OpenCode cache. A process in another mount namespace (a container's
243
+ * `opencode serve`) has its own cache; when that cannot be read, a container cgroup says the same. Anything
244
+ * unreadable counts as a host process, so the answer errs towards deferring.
245
+ */
246
+ export function usesHostCache(pid, procRoot = "/proc") {
247
+ try {
248
+ return fs.readlinkSync(`${procRoot}/${pid}/ns/mnt`) === fs.readlinkSync(`${procRoot}/self/ns/mnt`);
249
+ }
250
+ catch {
251
+ // not readable (another user's process, or no such namespace file): look at the cgroup instead
252
+ }
253
+ try {
254
+ return !CONTAINER_CGROUP.test(fs.readFileSync(`${procRoot}/${pid}/cgroup`, "utf8"));
255
+ }
256
+ catch {
257
+ return true;
258
+ }
259
+ }
240
260
  /** Whether an OpenCode process is running (its prefetch would be replaced under it). `undefined` when that cannot be told. */
241
261
  function openCodeRunning() {
242
262
  if (IS_WINDOWS) {
243
263
  const tasks = runCommand("tasklist", ["/FI", "IMAGENAME eq opencode.exe", "/NH"], READ_TIMEOUT_MS);
244
264
  return tasks.ok ? /opencode\.exe/i.test(tasks.stdout) : undefined;
245
265
  }
266
+ const linux = process.platform === "linux";
246
267
  // `-f` because a Node-wrapped opencode shows up as `node …/opencode`; the
247
268
  // pattern needs `opencode` to be a whole path segment so `akm-opencode`
248
269
  // in some other command's arguments does not match.
249
270
  const pgrep = runCommand("pgrep", ["-f", "(^|/)opencode( |$)"], READ_TIMEOUT_MS);
250
- if (pgrep.ok)
251
- return true;
271
+ if (pgrep.ok) {
272
+ return !linux || pgrep.stdout.split(/\s+/).some((pid) => /^\d+$/.test(pid) && usesHostCache(pid));
273
+ }
252
274
  if (!pgrep.missing)
253
275
  return false; // pgrep exits 1 when nothing matched
254
- if (process.platform !== "linux")
276
+ if (!linux)
255
277
  return undefined;
256
278
  try {
257
279
  return fs.readdirSync("/proc").some((pid) => {
@@ -259,7 +281,7 @@ function openCodeRunning() {
259
281
  return false;
260
282
  try {
261
283
  const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
262
- return argv.slice(0, 2).some((arg) => path.basename(arg) === "opencode");
284
+ return argv.slice(0, 2).some((arg) => path.basename(arg) === "opencode") && usesHostCache(pid);
263
285
  }
264
286
  catch {
265
287
  return false;
@@ -457,7 +457,8 @@ export const akmAdapter = {
457
457
  // file that has not changed: the version is folded into each directory's
458
458
  // freshness, so the next incremental `akm index` re-drains it and re-files
459
459
  // those rows (#1063). 0.9.1: a skill's resource is never retyped by a `$1`.
460
- version: "0.9.1",
460
+ // 0.9.2: a skill's resource is never retyped by a `$ARGUMENTS` (#1084).
461
+ version: "0.9.2",
461
462
  // Recognized-extension HINT, derived from what the matchers accept (§6):
462
463
  // `.md` (Markdown types + workflow peer), `.yml` (task and workflow YAML),
463
464
  // `.yaml` (task near-miss diagnostics), `.env` (env files), and the 16
@@ -231,8 +231,10 @@ function classifyBySmartMd(ctx) {
231
231
  }
232
232
  }
233
233
  // `$ARGUMENTS` is unambiguous, so it keeps its long-standing precedence: a
234
- // command dropped under `knowledge/` is still found as a command.
235
- if (ARGUMENTS_PLACEHOLDER_RE.test(body)) {
234
+ // command dropped under `knowledge/` is still found as a command. A skill's
235
+ // folder is the one declared context it defers to — a file there is a skill
236
+ // resource, so documenting a command template must not retype it (#1084).
237
+ if (ARGUMENTS_PLACEHOLDER_RE.test(body) && !isNestedSkillResource(ctx)) {
236
238
  return SMART_MD_FACTS.command;
237
239
  }
238
240
  // A NUMERIC placeholder is a guess, and a typed directory is a declaration.
@@ -40534,7 +40534,7 @@ function classifyBySmartMd(ctx) {
40534
40534
  return SMART_MD_FACTS.command;
40535
40535
  }
40536
40536
  }
40537
- if (ARGUMENTS_PLACEHOLDER_RE.test(body)) {
40537
+ if (ARGUMENTS_PLACEHOLDER_RE.test(body) && !isNestedSkillResource(ctx)) {
40538
40538
  return SMART_MD_FACTS.command;
40539
40539
  }
40540
40540
  if (NUMERIC_PLACEHOLDER_RE.test(body) && !hasDeclaredDirType(ctx)) {
@@ -45095,7 +45095,7 @@ async function validate2(c, changes, ctx) {
45095
45095
  }
45096
45096
  var akmAdapter = {
45097
45097
  id: "akm",
45098
- version: "0.9.1",
45098
+ version: "0.9.2",
45099
45099
  extensions: [
45100
45100
  ".md",
45101
45101
  ".yaml",
@@ -39862,7 +39862,7 @@ function classifyBySmartMd(ctx) {
39862
39862
  return SMART_MD_FACTS.command;
39863
39863
  }
39864
39864
  }
39865
- if (ARGUMENTS_PLACEHOLDER_RE.test(body)) {
39865
+ if (ARGUMENTS_PLACEHOLDER_RE.test(body) && !isNestedSkillResource(ctx)) {
39866
39866
  return SMART_MD_FACTS.command;
39867
39867
  }
39868
39868
  if (NUMERIC_PLACEHOLDER_RE.test(body) && !hasDeclaredDirType(ctx)) {
@@ -44423,7 +44423,7 @@ async function validate2(c, changes, ctx) {
44423
44423
  }
44424
44424
  var akmAdapter = {
44425
44425
  id: "akm",
44426
- version: "0.9.1",
44426
+ version: "0.9.2",
44427
44427
  extensions: [
44428
44428
  ".md",
44429
44429
  ".yaml",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.28-alpha.10",
3
+ "version": "0.9.28-alpha.11",
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": [