akm-cli 0.7.0-rc1 → 0.7.0

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 (83) hide show
  1. package/dist/src/cli.js +100 -16
  2. package/dist/src/commands/config-cli.js +42 -0
  3. package/dist/src/commands/history.js +78 -7
  4. package/dist/src/commands/registry-search.js +69 -6
  5. package/dist/src/commands/search.js +30 -3
  6. package/dist/src/commands/show.js +29 -0
  7. package/dist/src/commands/source-add.js +5 -1
  8. package/dist/src/commands/source-manage.js +7 -1
  9. package/dist/src/core/config.js +28 -0
  10. package/dist/src/indexer/db-search.js +1 -0
  11. package/dist/src/indexer/indexer.js +16 -2
  12. package/dist/src/indexer/matchers.js +1 -1
  13. package/dist/src/indexer/search-source.js +4 -2
  14. package/dist/src/integrations/agent/profiles.js +1 -1
  15. package/dist/src/integrations/agent/spawn.js +67 -16
  16. package/dist/src/integrations/github.js +9 -3
  17. package/dist/src/llm/embedders/remote.js +37 -3
  18. package/dist/src/output/cli-hints.js +15 -2
  19. package/dist/src/output/renderers.js +3 -1
  20. package/dist/src/output/shapes.js +8 -1
  21. package/dist/src/output/text.js +156 -3
  22. package/dist/src/registry/build-index.js +5 -4
  23. package/dist/src/registry/providers/static-index.js +3 -1
  24. package/dist/src/setup/setup.js +9 -0
  25. package/dist/src/wiki/wiki.js +54 -6
  26. package/dist/src/workflows/runs.js +37 -3
  27. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +1 -1
  28. package/dist/tests/bench/attribution.test.js +24 -23
  29. package/dist/tests/bench/cleanup.js +31 -0
  30. package/dist/tests/bench/cli.js +366 -31
  31. package/dist/tests/bench/cli.test.js +282 -14
  32. package/dist/tests/bench/corpus.js +3 -0
  33. package/dist/tests/bench/corpus.test.js +10 -10
  34. package/dist/tests/bench/doctor.js +525 -0
  35. package/dist/tests/bench/driver.js +77 -22
  36. package/dist/tests/bench/driver.test.js +142 -1
  37. package/dist/tests/bench/environment.js +233 -0
  38. package/dist/tests/bench/environment.test.js +199 -0
  39. package/dist/tests/bench/evolve.js +67 -0
  40. package/dist/tests/bench/evolve.test.js +12 -4
  41. package/dist/tests/bench/failure-modes.test.js +52 -3
  42. package/dist/tests/bench/feedback-integrity.test.js +3 -2
  43. package/dist/tests/bench/leakage.test.js +105 -2
  44. package/dist/tests/bench/learning-curve.test.js +3 -2
  45. package/dist/tests/bench/metrics.js +102 -26
  46. package/dist/tests/bench/metrics.test.js +10 -4
  47. package/dist/tests/bench/opencode-config.js +194 -0
  48. package/dist/tests/bench/opencode-config.test.js +370 -0
  49. package/dist/tests/bench/report.js +73 -9
  50. package/dist/tests/bench/report.test.js +59 -10
  51. package/dist/tests/bench/run-config.js +355 -0
  52. package/dist/tests/bench/run-config.test.js +298 -0
  53. package/dist/tests/bench/run-curate-test.js +32 -0
  54. package/dist/tests/bench/run-failing-tasks.js +56 -0
  55. package/dist/tests/bench/run-full-bench.js +51 -0
  56. package/dist/tests/bench/run-items36-targeted.js +69 -0
  57. package/dist/tests/bench/run-nano-quick.js +42 -0
  58. package/dist/tests/bench/run-waveg-targeted.js +62 -0
  59. package/dist/tests/bench/runner.js +257 -94
  60. package/dist/tests/bench/tmp.js +90 -0
  61. package/dist/tests/bench/trajectory.js +2 -2
  62. package/dist/tests/bench/verifier.js +6 -1
  63. package/dist/tests/bench/workflow-spec.js +11 -24
  64. package/dist/tests/bench/workflow-spec.test.js +1 -1
  65. package/dist/tests/bench/workflow-trace.js +34 -0
  66. package/dist/tests/cli-errors.test.js +1 -0
  67. package/dist/tests/commands/history.test.js +195 -0
  68. package/dist/tests/config.test.js +25 -0
  69. package/dist/tests/e2e.test.js +23 -2
  70. package/dist/tests/fixtures/stashes/load.js +1 -1
  71. package/dist/tests/fixtures/stashes/load.test.js +11 -2
  72. package/dist/tests/indexer.test.js +12 -1
  73. package/dist/tests/output-baseline.test.js +2 -1
  74. package/dist/tests/output-shapes-unit.test.js +3 -1
  75. package/dist/tests/registry-build-index.test.js +17 -1
  76. package/dist/tests/registry-providers/static-index.test.js +34 -0
  77. package/dist/tests/registry-search.test.js +200 -0
  78. package/dist/tests/remember-frontmatter.test.js +11 -13
  79. package/dist/tests/source-qa-fixes.test.js +18 -0
  80. package/dist/tests/source-registry.test.js +3 -3
  81. package/dist/tests/source-source.test.js +61 -1
  82. package/dist/tests/workflow-qa-fixes.test.js +18 -0
  83. package/package.json +1 -1
