akm-cli 0.8.0-rc.7 → 0.8.0-rc.8

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,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-05-28
10
+
11
+ ### Fixed
12
+
13
+ - **Consolidation `delete_failed` on stale index entries** — when consolidation
14
+ successfully deleted a memory file, the index DB was not re-indexed between
15
+ runs. Subsequent runs loaded the stale DB entry into their memory map, the LLM
16
+ re-proposed the deletion, and `deleteAssetFromSource` threw "not found in
17
+ source" — appearing as `delete_failed` in skipReasons. Fix: `loadMemoriesForSource`
18
+ now filters entries whose file no longer exists on disk before building chunks,
19
+ so phantom memories are never sent to the LLM. A secondary catch in the delete
20
+ handler emits `delete_already_gone` instead of `delete_failed` when the file
21
+ is confirmed absent.
22
+
9
23
  > **CI / Docker users:** the 0.8.0 storage split moved `akm.lock`, the event
10
24
  > database, and the registry cache out of `$XDG_CONFIG_HOME/akm/` into
11
25
  > `$XDG_DATA_HOME`, `$XDG_STATE_HOME`, and `$XDG_CACHE_HOME` respectively. If
@@ -65,6 +79,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
65
79
 
66
80
  ### Changed
67
81
 
82
+ - **Rebrand**: the full name "Agent Kit Manager" is now **Agent Knowledge Management** — `akm` stands for Agent Knowledge Management going forward. The binary name, npm package (`akm-cli`), and all APIs remain unchanged.
83
+
68
84
  - **Config layer rewrite** — single-source-of-truth Zod schema in
69
85
  `src/core/config-schema.ts` replaces the per-field parse switch AND
70
86
  the per-shape load-time parser. Adding a new config field is now one
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # akm -- Agent Kit Manager
1
+ # akm -- Agent Knowledge Management
2
2
 