@@ -30,6 +30,27 @@
30
30
  *
31
31
  * NOT in scope: live tailing, persistence, evaluation. Those belong to #256.
32
32
  */
33
+ /** Runtime set of all valid workflow trace event names. Single source of truth. */
34
+ export const WORKFLOW_TRACE_EVENT_NAMES = new Set([
35
+ "agent_started",
36
+ "akm_search",
37
+ "akm_show",
38
+ "akm_feedback",
39
+ "akm_reflect",
40
+ "akm_distill",
41
+ "akm_propose",
42
+ "akm_proposal_accept",
43
+ "akm_workflow_start",
44
+ "akm_workflow_next",
45
+ "akm_workflow_complete",
46
+ "akm_workflow_finish",
47
+ "workspace_read",
48
+ "workspace_write",
49
+ "first_workspace_write",
50
+ "test_run",
51
+ "verifier_run",
52
+ "agent_finished",
53
+ ]);
33
54
  /* ─── Caps (documented contract) ──────────────────────────────────────────── */
34
55
  /** Hard cap on total events per trace. Prevents a noisy run from OOM-ing the harness. */
35
56
  export const MAX_EVENT_COUNT = 4096;
@@ -182,6 +203,9 @@ const AKM_EVENT_TYPE_MAP = {
182
203
  distill_invoked: "akm_distill",
183
204
  propose_invoked: "akm_propose",
184
205
  promoted: "akm_proposal_accept",
206
+ workflow_started: "akm_workflow_start",
207
+ workflow_step_completed: "akm_workflow_complete",
208
+ workflow_finished: "akm_workflow_finish",
185
209
  };
186
210
  function fromAkmEvent(ev, run, options, originalIndex) {
187
211
  if (!ev || typeof ev !== "object")
@@ -354,6 +378,16 @@ function classifyArgs(command, argv) {
354
378
  return { type: "akm_distill", command, args: argv };
355
379
  case "propose":
356
380
  return { type: "akm_propose", command, args: argv };
381
+ case "workflow": {
382
+ const sub = rest[0];
383
+ if (sub === "start")
384
+ return { type: "akm_workflow_start", command, args: argv, assetRef: rest[1] };
385
+ if (sub === "next")
386
+ return { type: "akm_workflow_next", command, args: argv, assetRef: rest[1] };
387
+ if (sub === "complete")
388
+ return { type: "akm_workflow_complete", command, args: argv };
389
+ return null;
390
+ }
357
391
  default:
358
392
  return null;
359
393
  }
@@ -59,6 +59,7 @@ describe("CLI error handling", () => {
59
59
  const parsed = JSON.parse(stderr.trim());
60
60
  expect(parsed.ok).toBe(false);
61
61
  expect(typeof parsed.error).toBe("string");
62
+ expect(parsed.code).toBe("INVALID_FLAG_VALUE");
62
63
  });
63
64
  test("search --source invalid prints hint about source", () => {
64
65
  const { stderr, status } = runCli("search", "test", "--source", "invalid");
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { akmHistory } from "../../src/commands/history";
7
7
  import { saveConfig } from "../../src/core/config";
8
+ import { appendEvent } from "../../src/core/events";
8
9
  import { getDbPath } from "../../src/core/paths";
9
10
  import { closeDatabase, openDatabase } from "../../src/indexer/db";
10
11
  import { akmIndex } from "../../src/indexer/indexer";
@@ -221,3 +222,197 @@ describe("akm history CLI", () => {
221
222
  expect(typeof parsed.error).toBe("string");
222
223
  });
223
224
  });
225
+ describe("akmHistory --include-proposals", () => {
226
+ test("sources field is ['usage_events'] by default", async () => {
227
+ const db = openDatabase(":memory:");
228
+ try {
229
+ ensureUsageEventsSchema(db);
230
+ const result = await akmHistory({ db });
231
+ expect(result.sources).toEqual(["usage_events"]);
232
+ }
233
+ finally {
234
+ closeDatabase(db);
235
+ }
236
+ });
237
+ test("sources field includes events.jsonl when includeProposals is true", async () => {
238
+ const eventsFile = path.join(makeTempDir("akm-history-events-"), "events.jsonl");
239
+ const db = openDatabase(":memory:");
240
+ try {
241
+ ensureUsageEventsSchema(db);
242
+ const result = await akmHistory({
243
+ db,
244
+ includeProposals: true,
245
+ eventsCtx: { filePath: eventsFile },
246
+ });
247
+ expect(result.sources).toEqual(["usage_events", "events.jsonl"]);
248
+ }
249
+ finally {
250
+ closeDatabase(db);
251
+ }
252
+ });
253
+ test("proposal accept event (promoted) appears in history with --include-proposals", async () => {
254
+ const eventsFile = path.join(makeTempDir("akm-history-proposal-"), "events.jsonl");
255
+ const db = openDatabase(":memory:");
256
+ try {
257
+ ensureUsageEventsSchema(db);
258
+ // Simulate `akm proposal accept` emitting a promoted event.
259
+ appendEvent({
260
+ eventType: "promoted",
261
+ ref: "skill:deploy",
262
+ metadata: { proposalId: "prop-001", source: "reflect", assetPath: "/stash/skills/deploy.md" },
263
+ }, { filePath: eventsFile });
264
+ const result = await akmHistory({
265
+ db,
266
+ includeProposals: true,
267
+ eventsCtx: { filePath: eventsFile },
268
+ });
269
+ expect(result.totalCount).toBe(1);
270
+ const promoted = result.entries.find((e) => e.eventType === "promoted");
271
+ expect(promoted).toBeDefined();
272
+ expect(promoted?.ref).toBe("skill:deploy");
273
+ expect(promoted?.eventType).toBe("promoted");
274
+ // Metadata from the proposal should be accessible.
275
+ expect(promoted?.metadata?.proposalId).toBe("prop-001");
276
+ }
277
+ finally {
278
+ closeDatabase(db);
279
+ }
280
+ });
281
+ test("proposal reject event (rejected) appears in history with --include-proposals", async () => {
282
+ const eventsFile = path.join(makeTempDir("akm-history-reject-"), "events.jsonl");
283
+ const db = openDatabase(":memory:");
284
+ try {
285
+ ensureUsageEventsSchema(db);
286
+ appendEvent({
287
+ eventType: "rejected",
288
+ ref: "memory:old-draft",
289
+ metadata: { proposalId: "prop-002", source: "reflect", reason: "outdated" },
290
+ }, { filePath: eventsFile });
291
+ const result = await akmHistory({
292
+ db,
293
+ includeProposals: true,
294
+ eventsCtx: { filePath: eventsFile },
295
+ });
296
+ expect(result.totalCount).toBe(1);
297
+ const rejected = result.entries.find((e) => e.eventType === "rejected");
298
+ expect(rejected).toBeDefined();
299
+ expect(rejected?.ref).toBe("memory:old-draft");
300
+ expect(rejected?.metadata?.reason).toBe("outdated");
301
+ }
302
+ finally {
303
+ closeDatabase(db);
304
+ }
305
+ });
306
+ test("non-proposal events in events.jsonl are excluded even with --include-proposals", async () => {
307
+ const eventsFile = path.join(makeTempDir("akm-history-filter-"), "events.jsonl");
308
+ const db = openDatabase(":memory:");
309
+ try {
310
+ ensureUsageEventsSchema(db);
311
+ // These event types should NOT appear in history even with --include-proposals.
312
+ appendEvent({ eventType: "add", ref: "skill:deploy" }, { filePath: eventsFile });
313
+ appendEvent({ eventType: "reflect_invoked", ref: "memory:alpha" }, { filePath: eventsFile });
314
+ // Only this one should appear.
315
+ appendEvent({ eventType: "promoted", ref: "skill:deploy", metadata: { proposalId: "p1", source: "reflect" } }, { filePath: eventsFile });
316
+ const result = await akmHistory({
317
+ db,
318
+ includeProposals: true,
319
+ eventsCtx: { filePath: eventsFile },
320
+ });
321
+ expect(result.entries.every((e) => e.eventType === "promoted" || e.eventType === "rejected")).toBe(true);
322
+ expect(result.entries.length).toBe(1);
323
+ expect(result.entries[0]?.eventType).toBe("promoted");
324
+ }
325
+ finally {
326
+ closeDatabase(db);
327
+ }
328
+ });
329
+ test("usage events and proposal events are merged chronologically", async () => {
330
+ const eventsFile = path.join(makeTempDir("akm-history-merge-"), "events.jsonl");
331
+ const db = openDatabase(":memory:");
332
+ try {
333
+ ensureUsageEventsSchema(db);
334
+ // Insert usage events with explicit timestamps (early, then late).
335
+ db.prepare("INSERT INTO usage_events (event_type, entry_ref, entry_id, created_at) VALUES (?, ?, ?, ?)").run("show", "skill:deploy", 1, "2026-01-01 10:00:00");
336
+ db.prepare("INSERT INTO usage_events (event_type, entry_ref, entry_id, created_at) VALUES (?, ?, ?, ?)").run("feedback", "skill:deploy", 1, "2026-01-03 12:00:00");
337
+ // Append a proposal event between the two usage events.
338
+ appendEvent({ eventType: "promoted", ref: "skill:deploy", metadata: { proposalId: "p3", source: "reflect" } }, {
339
+ filePath: eventsFile,
340
+ now: () => new Date("2026-01-02T09:00:00Z").getTime(),
341
+ });
342
+ const result = await akmHistory({
343
+ db,
344
+ includeProposals: true,
345
+ eventsCtx: { filePath: eventsFile },
346
+ });
347
+ expect(result.totalCount).toBe(3);
348
+ const types = result.entries.map((e) => e.eventType);
349
+ // Chronological: show (Jan 1), promoted (Jan 2), feedback (Jan 3)
350
+ expect(types).toEqual(["show", "promoted", "feedback"]);
351
+ }
352
+ finally {
353
+ closeDatabase(db);
354
+ }
355
+ });
356
+ test("--include-proposals ref filter shows only matching ref events", async () => {
357
+ const eventsFile = path.join(makeTempDir("akm-history-ref-filter-"), "events.jsonl");
358
+ const db = openDatabase(":memory:");
359
+ try {
360
+ ensureUsageEventsSchema(db);
361
+ // Two proposal events for different refs.
362
+ appendEvent({ eventType: "promoted", ref: "skill:deploy", metadata: { proposalId: "p1", source: "reflect" } }, { filePath: eventsFile });
363
+ appendEvent({ eventType: "rejected", ref: "memory:draft", metadata: { proposalId: "p2", source: "reflect" } }, { filePath: eventsFile });
364
+ const result = await akmHistory({
365
+ db,
366
+ ref: "skill:deploy",
367
+ includeProposals: true,
368
+ eventsCtx: { filePath: eventsFile },
369
+ });
370
+ // Only the promoted event for skill:deploy should appear.
371
+ expect(result.ref).toBe("skill:deploy");
372
+ expect(result.entries.every((e) => e.ref === "skill:deploy")).toBe(true);
373
+ const promoted = result.entries.find((e) => e.eventType === "promoted");
374
+ expect(promoted).toBeDefined();
375
+ }
376
+ finally {
377
+ closeDatabase(db);
378
+ }
379
+ });
380
+ test("akm history --include-proposals CLI flag surfaces proposal lifecycle events", async () => {
381
+ const stashDir = makeTempDir("akm-history-cli-proposals-");
382
+ process.env.AKM_STASH_DIR = stashDir;
383
+ const cacheDir = makeTempDir("akm-history-cli-cache-");
384
+ process.env.XDG_CACHE_HOME = cacheDir;
385
+ saveConfig({ semanticSearchMode: "off" });
386
+ writeFile(path.join(stashDir, "memories", "alpha.md"), "---\ndescription: alpha memory\n---\nAlpha.\n");
387
+ await akmIndex({ stashDir, full: true });
388
+ // We can't easily run a full accept without a real proposal, so instead
389
+ // write a promoted event directly to events.jsonl to verify the CLI flag.
390
+ const eventsFile = path.join(cacheDir, "akm", "events.jsonl");
391
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
392
+ const promoted = {
393
+ schemaVersion: 1,
394
+ ts: new Date().toISOString(),
395
+ eventType: "promoted",
396
+ ref: "memory:alpha",
397
+ metadata: { proposalId: "p-cli-test", source: "reflect", assetPath: "memories/alpha.md" },
398
+ };
399
+ fs.appendFileSync(eventsFile, `${JSON.stringify(promoted)}\n`);
400
+ const result = runCli(["history", "--include-proposals", "--ref", "memory:alpha", "--format=json"]);
401
+ expect(result.status).toBe(0);
402
+ const parsed = parseJsonOutput(result);
403
+ const entries = parsed.entries;
404
+ expect(Array.isArray(entries)).toBe(true);
405
+ // The promoted event should appear.
406
+ const promotedEntry = entries.find((e) => e.eventType === "promoted");
407
+ expect(promotedEntry).toBeDefined();
408
+ expect(promotedEntry?.ref).toBe("memory:alpha");
409
+ // Sources should include events.jsonl.
410
+ expect(Array.isArray(parsed.sources)).toBe(true);
411
+ expect(parsed.sources.includes("events.jsonl")).toBe(true);
412
+ // Verify text output also shows the proposal event.
413
+ const text = runCli(["history", "--include-proposals", "--ref", "memory:alpha", "--format=text"]);
414
+ expect(text.status).toBe(0);
415
+ expect(text.stdout).toContain("[promoted]");
416
+ expect(text.stdout).toContain("events.jsonl");
417
+ });
418
+ });
@@ -267,6 +267,31 @@ describe("loadConfig", () => {
267
267
  expect(hint).toContain("akm remove");
268
268
  }
269
269
  });