3
- > **akm** (Agent Kit Manager) -- A package manager for AI agent skills, commands, tools, and knowledge.
3
+ > **akm** (Agent Knowledge Management) -- A package manager for AI agent skills, commands, tools, and knowledge.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/akm-cli)](https://www.npmjs.com/package/akm-cli)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/akm-cli)](https://www.npmjs.com/package/akm-cli)
package/dist/cli.js CHANGED
@@ -3175,7 +3175,7 @@ const main = defineCommand({
3175
3175
  meta: {
3176
3176
  name: "akm",
3177
3177
  version: pkgVersion,
3178
- description: "Agent Kit Manager — search, show, and manage assets from your stash.",
3178
+ description: "Agent Knowledge Management — search, show, and manage assets from your stash.",
3179
3179
  },
3180
3180
  args: {
3181
3181
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
@@ -452,7 +452,11 @@ export function mergePlans(chunks) {
452
452
  mergeOps.set(op.primary, op);
453
453
  }
454
454
  else if (op.op === "delete") {
455
- if (!mergeOps.has(op.ref)) {
455
+ // merge and promote both win over delete. A promote is non-destructive
456
+ // (creates a proposal) but the source memory is counted in `promoted`;
457
+ // if a delete also fires, the ref lands in both `promoted` and
458
+ // `skipReasons`, breaking the invariant by +1.
459
+ if (!mergeOps.has(op.ref) && !promoteOps.has(op.ref)) {
456
460
  deleteOps.set(op.ref, op);
457
461
  }
458
462
  }
@@ -475,6 +479,44 @@ export function mergePlans(chunks) {
475
479
  }
476
480
  }
477
481
  }
482
+ // Second pass: enforce merge-wins-over-delete and deduplicate secondaries.
483
+ //
484
+ // 1. Delete/secondary ordering bug: the per-chunk loop removes delete ops
485
+ // for secondaries that were already in deleteOps, but misses the case
486
+ // where the delete chunk came first. A full sweep here fixes both orders.
487
+ //
488
+ // 2. Cross-merge secondary dedup: if ref A is a secondary in two merge ops,
489
+ // only the first (insertion-order) retains it. Without this, a successful
490
+ // merge credits A to mergedSecondaries and a later merge's emitMerge-
491
+ // FailureSkips also charges A to skipReasons — double-counting A while
492
+ // processed has it only once.
493
+ //
494
+ // 3. Primary-as-secondary dedup: if ref A is a primary in one merge op and
495
+ // a secondary in another, remove A from the secondary list. Both merges
496
+ // would otherwise claim A (merged++ for A, then mergedSecondaries++ for A)
497
+ // breaking the invariant the same way.
498
+ // Also remove delete ops for any ref claimed by a promote op (handles the
499
+ // case where the delete chunk appeared before the promote chunk).
500
+ for (const ref of promoteOps.keys()) {
501
+ deleteOps.delete(ref);
502
+ }
503
+ const claimedSecondaries = new Set();
504
+ for (const mergeOp of mergeOps.values()) {
505
+ deleteOps.delete(mergeOp.primary);
506
+ mergeOp.secondaries = mergeOp.secondaries.filter((sec) => {
507
+ if (mergeOps.has(sec)) {
508
+ warnings.push(`Merge: secondary ${sec} is also a merge primary — removing from secondary list to avoid double-count.`);
509
+ return false;
510
+ }
511
+ if (claimedSecondaries.has(sec)) {
512
+ warnings.push(`Merge: secondary ${sec} appears in multiple merge ops — retaining in first op only.`);
513
+ return false;
514
+ }
515
+ claimedSecondaries.add(sec);
516
+ deleteOps.delete(sec);
517
+ return true;
518
+ });
519
+ }
478
520
  // C-2 / #381: promote ops are ordered BEFORE merge ops so that the
479
521
  // human-gated proposal queue entry is created before any destructive merge.
480
522
  // Phase B processes ops in array order, so promote executes first.
@@ -773,6 +815,11 @@ export async function akmConsolidate(opts = {}) {
773
815
  // health rollup can aggregate without regex-parsing English warning
774
816
  // strings. See `/tmp/akm-health-investigations/tuning-reasons-investigation.md` §Q2.
775
817
  const skipReasons = [];
818
+ // Tracks refs already emitted to skipReasons. A ref can only occupy one
819
+ // accounting bucket; subsequent skip ops for the same ref are recorded as
820
+ // warnings but must not push a second skipReasons entry (that would inflate
821
+ // Σ(skipReasons) and break the invariant by +1 per duplicate).
822
+ const skipReasonEmittedRefs = new Set();
776
823
  const pushSkipReason = (op, ref, reason) => {
777
824
  // 2026-05-27 cross-chunk double-count fix: if `ref` already contributed
778
825
  // to judgedNoAction in its own chunk (a different chunk proposed an op
@@ -782,6 +829,13 @@ export async function akmConsolidate(opts = {}) {
782
829
  // Σ(skipReasons) + failedChunkMemories.
783
830
  if (judgedNoActionRefs.delete(ref))
784
831
  judgedNoAction--;
832
+ if (skipReasonEmittedRefs.has(ref)) {
833
+ // Already counted once. Record the extra skip for observability but
834
+ // don't push to skipReasons — that would break the accounting invariant.
835
+ warnings.push(`Skip: ${ref} already in skipReasons (${reason} via ${op}); not re-counted.`);
836
+ return;
837
+ }
838
+ skipReasonEmittedRefs.add(ref);
785
839
  skipReasons.push({ op, ref, reason });
786
840
  };
787
841
  // judgedNoAction tracks memories the LLM saw inside a chunk but proposed
@@ -1173,12 +1227,10 @@ export async function akmConsolidate(opts = {}) {
1173
1227
  const entry = memoryByRef.get(op.ref);
1174
1228
  if (!entry) {
1175
1229
  warnings.push(`Delete: ${op.ref} not found in loaded memories — skipping.`);
1176
- // Accounting impact only if op.ref happens to be a real in-chunk
1177
- // memory; for phantom refs (the typical case) the chunk's targetRefs
1178
- // gained the ref but no chunk member matched it, so judgedNoAction
1179
- // is unaffected and this skipReason is a no-op for the invariant.
1180
- // Emit unconditionally for visibility.
1181
- pushSkipReason("delete", op.ref, "delete_ref_missing");
1230
+ // Phantom ref: not in the batch so not in processed. Pushing to
1231
+ // skipReasons would inflate Σ(skipReasons) without a matching processed
1232
+ // entry, breaking the accounting invariant. Visibility is preserved via
1233
+ // the warnings array above.
1182
1234
  continue;
1183
1235
  }
1184
1236
  // captureMode:hot guard — refuse to delete user-captured memories OR
@@ -1205,15 +1257,26 @@ export async function akmConsolidate(opts = {}) {
1205
1257
  deleted++;
1206
1258
  }
1207
1259
  catch (e) {
1208
- warnings.push(`Delete: failed for ${op.ref}: ${String(e)}`);
1209
- pushSkipReason("delete", op.ref, "delete_failed");
1260
+ // Distinguish "file already absent" from genuine failures. A prior run
1261
+ // may have deleted the file but the DB was not yet re-indexed, so the
1262
+ // ref still appeared in memoryByRef. The delete goal is already met.
1263
+ const msg = e instanceof Error ? e.message : String(e);
1264
+ if (msg.includes("not found in source")) {
1265
+ warnings.push(`Delete: ${op.ref} — file already absent (stale DB entry); skipping.`);
1266
+ pushSkipReason("delete", op.ref, "delete_already_gone");
1267
+ }
1268
+ else {
1269
+ warnings.push(`Delete: failed for ${op.ref}: ${String(e)}`);
1270
+ pushSkipReason("delete", op.ref, "delete_failed");
1271
+ }
1210
1272
  }
1211
1273
  }
1212
1274
  else if (op.op === "promote") {
1213
1275
  const entry = memoryByRef.get(op.ref);
1214
1276
  if (!entry) {
1215
1277
  warnings.push(`Promote: ${op.ref} not found in loaded memories — skipping.`);
1216
- pushSkipReason("promote", op.ref, "promote_ref_missing");
1278
+ // Phantom ref: not in processed, so no skipReason (same rationale as
1279
+ // delete_ref_missing above).
1217
1280
  continue;
1218
1281
  }
1219
1282
  // Within-run source-ref dedup: skip if this source memory was already
@@ -1396,11 +1459,14 @@ export async function akmConsolidate(opts = {}) {
1396
1459
  const contradictorEntry = memoryByRef.get(op.contradictedByRef);
1397
1460
  if (!entry) {
1398
1461
  warnings.push(`Contradict: ${op.ref} not found in loaded memories — skipping.`);
1399
- pushSkipReason("contradict", op.ref, "contradict_ref_missing");
1462
+ // Phantom ref: not in processed, so no skipReason (same rationale as
1463
+ // delete_ref_missing).
1400
1464
  continue;
1401
1465
  }
1402
1466
  if (!contradictorEntry) {
1403
1467
  warnings.push(`Contradict: ${op.contradictedByRef} not found — skipping.`);
1468
+ // op.ref IS in the batch (entry found above) so the skipReason is
1469
+ // correctly charged against a real processed memory.
1404
1470
  pushSkipReason("contradict", op.ref, "contradict_target_missing");
1405
1471
  continue;
1406
1472
  }
@@ -1769,6 +1835,12 @@ function loadMemoriesForSource(source, stashDir, warnings) {
1769
1835
  return path.resolve(e.stashDir) === path.resolve(source);
1770
1836
  })
1771
1837
  .filter((e) => isConsolidationEligibleMemoryName(e.entry.name))
1838
+ // Skip stale DB entries whose file was deleted by a prior run but not yet
1839
+ // re-indexed. Without this guard the deleted file's ref appears in chunks
1840
+ // sent to the LLM, which then proposes a second delete → delete_failed
1841
+ // because the file is already gone. Re-indexing runs on a cron cadence so
1842
+ // several successful deletes can accumulate before the DB catches up.
1843
+ .filter((e) => fs.existsSync(e.filePath))
1772
1844
  .map((e) => ({
1773
1845
  name: e.entry.name,
1774
1846
  filePath: e.filePath,
@@ -704,6 +704,14 @@ function computeWallTimeStats(durationsMs, byPhase) {
704
704
  byPhase: phase,
705
705
  };
706
706
  }
707
+ /**
708
+ * NOTE: this reads from task_history, which can produce a count that differs
709
+ * by ±1 from improve_runs (the source for wallTime.byPhase). The discrepancy
710
+ * occurs when a task_history row has no matching improve_runs record (task
711
+ * crashed before recordImproveRun wrote) or vice versa (manual run). The
712
+ * count mismatch is cosmetic — it does not affect median/p95 materially.
713
+ * A full fix requires joining against improve_runs; tracked as a follow-up.
714
+ */
707
715
  function collectImproveWallTimes(db, since, until) {
708
716
  const sql = until
709
717
  ? "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND started_at < ? AND completed_at IS NOT NULL"
@@ -127,6 +127,9 @@ export const ImproveProcessConfigSchema = z
127
127
  allowedTypes: z.array(z.string().min(1)).optional(),
128
128
  qualityGate: z.object({ enabled: z.boolean().optional() }).strict().optional(),
129
129
  contradictionDetection: z.object({ enabled: z.boolean().optional() }).strict().optional(),
130
+ // Extract process config (only meaningful for extract process)
131
+ defaultSince: z.string().min(1).optional(),
132
+ maxTotalChars: positiveInt.optional(),
130
133
  })
131
134
  .strict();
132
135
  const ImproveProfileProcessesSchema = z
@@ -1033,7 +1033,7 @@ export function shouldSkipAlreadyExtractedSession(prior, liveSessionEndedAtMs) {
1033
1033
  * created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
1034
1034
  * ensureSchema() or as a standalone migration.
1035
1035
  *
1036
- * Purpose: caches the result of resolving and fetching remote registry kit
1036
+ * Purpose: caches the result of resolving and fetching remote registry stash
1037
1037
  * indexes so `akm search` does not hit the network on every invocation.
1038
1038
  *
1039
1039
  * Indexed (query) columns:
@@ -7,7 +7,7 @@
7
7
  * Maps registry provider type identifiers (e.g. "static-index", "skills-sh")
8
8
  * to factory functions that create RegistryProvider instances.
9
9
  *
10
- * "Registry" here refers to the kit discovery registries (static index files,
10
+ * "Registry" here refers to the stash discovery registries (static index files,
11
11
  * skills.sh API) — not to be confused with the source provider factory map in
12
12
  * `sources/provider-factory.ts` or the installed-source operations in
13
13
  * `installed-stashes.ts`.
@@ -64,7 +64,7 @@ class SkillsShProvider {
64
64
  /**
65
65
  * skills.sh has no `getKit` API — every entry corresponds to a GitHub
66
66
  * repository whose metadata we already include in the search result. We
67
- * synthesize a manifest from the search hit when the caller knows the kit
67
+ * synthesize a manifest from the search hit when the caller knows the stash
68
68
  * id; if not present in the most recent results, return null.
69
69
  */
70
70
  async getKit(id) {
@@ -14436,7 +14436,9 @@ var init_config_schema = __esm(() => {
14436
14436
  timeoutMs: exports_external.union([positiveInt, exports_external.null()]).optional(),
14437
14437
  allowedTypes: exports_external.array(exports_external.string().min(1)).optional(),
14438
14438
  qualityGate: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
14439
- contradictionDetection: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional()
14439
+ contradictionDetection: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
14440
+ defaultSince: exports_external.string().min(1).optional(),
14441
+ maxTotalChars: positiveInt.optional()
14440
14442
  }).strict();
14441
14443
  ImproveProfileProcessesSchema = exports_external.object({
14442
14444
  reflect: ImproveProcessConfigSchema.optional(),
@@ -18,6 +18,13 @@ import { applyAkmIncludeConfig, buildInstallCacheDir, copyDirectoryContents, det
18
18
  const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
19
19
  /** Maximum stale age allowed when refresh fails (7 days). */
20
20
  const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
21
+ function runGit(args, options) {
22
+ return spawnSync("git", args, {
23
+ encoding: "utf8",
24
+ ...options,
25
+ env: { ...process.env, ...options?.env, GIT_TERMINAL_PROMPT: "0" },
26
+ });
27
+ }
21
28
  /**
22
29
  * Git source provider — clones (and re-pulls) a remote repo into a local
23
30
  * cache directory. Implements the v1 {@link SourceProvider} interface (spec
@@ -221,7 +228,7 @@ async function doSyncGit(parsed, options) {
221
228
  cloneArgs.push("--branch", parsed.requestedRef);
222
229
  }
223
230
  cloneArgs.push(parsed.url, cloneDir);
224
- const cloneResult = spawnSync("git", cloneArgs, { encoding: "utf8", timeout: 120_000 });
231
+ const cloneResult = runGit(cloneArgs, { timeout: 120_000 });
225
232
  if (cloneResult.status !== 0) {
226
233
  throw new Error(classifyCloneFailure(parsed.url, cloneResult.stderr, cloneResult.error));
227
234
  }
@@ -268,7 +275,7 @@ export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
268
275
  if (ref)
269
276
  args.push("--branch", ref);
270
277
  args.push(cloneUrl, tmpDir);
271
- const result = spawnSync("git", args, { encoding: "utf8", timeout: 120_000 });
278
+ const result = runGit(args, { timeout: 120_000 });
272
279
  if (result.status !== 0) {
273
280
  // Clean up the (possibly partial) temp dir but leave destDir untouched.
274
281
  fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -293,8 +300,7 @@ export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
293
300
  }
294
301
  }
295
302
  function pullRepo(repoDir) {
296
- const result = spawnSync("git", ["-C", repoDir, "pull", "--ff-only"], {
297
- encoding: "utf8",
303
+ const result = runGit(["-C", repoDir, "pull", "--ff-only"], {
298
304
  timeout: 120_000,
299
305
  });
300
306
  if (result.status !== 0) {
@@ -431,7 +437,7 @@ export function saveGitStash(name, message, writableOverride) {
431
437
  return { committed: false, pushed: false, skipped: true, reason: "not a git repository", output: "" };
432
438
  }
433
439
  // Nothing to commit?
434
- const statusResult = spawnSync("git", ["-C", repoDir, "status", "--porcelain"], { encoding: "utf8" });
440
+ const statusResult = runGit(["-C", repoDir, "status", "--porcelain"]);
435
441
  if (statusResult.error || statusResult.status !== 0) {
436
442
  throw new Error(`git status failed: ${statusResult.error?.message || statusResult.stderr?.trim() || "unknown error"}`);
437
443
  }
@@ -456,16 +462,26 @@ export function saveGitStash(name, message, writableOverride) {
456
462
  // Stage and commit — supply fallback identity so fresh environments without
457
463
  // user.name/user.email configured can always commit to the default stash.
458
464
  // `add -A` is safe here because nonAkmDirty was just verified empty.
459
- const addResult = spawnSync("git", ["-C", repoDir, "add", "-A"], { encoding: "utf8" });
465
+ const addResult = runGit(["-C", repoDir, "add", "-A"]);
460
466
  if (addResult.status !== 0) {
461
467
  throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
462
468
  }
463
- const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", commitMessage], { encoding: "utf8" });
469
+ const commitResult = runGit([
470
+ "-C",
471
+ repoDir,
472
+ "-c",
473
+ "user.name=akm",
474
+ "-c",
475
+ "user.email=akm@local",
476
+ "commit",
477
+ "-m",
478
+ commitMessage,
479
+ ]);
464
480
  if (commitResult.status !== 0) {
465
481
  throw new Error(`git commit failed: ${commitResult.stderr?.trim() || "unknown error"}`);
466
482
  }
467
483
  // Push only when there is a remote AND the stash is marked writable
468
- const remoteResult = spawnSync("git", ["-C", repoDir, "remote"], { encoding: "utf8" });
484
+ const remoteResult = runGit(["-C", repoDir, "remote"]);
469
485
  if (remoteResult.status !== 0) {
470
486
  throw new Error(`git remote failed: ${remoteResult.stderr?.trim() || "unknown error"}`);
471
487
  }
@@ -473,7 +489,7 @@ export function saveGitStash(name, message, writableOverride) {
473
489
  if (!hasRemote || !writable) {
474
490
  return { committed: true, pushed: false, skipped: false, output: commitResult.stdout.trim() };
475
491
  }
476
- const pushResult = spawnSync("git", ["-C", repoDir, "push"], { encoding: "utf8", timeout: 120_000 });
492
+ const pushResult = runGit(["-C", repoDir, "push"], { timeout: 120_000 });
477
493
  if (pushResult.status !== 0) {
478
494
  throw new Error(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`);
479
495
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.8.0-rc.7",
3
+ "version": "0.8.0-rc.8",
4
4
  "type": "module",
5
- "description": "akm (Agent Kit Manager) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
5
+ "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [
7
7
  "akm",
8
+ "agent-knowledge-management",
8
9
  "agent-kit-manager",
9
10
  "akm-cli",
10
11
  "ai-agent",
@@ -53,9 +54,9 @@
53
54
  "build": "rm -rf dist && bun run tsc --project ./tsconfig.build.json && bun scripts/copy-assets.ts",
54
55
  "check": "bun run lint && bunx tsc --noEmit && bun run test:unit && bun run test:integration",
55
56
  "check:changed": "bun test tests/output-baseline.test.ts tests/integration/e2e.test.ts tests/stash-search.test.ts && bun run lint && bunx tsc --noEmit",
56
- "test": "bun test --parallel=12 --timeout=15000 ./tests",
57
- "test:unit": "bun test --parallel=12 --timeout=15000 ./tests --path-ignore-patterns=tests/integration",
58
- "test:integration": "bun test --parallel=12 --timeout=15000 ./tests/integration ./tests/commands ./tests/workflows",
57
+ "test": "bun test --parallel=12 --timeout=30000 ./tests",
58
+ "test:unit": "bun test --parallel=12 --timeout=30000 ./tests --path-ignore-patterns=tests/integration",
59
+ "test:integration": "bun test --parallel=12 --timeout=30000 ./tests/integration ./tests/commands ./tests/workflows",
59
60
  "test:sharded": "bun test ./tests --shard=1/4 & bun test ./tests --shard=2/4 & bun test ./tests --shard=3/4 & bun test ./tests --shard=4/4 & wait",
60
61
  "test:time": "bun scripts/test-timing-report.ts",
61
62
  "lint:isolation": "bun scripts/lint-tests-isolation.ts",