270
+ test("throws ConfigError when config contains legacy stashes[]", () => {
271
+ writeRawConfig(getConfigPath(), JSON.stringify({
272
+ stashes: [{ type: "filesystem", path: "/legacy-stash" }],
273
+ }));
274
+ expect(() => loadConfig()).toThrow(ConfigError);
275
+ expect(() => loadConfig()).toThrow("legacy `stashes[]` config key");
276
+ });
277
+ test("throws ConfigError when installed npm entry is marked writable", () => {
278
+ writeRawConfig(getConfigPath(), JSON.stringify({
279
+ installed: [
280
+ {
281
+ id: "npm:left-pad",
282
+ source: "npm",
283
+ ref: "npm:left-pad",
284
+ artifactUrl: "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
285
+ stashRoot: "/tmp/left-pad",
286
+ cacheDir: "/tmp/cache",
287
+ installedAt: "2026-05-01T00:00:00.000Z",
288
+ writable: true,
289
+ },
290
+ ],
291
+ }));
292
+ expect(() => loadConfig()).toThrow(ConfigError);
293
+ expect(() => loadConfig()).toThrow("writable: true is only supported on filesystem and git sources");
294
+ });
270
295
  test("recomputes merged config when cwd changes", () => {
271
296
  const firstProject = makeTmpDir();
272
297
  const secondProject = makeTmpDir();
@@ -500,8 +500,28 @@ describe("Scenario: CLI subprocess execution", () => {
500
500
  expect(json.hits.length).toBeGreaterThan(0);
501
501
  expect(json.hits.every((h) => h.type === "knowledge")).toBe(true);
502
502
  });
503
+ // Issue 9: empty query must list all assets rather than throwing UsageError.
504
+ // The CLI contract (--help text) promises omitting the query returns all
505
+ // indexed assets. The akmSearch function handles empty queries end-to-end
506
+ // via getAllEntries (DB path) and the substring-fallback's query-less branch.
507
+ test("cli: akm search with no query lists all assets (empty-query listing)", async () => {
508
+ const result = runCli("search");
509
+ expect(result.exitCode).toBe(0);
510
+ const json = parseJson(result.stdout);
511
+ expect(json.hits).toBeInstanceOf(Array);
512
+ expect(json.hits.length).toBeGreaterThan(0);
513
+ // Brief output: ref is NOT present (only full/agent levels include ref)
514
+ expect(json.hits.every((h) => h.type !== undefined)).toBe(true);
515
+ expect(json.hits.every((h) => h.name !== undefined)).toBe(true);
516
+ });
517
+ test("cli: akm search with no query and --type filters by type", async () => {
518
+ const result = runCli("search", "--type", "skill");
519
+ expect(result.exitCode).toBe(0);
520
+ const json = parseJson(result.stdout);
521
+ expect(json.hits).toBeInstanceOf(Array);
522
+ expect(json.hits.every((h) => h.type === "skill")).toBe(true);
523
+ });
503
524
  test("cli: akm search --limit 2 respects limit", async () => {
504
- // QA #14: empty query now throws UsageError (exit 2); use a real query
505
525
  const result = runCli("search", "docker", "--limit", "2");
506
526
  expect(result.exitCode).toBe(0);
507
527
  const json = parseJson(result.stdout);
@@ -513,7 +533,8 @@ describe("Scenario: CLI subprocess execution", () => {
513
533
  const json = parseJson(result.stdout);
514
534
  expect(json.source).toBeUndefined();
515
535
  expect(json.timing).toBeUndefined();
516
- expect(json.hits.every((h) => h.ref === undefined)).toBe(true);
536
+ // REC-03: ref is now included at brief detail so agents can run `akm show <ref>`
537
+ expect(json.hits.every((h) => h.ref !== undefined)).toBe(true);
517
538
  expect(json.hits.every((h) => h.type !== undefined)).toBe(true);
518
539
  expect(json.hits.every((h) => h.name !== undefined)).toBe(true);
519
540
  expect(json.hits.every((h) => h.action !== undefined)).toBe(true);
@@ -120,7 +120,7 @@ export function loadFixtureStash(name, options = {}) {
120
120
  process.env.AKM_STASH_DIR = priorAkmStashDir;
121
121
  fs.rmSync(tmpRoot, { recursive: true, force: true });
122
122
  };
123
- return { stashDir, cleanup, contentHash };
123
+ return { stashDir, cleanup, contentHash, ...(!options.skipIndex ? { indexCacheHome: cacheHome } : {}) };
124
124
  }
125
125
  // ── Internals ───────────────────────────────────────────────────────────────
126
126
  function fixtureSourceDir(name) {
@@ -81,8 +81,17 @@ describe("fixtureContentHash", () => {
81
81
  });
82
82
  });
83
83
  describe("listFixtures", () => {
84
- test("returns all six shipped fixtures, sorted", () => {
84
+ test("returns all shipped fixtures, sorted", () => {
85
85
  const names = listFixtures();
86
- expect(names).toEqual(["az-cli", "docker-homelab", "minimal", "multi-domain", "noisy", "ranking-baseline"]);
86
+ expect(names).toEqual([
87
+ "az-cli",
88
+ "docker-homelab",
89
+ "drillbit",
90
+ "inkwell",
91
+ "minimal",
92
+ "multi-domain",
93
+ "noisy",
94
+ "ranking-baseline",
95
+ ]);
87
96
  });
88
97
  });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { saveConfig } from "../src/core/config";
5
6
  import { getDbPath } from "../src/core/paths";
6
7
  import { closeDatabase, DB_VERSION, getAllEntries, getEmbeddingCount, getMeta, openDatabase } from "../src/indexer/db";
7
8
  import { akmIndex, buildFileBasenameMap, matchEntryToFile } from "../src/indexer/indexer";
@@ -13,7 +14,7 @@ let embedBatchImpl;
13
14
  const actualEmbedBatch = embedderModule.embedBatch;
14
15
  const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
15
16
  const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
16
- mock.module("../src/embedder.js", () => ({
17
+ mock.module("../src/llm/embedder.js", () => ({
17
18
  ...embedderModule,
18
19
  embedBatch: (texts, embeddingConfig) => embedBatchImpl ? embedBatchImpl(texts, embeddingConfig) : actualEmbedBatch(texts, embeddingConfig),
19
20
  }));
@@ -138,6 +139,15 @@ test("akmIndex handles markdown assets", async () => {
138
139
  const result = await akmIndex({ stashDir });
139
140
  expect(result.totalEntries).toBe(2);
140
141
  });
142
+ test("akmIndex classifies flat markdown files under skills/ as skill assets", async () => {
143
+ const stashDir = tmpStash();
144
+ writeFile(path.join(stashDir, "skills", "deploy.md"), "---\ndescription: Deploy skill\n---\n# Deploy\n");
145
+ await akmIndex({ stashDir, full: true });
146
+ const db = openDatabase();
147
+ const skillEntry = getAllEntries(db).find((row) => row.entry.type === "skill" && row.entry.name === "deploy");
148
+ expect(skillEntry).toBeDefined();
149
+ closeDatabase(db);
150
+ });
141
151
  test("akmIndex includes wiki raw files but excludes infrastructure files from the primary stash index", async () => {
142
152
  const stashDir = tmpStash();
143
153
  writeFile(path.join(stashDir, "wikis", "research", "schema.md"), "---\ndescription: Schema\n---\n# Schema\n");
@@ -511,6 +521,7 @@ test("usage_events are re-linked after full reindex", async () => {
511
521
  test("incremental reindex clears embeddings when provider fingerprint changes", async () => {
512
522
  const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-fp-"));
513
523
  process.env.AKM_STASH_DIR = stashDir;
524
+ saveConfig({ semanticSearchMode: "auto" });
514
525
  embedBatchImpl = async (texts) => texts.map((_text, index) => {
515
526
  const embedding = new Float32Array(384);
516
527
  embedding[0] = index + 1;
@@ -83,7 +83,8 @@ describe("output baseline", () => {
83
83
  const json = JSON.parse(output);
84
84
  // hits is always present; warnings may appear when semantic search is pending
85
85
  expect(Object.keys(json)).toContain("hits");
86
- expect(Object.keys(json.hits[0] ?? {}).sort()).toEqual(["action", "estimatedTokens", "name", "type"]);
86
+ // REC-03: ref is now included at brief detail so agents can run `akm show <ref>`
87
+ expect(Object.keys(json.hits[0] ?? {}).sort()).toEqual(["action", "estimatedTokens", "name", "ref", "type"]);
87
88
  });
88
89
  test("search normal detail includes description capped at 250 characters", () => {
89
90
  const stashDir = makeTempDir("akm-output-stash-");
@@ -69,10 +69,12 @@ describe("shapeSearchHit — local stash hits", () => {
69
69
  tags: ["ops"],
70
70
  whyMatched: "name match",
71
71
  };
72
- test("brief keeps only type/name/action/estimatedTokens", () => {
72
+ test("brief keeps only type/name/ref/action/estimatedTokens", () => {
73
+ // REC-03: ref is now included at brief so agents can run `akm show <ref>`
73
74
  expect(shapeSearchHit(fullHit, "brief")).toEqual({
74
75
  type: "skill",
75
76
  name: "deploy",
77
+ ref: "skill:deploy",
76
78
  action: "akm show skill:deploy",
77
79
  estimatedTokens: 120,
78
80
  });
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { buildRegistryIndex } from "../src/registry/build-index";
6
+ import { buildRegistryIndex, writeRegistryIndex } from "../src/registry/build-index";
7
7
  const CLI = path.join(import.meta.dir, "..", "src", "cli.ts");
8
8
  const tempDirs = [];
9
9
  const servers = [];
@@ -105,6 +105,22 @@ afterAll(() => {
105
105
  }
106
106
  });
107
107
  describe("buildRegistryIndex", () => {
108
+ test("writeRegistryIndex defaults under the cache registry-build directory", () => {
109
+ const cacheHome = makeTempDir("akm-registry-cache-");
110
+ const originalCacheHome = process.env.XDG_CACHE_HOME;
111
+ process.env.XDG_CACHE_HOME = cacheHome;
112
+ try {
113
+ const outPath = writeRegistryIndex({ version: 3, updatedAt: "2026-05-01T00:00:00.000Z", stashes: [] });
114
+ expect(outPath).toBe(path.join(cacheHome, "akm", "registry-build", "index.json"));
115
+ expect(fs.existsSync(outPath)).toBe(true);
116
+ }
117
+ finally {
118
+ if (originalCacheHome === undefined)
119
+ delete process.env.XDG_CACHE_HOME;
120
+ else
121
+ process.env.XDG_CACHE_HOME = originalCacheHome;
122
+ }
123
+ });
108
124
  test("builds a v2 index from discovery and manual entries", async () => {
109
125
  const fixtureRoot = makeTempDir("akm-registry-build-fixture-");
110
126
  const npmPackageDir = path.join(fixtureRoot, "package");
@@ -201,4 +201,38 @@ describe("StaticIndexProvider", () => {
201
201
  expect(hit?.installRef).toBe("github:vercel-labs/agent-skills");
202
202
  });
203
203
  });
204
+ describe("registry version contract", () => {
205
+ test("version 3 index parses without warnings (canonical format)", async () => {
206
+ const srv = serveJson(FIXTURE_INDEX); // FIXTURE_INDEX has version: 3
207
+ const provider = makeProvider(srv.url);
208
+ const result = await provider.search({ query: "agent", limit: 10 });
209
+ expect(result.warnings ?? []).toHaveLength(0);
210
+ expect(result.hits.length).toBeGreaterThan(0);
211
+ });
212
+ test("version 2 index parses without warnings (live official registry format)", async () => {
213
+ const v2Index = { ...FIXTURE_INDEX, version: 2 };
214
+ const srv = serveJson(v2Index);
215
+ const provider = makeProvider(srv.url);
216
+ const result = await provider.search({ query: "agent", limit: 10 });
217
+ expect(result.warnings ?? []).toHaveLength(0);
218
+ expect(result.hits.length).toBeGreaterThan(0);
219
+ });
220
+ test("version 2 index returns correct kit hits", async () => {
221
+ const v2Index = { ...FIXTURE_INDEX, version: 2 };
222
+ const srv = serveJson(v2Index);
223
+ const provider = makeProvider(srv.url);
224
+ const kits = await provider.searchKits({ text: "agent", limit: 10 });
225
+ expect(kits.length).toBeGreaterThan(0);
226
+ expect(kits.some((k) => k.id === "github:vercel-labs/agent-skills")).toBe(true);
227
+ });
228
+ test("version 1 index returns null (unsupported)", async () => {
229
+ // version 1 is explicitly unsupported per schema comment
230
+ const v1Index = { ...FIXTURE_INDEX, version: 1 };
231
+ const srv = serveJson(v1Index);
232
+ const provider = makeProvider(srv.url);
233
+ const result = await provider.search({ query: "agent", limit: 10 });
234
+ // No hits because the parser returns null for unsupported versions
235
+ expect(result.hits).toHaveLength(0);
236
+ });
237
+ });
204
238
  });