akm-cli 0.10.261006-beta → 0.10.261007-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261007] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - **`akm proposal new --bundle <name>` queues a new-asset proposal for another configured bundle.** The bundle must be
14
+ configured and writable; an unknown, disabled or read-only one is refused with the same error `akm remember --bundle`
15
+ gives. The generation prompt draws its convention facts from that bundle, and the proposal records it as its target,
16
+ so `akm proposal accept` writes there. Without the flag the proposal goes to the working bundle, as before.
17
+
18
+ ### Fixed
19
+
20
+ - **`akm ui` searches and asset views never open the index for write.** In read-only mode, `akm search` and
21
+ `akm show` still opened `index.db` read-write; closing that connection can checkpoint the WAL into the main file,
22
+ so browsing could change `index.db` even though nothing was written (seen as an intermittent CI failure). They now
23
+ use read-only connections.
24
+ - **`akm clone` works on flat skill repos and website pages, and records where the clone came from.** A ref whose
25
+ concept id is not under a type directory (`my-repo//domain-name-brainstormer`, a page from a `bundle add <url>`
26
+ source) failed with `TypeError: paths[1] must be string`. The source is now found through the bundle's adapter and
27
+ placed by what the file is: a skill into `skills/<name>/`, other markdown into `knowledge/<name>.md`, and an asset
28
+ that cannot be placed fails with a usage error that points to `akm import`. Cloned markdown gains the origin ref in
29
+ its `sources:` frontmatter. A `knowledge/<name>` ref given without `.md` now clones to `<name>.md`; it used to
30
+ be written with no extension and left out of the index.
31
+ - **`akm ui`'s Overview no longer stalls while another process writes the index.** Its metrics resolved refs through
32
+ a disposable copy of `index.db`, which retried for several seconds and printed a "could not take a non-mutating
33
+ snapshot" warning whenever the index was being written. The UI now reads `index.db` directly in read-only mode;
34
+ `akm metrics` from the CLI is unchanged.
35
+
9
36
  ## [0.10.261006] - 2026-10-16
10
37
 
11
38
  ### Added
@@ -53,7 +53,7 @@ export function metricsIncludeRows(mode) {
53
53
  * `bundle//conceptId`; resolving here keeps one asset from showing as two rows.
54
54
  * A ref that does not resolve (registry results, removed assets) is kept as is.
55
55
  */
56
- async function durableSelects(selects) {
56
+ async function durableSelects(selects, indexSnapshot = true) {
57
57
  const unqualified = [];
58
58
  for (const ref of new Set(selects.map((select) => select.ref))) {
59
59
  try {
@@ -65,7 +65,7 @@ async function durableSelects(selects) {
65
65
  // Not a local bundle ref: keep the ref as recorded.
66
66
  }
67
67
  }
68
- const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed));
68
+ const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed), { isolatedSnapshot: indexSnapshot });
69
69
  const resolved = new Map(unqualified.map(({ ref }, i) => [ref, entries[i]?.itemRef ?? ref]));
70
70
  return selects.map((select) => ({ ts: select.ts, ref: resolved.get(select.ref) ?? select.ref }));
71
71
  }
@@ -190,7 +190,7 @@ export async function akmMetrics(options = {}) {
190
190
  finally {
191
191
  stateDb.close();
192
192
  }
193
- input.selects = await durableSelects(selects);
193
+ input.selects = await durableSelects(selects, options.indexSnapshot ?? true);
194
194
  return buildMetricsResult(input);
195
195
  }
196
196
  export const metricsCommand = defineJsonCommand({
@@ -47,6 +47,10 @@ export const proposeCommand = defineCommand({
47
47
  },
48
48
  task: { type: "string", description: "Task description for the agent (what should the asset do?)" },
49
49
  file: { type: "string", description: "Read the task or prompt text from a UTF-8 file" },
50
+ bundle: {
51
+ type: "string",
52
+ description: "Configured writable bundle to queue the proposal for and draw convention facts from (default: the working bundle)",
53
+ },
50
54
  engine: { type: "string", description: "Engine to use (defaults to defaults.engine)" },
51
55
  "timeout-ms": { type: "string", description: "Override the engine timeout in milliseconds" },
52
56
  },
@@ -73,6 +77,7 @@ export const proposeCommand = defineCommand({
73
77
  name: proposedName,
74
78
  task: taskText,
75
79
  engine: getStringArg(args, "engine"),
80
+ bundle: getStringArg(args, "bundle"),
76
81
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
77
82
  });
78
83
  output("proposal-new", result);
@@ -23,6 +23,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
23
23
  import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
24
24
  import { runStructured } from "../../core/structured.js";
25
25
  import { warn } from "../../core/warn.js";
26
+ import { resolveWriteTarget } from "../../core/write-source.js";
26
27
  import { deriveEntryProvenance } from "../../indexer/installations.js";
27
28
  import { fallbackAnnouncement } from "../../integrations/agent/engine-fallback.js";
28
29
  import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
@@ -144,10 +145,13 @@ export async function akmPropose(options) {
144
145
  if (!stashDirFor(options.type)) {
145
146
  throw new UsageError(`propose: unknown asset type "${options.type}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_FLAG_VALUE");
146
147
  }
147
- const stash = options.stashDir ?? resolveStashDir();
148
148
  // 1. Resolve the write target. Engine/model/inference resolution happens
149
- // exactly once below through the shared execution cascade.
149
+ // exactly once below through the shared execution cascade. `--bundle` names a
150
+ // configured, writable bundle (same resolver and errors as `akm remember
151
+ // --bundle`); its root becomes the stash the proposal is queued for and the
152
+ // prompt's standards are read from.
150
153
  const config = options.agentConfig ?? (await import("../../core/config/config.js")).loadConfig();
154
+ const stash = options.stashDir ?? (options.bundle ? resolveWriteTarget(config, options.bundle).source.path : resolveStashDir());
151
155
  const target = resolveProposalQueueTarget(stash, config);
152
156
  // 2. Build terminal user content.
153
157
  // Standards "rulebook" for this target — wiki schema (wiki page) or stash
@@ -33,7 +33,7 @@ import { appendEvent } from "../../core/events.js";
33
33
  import { SCRIPT_EXTENSIONS } from "../../core/recognition-util.js";
34
34
  import { presentationFor } from "../../core/type-presentation.js";
35
35
  import { warn, warnOnce } from "../../core/warn.js";
36
- import { lookupBundleRef, lookupBundleRefWithResolution } from "../../indexer/indexer.js";
36
+ import { lookupBundleRef, lookupBundleRefWithResolution, lookupBundleRefWithResolutionReadonly, } from "../../indexer/indexer.js";
37
37
  import { projectMarkdownFragmentContent } from "../../indexer/passes/metadata.js";
38
38
  import { ensurePrimaryIndexForRead, resolveReadSources } from "../../indexer/read-preflight.js";
39
39
  import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries, } from "../../indexer/search/search-source.js";
@@ -213,7 +213,9 @@ export async function showLocal(input) {
213
213
  const allSources = resolveSourceEntries(input.stashDir);
214
214
  const searchSources = resolveSourcesForOrigin(parsed.bundle, allSources);
215
215
  const allSourceDirs = searchSources.map((s) => s.path);
216
- const resolution = await lookupBundleRefWithResolution(parsed);
216
+ const resolution = input.readOnly
217
+ ? await lookupBundleRefWithResolutionReadonly(parsed)
218
+ : await lookupBundleRefWithResolution(parsed);
217
219
  if (resolution.indexError !== undefined)
218
220
  throw resolution.indexError;
219
221
  const indexedEntry = resolution.entry;
@@ -262,7 +264,9 @@ export async function showLocal(input) {
262
264
  // Only resolve the indexed-safe revision when the selector itself is opaque,
263
265
  // or when the caller explicitly opts into indexed-safe contextual output.
264
266
  const indexedFragment = parsed.fragment && (opaqueFragmentSelector || input.contextMode === "lead")
265
- ? withIndexDb((db) => getIndexedMarkdownFragment(db, indexedEntry.itemRef, parsed.fragment))
267
+ ? withIndexDb((db) => getIndexedMarkdownFragment(db, indexedEntry.itemRef, parsed.fragment), {
268
+ readOnly: input.readOnly === true,
269
+ })
266
270
  : undefined;
267
271
  const indexedFragmentContent = opaqueFragmentSelector ? indexedFragment?.content : undefined;
268
272
  const indexedRenderer = rendererForIndexedEntry(indexedEntry, fileCtx);
@@ -366,7 +370,7 @@ export async function showLocal(input) {
366
370
  origin: source?.registryId ?? null,
367
371
  editable,
368
372
  ...(!editable ? { editHint: buildEditHint(canonicalRef) } : {}),
369
- ...showLinks(indexedEntry.itemRef, displayDefaultBundle),
373
+ ...showLinks(indexedEntry.itemRef, displayDefaultBundle, input.readOnly === true),
370
374
  };
371
375
  const activeRun = input.readOnly ? null : await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
372
376
  if (activeRun) {
@@ -387,10 +391,10 @@ const LINKS_PER_KIND = 10;
387
391
  * authored order, incoming by ref, and unresolved tokens as authored. Empty
388
392
  * parts are omitted, and the field when nothing links either way.
389
393
  */
390
- function showLinks(itemRef, defaultBundle) {
394
+ function showLinks(itemRef, defaultBundle, readOnly = false) {
391
395
  let rows;
392
396
  try {
393
- rows = withIndexDb((db) => readEntryLinks(db, itemRef));
397
+ rows = withIndexDb((db) => readEntryLinks(db, itemRef), { readOnly });
394
398
  }
395
399
  catch (err) {
396
400
  rethrowIfTestIsolationError(err);
@@ -4,9 +4,14 @@
4
4
  import { randomUUID } from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
- import { stashDirFor } from "../../core/asset/asset-placement.js";
7
+ import { isDeepStrictEqual } from "node:util";
8
+ import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
9
+ import { adapterForId } from "../../core/adapter/registry.js";
10
+ import { placementSpecFor, stashDirFor } from "../../core/asset/asset-placement.js";
8
11
  import { isBundleSlug } from "../../core/asset/asset-ref.js";
9
- import { displayRef, parseQualifiedRefInput } from "../../core/asset/resolve-ref.js";
12
+ import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
13
+ import { parseFrontmatter, replaceFrontmatterBlocks } from "../../core/asset/frontmatter.js";
14
+ import { conceptIdFromTypeName, displayRef, parseQualifiedRefInput } from "../../core/asset/resolve-ref.js";
10
15
  import { isWithin } from "../../core/common.js";
11
16
  import { loadConfig } from "../../core/config/config.js";
12
17
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
@@ -15,10 +20,13 @@ import { warn } from "../../core/warn.js";
15
20
  import { commitWriteTargetBoundary, prepareWriteTargetForMutation, resolveWriteTarget, } from "../../core/write-source.js";
16
21
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
17
22
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
23
+ import { resolveAdapterConceptOwner } from "../../indexer/lookup/adapter-concept-owner.js";
18
24
  import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
25
+ import { buildFileContext } from "../../indexer/walk/file-context.js";
19
26
  import { isRemoteOrigin, resolveSourcesForLocator, resolveSourcesForOrigin } from "../../registry/origin-resolve.js";
20
27
  import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
21
28
  import { resolveAssetPath } from "../../sources/resolve.js";
29
+ import { inferAssetName } from "../read/knowledge.js";
22
30
  function destinationRef(config, target, type, name) {
23
31
  if (!config || !target)
24
32
  return undefined;
@@ -77,11 +85,25 @@ export async function akmClone(options) {
77
85
  cacheDir: installResult.cacheDir,
78
86
  };
79
87
  }
88
+ // A ref whose conceptId is not under a known type directory (a flat skill
89
+ // repo, a website or wiki page) is opaque: its source and type come from the
90
+ // adapter that owns the concept, not from the shape of the ref.
91
+ const known = placementSpecFor(parsed.type) !== undefined;
80
92
  let sourcePath;
93
+ let opaqueType;
81
94
  let lastError;
82
95
  for (const source of searchSources) {
83
96
  try {
84
- sourcePath = await resolveAssetPath(source.path, parsed.type, parsed.name);
97
+ if (known) {
98
+ sourcePath = await resolveAssetPath(source.path, parsed.type, parsed.name);
99
+ }
100
+ else {
101
+ const owned = resolveOpaqueAsset(source, parsed.name);
102
+ if (!owned)
103
+ throw new NotFoundError(`Source asset not found for ref: ${options.sourceRef}`, "ASSET_NOT_FOUND");
104
+ sourcePath = owned.path;
105
+ opaqueType = owned.type;
106
+ }
85
107
  break;
86
108
  }
87
109
  catch (err) {
@@ -95,8 +117,20 @@ export async function akmClone(options) {
95
117
  throw lastError ?? new NotFoundError(`Source asset not found for ref: ${options.sourceRef}`, "ASSET_NOT_FOUND");
96
118
  }
97
119
  const sourceSource = findSourceForPath(sourcePath, allSources);
98
- const destName = options.newName ?? parsed.name;
99
- const typeDir = stashDirFor(parsed.type);
120
+ // Where the clone lands: a known-type ref keeps its type and name; an opaque
121
+ // one is placed by what the source file is.
122
+ const sourceExt = path.extname(sourcePath);
123
+ const cloneType = known ? parsed.type : placementTypeForOpaque(options.sourceRef, sourcePath, opaqueType);
124
+ const defaultName = known ? parsed.name : slugName(parsed.name);
125
+ const destName = options.newName ?? defaultName;
126
+ const typeDir = stashDirFor(cloneType);
127
+ // A ref's name often omits its extension (`knowledge/<name>` names `<name>.md`), and an opaque one is a slug:
128
+ // either way the copy keeps the source file's extension, or it would be written as a file the index ignores.
129
+ const destFileName = path.extname(destName) ? destName : destName + sourceExt;
130
+ const isSkill = cloneType === "skill";
131
+ // A concept id is the path without its markdown extension.
132
+ const originConceptId = (known ? conceptIdFromTypeName(parsed.type, parsed.name) : parsed.name).replace(/\.md$/i, "");
133
+ const originRef = sourceSource?.registryId ? `${sourceSource.registryId}//${originConceptId}` : undefined;
100
134
  // Validate destName to prevent path traversal (parsed.name is already
101
135
  // validated by the ref parser, but newName comes directly from user input).
102
136
  // Run whenever newName is provided, including empty string.
@@ -128,7 +162,7 @@ export async function akmClone(options) {
128
162
  ? `in target "${writeTarget.selector}"`
129
163
  : "in working stash";
130
164
  // Guard against self-clone
131
- if (parsed.type === "skill") {
165
+ if (isSkill) {
132
166
  const sourceSkillDir = path.resolve(path.dirname(sourcePath));
133
167
  const destSkillDir = path.resolve(path.join(destRoot, typeDir, destName));
134
168
  if (sourceSkillDir === destSkillDir) {
@@ -137,8 +171,7 @@ export async function akmClone(options) {
137
171
  }
138
172
  else {
139
173
  const resolvedSource = path.resolve(sourcePath);
140
- const sourceExt = path.extname(sourcePath);
141
- const effectiveDestName = !path.extname(destName) && sourceExt ? destName + sourceExt : destName;
174
+ const effectiveDestName = !path.extname(destFileName) && sourceExt ? destFileName + sourceExt : destFileName;
142
175
  const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName));
143
176
  if (resolvedSource === resolvedDest) {
144
177
  throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
@@ -148,7 +181,7 @@ export async function akmClone(options) {
148
181
  let destPath;
149
182
  let overwritten;
150
183
  let operationPaths;
151
- if (parsed.type === "skill") {
184
+ if (isSkill) {
152
185
  const sourceSkillDir = path.dirname(sourcePath);
153
186
  const destSkillDir = path.join(destRoot, typeDir, destName);
154
187
  assertNoDestinationSymlinkParent(destRoot, destSkillDir);
@@ -163,6 +196,7 @@ export async function akmClone(options) {
163
196
  const stagingDir = `${destSkillDir}.tmp-${randomUUID()}`;
164
197
  try {
165
198
  fs.cpSync(sourceSkillDir, stagingDir, { recursive: true, errorOnExist: true, force: false });
199
+ stampCloneOrigin(path.join(stagingDir, "SKILL.md"), originRef);
166
200
  if (overwritten)
167
201
  fs.rmSync(destSkillDir, { recursive: true, force: true });
168
202
  fs.renameSync(stagingDir, destSkillDir);
@@ -175,7 +209,7 @@ export async function akmClone(options) {
175
209
  operationPaths = [...new Set([...replacedPaths, ...listTreeFiles(destSkillDir)])];
176
210
  }
177
211
  else {
178
- destPath = path.join(destRoot, typeDir, destName);
212
+ destPath = path.join(destRoot, typeDir, destFileName);
179
213
  assertNoDestinationSymlinkParent(destRoot, destPath);
180
214
  const existing = lstatIfExists(destPath);
181
215
  overwritten = existing !== undefined;
@@ -186,6 +220,8 @@ export async function akmClone(options) {
186
220
  const stagingPath = `${destPath}.tmp-${randomUUID()}`;
187
221
  try {
188
222
  fs.copyFileSync(sourcePath, stagingPath, fs.constants.COPYFILE_EXCL);
223
+ if (cloneType !== "workflow" && sourceExt.toLowerCase() === ".md")
224
+ stampCloneOrigin(stagingPath, originRef);
189
225
  if (overwritten)
190
226
  fs.rmSync(destPath, { recursive: true, force: true });
191
227
  fs.renameSync(stagingPath, destPath);
@@ -196,7 +232,7 @@ export async function akmClone(options) {
196
232
  }
197
233
  operationPaths = [destPath];
198
234
  }
199
- const ref = destinationRef(config, writeTarget, parsed.type, destName);
235
+ const ref = destinationRef(config, writeTarget, cloneType, destName);
200
236
  if (writeTarget) {
201
237
  const commitRoot = writeTarget.source.repoPath ?? writeTarget.source.path;
202
238
  const commitPaths = operationPaths.map((filePath) => path.relative(commitRoot, filePath).replaceAll(path.sep, "/"));
@@ -213,6 +249,82 @@ export async function akmClone(options) {
213
249
  };
214
250
  });
215
251
  }
252
+ /**
253
+ * Find an opaque concept (one whose id is not under a known type directory) in
254
+ * a source through the adapter that owns it, and name the type that adapter
255
+ * recognizes the file as. The same ownership + recognition `akm show` and the
256
+ * index use, so a clone sees exactly the file the index holds for the ref.
257
+ */
258
+ function resolveOpaqueAsset(source, conceptId) {
259
+ const adapterId = source.adapterId ?? detectAdapterId(source.path);
260
+ const owner = resolveAdapterConceptOwner(source.path, adapterId, conceptId, { mode: "read" });
261
+ if (!owner)
262
+ return undefined;
263
+ let type;
264
+ try {
265
+ const component = { id: adapterId, adapter: adapterId, root: source.path, writable: false };
266
+ type = adapterForId(adapterId)?.recognize(component, buildFileContext(source.path, owner.path))?.type;
267
+ }
268
+ catch {
269
+ // An adapter that cannot recognize the file leaves its type unknown.
270
+ }
271
+ return { path: owner.realPath, type };
272
+ }
273
+ /**
274
+ * Where an opaque asset is cloned: a skill (SKILL.md) as a directory under
275
+ * `skills/`, any other recognized placement type under its own directory, and
276
+ * markdown of an unknown type (a website or wiki page, a flat doc) as
277
+ * `knowledge/`. Anything else cannot be placed.
278
+ */
279
+ function placementTypeForOpaque(sourceRef, sourcePath, indexedType) {
280
+ if (indexedType === "skill" && path.basename(sourcePath) === "SKILL.md")
281
+ return "skill";
282
+ if (indexedType !== undefined && indexedType !== "skill" && placementSpecFor(indexedType) !== undefined) {
283
+ return indexedType;
284
+ }
285
+ if (path.extname(sourcePath).toLowerCase() === ".md")
286
+ return "knowledge";
287
+ throw new UsageError(`cannot clone ${sourceRef}: its type${indexedType ? ` "${indexedType}"` : ""} has no place in a bundle and ` +
288
+ `${path.basename(sourcePath)} is not markdown; use \`akm import <path>\` or copy the file.`);
289
+ }
290
+ /** Clone name of an opaque asset: the last segment of its concept id, slugified like every other asset name. */
291
+ function slugName(conceptId) {
292
+ const last = conceptId.split("/").pop() ?? conceptId;
293
+ const stem = last.replace(/\.[A-Za-z0-9]{1,5}$/, "");
294
+ return inferAssetName("", "clone", stem);
295
+ }
296
+ /**
297
+ * Record where a cloned markdown asset came from by merging the origin ref into
298
+ * its `sources:` frontmatter. `sources:` is free-form provenance that `akm lint` does
299
+ * not resolve, unlike `xrefs:`: the origin bundle is often not installed where the
300
+ * clone is read (a shared domain bundle), and an `xrefs:` entry would be reported as
301
+ * a missing ref there. Only the `sources` block is rewritten; every other
302
+ * byte stays as written. A file whose frontmatter cannot be edited that way
303
+ * (and still parse back to exactly the intended mapping) is left untouched.
304
+ */
305
+ function stampCloneOrigin(filePath, originRef) {
306
+ if (!originRef)
307
+ return;
308
+ const raw = fs.readFileSync(filePath, "utf8");
309
+ const parsed = parseFrontmatter(raw);
310
+ const existing = parsed.data.sources;
311
+ const prior = Array.isArray(existing) ? existing : typeof existing === "string" ? [existing] : [];
312
+ if (existing !== undefined && prior.length === 0)
313
+ return;
314
+ if (prior.includes(originRef))
315
+ return;
316
+ const sources = [...prior, originRef];
317
+ let next;
318
+ if (parsed.frontmatter === null) {
319
+ next = `---\n${serializeFrontmatter({ sources })}\n---\n${raw}`;
320
+ }
321
+ else {
322
+ next = replaceFrontmatterBlocks(raw, ["sources"], serializeFrontmatter({ sources }).split("\n"));
323
+ if (next === null || !isDeepStrictEqual(parseFrontmatter(next).data, { ...parsed.data, sources }))
324
+ return;
325
+ }
326
+ fs.writeFileSync(filePath, next);
327
+ }
216
328
  function lstatIfExists(filePath) {
217
329
  try {
218
330
  return fs.lstatSync(filePath);
@@ -212,7 +212,7 @@ async function overview(query) {
212
212
  }), null);
213
213
  return { ...base, ...(report ? { report } : {}) };
214
214
  });
215
- const metrics = await section(errors, "metrics", () => akmMetrics({ since: query.get("metricsSince") || "30d" }));
215
+ const metrics = await section(errors, "metrics", () => akmMetrics({ since: query.get("metricsSince") || "30d", indexSnapshot: false }));
216
216
  const stateReads = await section(errors, "state", () => withReadonlyStateDb((db) => ({
217
217
  improveRuns: listRecentImproveRuns(db, IMPROVE_RUNS_SHOWN).map(summarizeImproveRun),
218
218
  usageByDay: usageByDay(db, Date.now()),
@@ -232,13 +232,17 @@ function opaqueRefParts(conceptId, allowRoot = false) {
232
232
  * that `resolveSourcesForOrigin` matches on `registryId`. The SHORT form (no
233
233
  * bundle) leaves `origin` undefined = search-all-sources.
234
234
  * - `#fragment` → rejected. No input boundary consumes an export fragment.
235
+ *
236
+ * `originGiven` — the caller already split a (non-slug) origin off the ref, so a
237
+ * single-segment conceptId (a flat skill repo's `domain-name-brainstormer`) is
238
+ * qualified and parses as opaque, exactly as it does behind a slug `bundle//`.
235
239
  */
236
- export function parseRefInput(raw) {
240
+ export function parseRefInput(raw, originGiven = false) {
237
241
  const ref = parseBundleRef(raw);
238
242
  if (ref.fragment !== undefined) {
239
243
  throw new UsageError(`Export fragment "#${ref.fragment}" is not accepted here — drop it from "${raw.trim()}".`, "INVALID_FLAG_VALUE");
240
244
  }
241
- const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, ref.bundle !== undefined);
245
+ const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, originGiven || ref.bundle !== undefined);
242
246
  if (parts === undefined) {
243
247
  throw new NotFoundError(`Unrecognized asset ref "${raw.trim()}": conceptId "${ref.conceptId}" has no known asset-type prefix.`, "ASSET_NOT_FOUND");
244
248
  }
@@ -265,7 +269,7 @@ export function parseQualifiedRefInput(raw) {
265
269
  if (boundary > 0) {
266
270
  const origin = trimmed.slice(0, boundary);
267
271
  if (!isBundleSlug(origin)) {
268
- return { ...parseRefInput(trimmed.slice(boundary + 2)), origin };
272
+ return { ...parseRefInput(trimmed.slice(boundary + 2), true), origin };
269
273
  }
270
274
  }
271
275
  return parseRefInput(trimmed);
@@ -1196,6 +1196,18 @@ borrowed = false) {
1196
1196
  export async function lookupBundleRefWithResolution(ref) {
1197
1197
  return lookupBundleRefWithResolutionUsing(ref, openExistingDatabase);
1198
1198
  }
1199
+ /**
1200
+ * {@link lookupBundleRefWithResolution} on a plain read-only connection (`akm ui`): a read-write one can checkpoint
1201
+ * the WAL into the main file when it closes, changing the file although nothing was written.
1202
+ */
1203
+ export async function lookupBundleRefWithResolutionReadonly(ref) {
1204
+ return lookupBundleRefWithResolutionUsing(ref, (dbPath) => {
1205
+ const db = openReadonlyExistingDatabase(dbPath);
1206
+ if (!db)
1207
+ throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
1208
+ return db;
1209
+ });
1210
+ }
1199
1211
  /** Resolve an adapter-owned `[bundle//]conceptId` without interpreting its path as an AKM type. */
1200
1212
  export async function lookupBundleRef(ref) {
1201
1213
  const resolution = await lookupBundleRefWithResolution(ref);
@@ -1223,12 +1235,12 @@ export async function lookupBundleRefReadonly(ref) {
1223
1235
  * index, which is copied once rather than once per ref. A ref that does not
1224
1236
  * resolve, or an index that cannot be read, gives `null` for that ref.
1225
1237
  */
1226
- export async function lookupBundleRefsReadonly(refs) {
1238
+ export async function lookupBundleRefsReadonly(refs, options = {}) {
1227
1239
  if (refs.length === 0)
1228
1240
  return [];
1229
1241
  let db;
1230
1242
  try {
1231
- db = openReadonlyExistingDatabase(getDbPath(), { isolatedSnapshot: true });
1243
+ db = openReadonlyExistingDatabase(getDbPath(), { isolatedSnapshot: options.isolatedSnapshot ?? true });
1232
1244
  }
1233
1245
  catch {
1234
1246
  // Unreadable index: every ref stays unresolved.
@@ -21,7 +21,7 @@ import { presentationFor } from "../../core/type-presentation.js";
21
21
  import { embed } from "../../llm/embedder.js";
22
22
  import { applyEmbeddingTemplate, resolveEmbeddingProfile } from "../../llm/embedders/profile.js";
23
23
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
24
- import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
24
+ import { assertIndexPathReadable, closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../storage/repositories/index-connection.js";
25
25
  import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getEntryRefsAndTypes, } from "../../storage/repositories/index-entries-repository.js";
26
26
  import { searchFts } from "../../storage/repositories/index-fts-repository.js";
27
27
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
@@ -128,7 +128,10 @@ export async function searchLocal(input) {
128
128
  mode: "keyword",
129
129
  };
130
130
  }
131
- const db = openExistingDatabase(dbPath);
131
+ // A read-only search (`akm ui`) must not open the index for write: closing a
132
+ // read-write connection can checkpoint the WAL into the main file, which
133
+ // changes it even though nothing was written.
134
+ const db = input.readOnly ? openReadonlyExistingDatabase(dbPath) : openExistingDatabase(dbPath);
132
135
  try {
133
136
  const entryCount = getEntryCount(db);
134
137
  if (entryCount === 0) {
@@ -3,7 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { resolveStorageLocations } from "../locations.js";
5
5
  import { withManagedDb } from "../managed-db.js";
6
- import { openExistingDatabase } from "./index-connection.js";
6
+ import { openExistingDatabase, openReadonlyExistingDatabase } from "./index-connection.js";
7
7
  /**
8
8
  * Busy-timeout (ms) for read-path telemetry writers. Small on purpose: a
9
9
  * usage-event insert contending with a background reindex should be dropped,
@@ -41,7 +41,15 @@ export const TELEMETRY_BUSY_TIMEOUT_MS = 250;
41
41
  * @returns Whatever `fn` returns.
42
42
  */
43
43
  export function withIndexDb(fn, opts) {
44
- return withManagedDb(() => openExistingDatabase(resolveStorageLocations().indexDb), (db) => {
44
+ return withManagedDb(() => {
45
+ const dbPath = resolveStorageLocations().indexDb;
46
+ if (!opts?.readOnly)
47
+ return openExistingDatabase(dbPath);
48
+ const db = openReadonlyExistingDatabase(dbPath);
49
+ if (!db)
50
+ throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
51
+ return db;
52
+ }, (db) => {
45
53
  if (opts?.busyTimeoutMs !== undefined) {
46
54
  db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts.busyTimeoutMs))}`);
47
55
  }
@@ -1558,6 +1558,23 @@ akm clone "npm:@scope/pkg//scripts/deploy.sh" --dest /tmp/preview
1558
1558
 
1559
1559
  Skills (directories) are copied recursively. Other types copy a single file.
1560
1560
 
1561
+ **Refs outside the type directories.** A ref whose concept id is not under a
1562
+ type directory (a skill in a flat skills repo, `my-repo//domain-name-brainstormer`;
1563
+ a page from a website or wiki bundle) is placed by what the source file is: a
1564
+ `SKILL.md` directory clones to `skills/<name>/`, any other recognized type to its
1565
+ own directory, and markdown of any other type to `knowledge/<name>.md`. `<name>`
1566
+ is the last segment of the concept id, slugified; `--name` overrides it. An asset
1567
+ that is not markdown and has no place in a bundle fails with a usage error that
1568
+ points to `akm import <path>`.
1569
+
1570
+ **Provenance.** A cloned markdown asset (a skill's `SKILL.md` included) records
1571
+ where it came from: the origin ref, `<bundle>//<concept-id>`, is added to its
1572
+ `sources:` frontmatter, keeping any entries already listed. `sources:` is free-form
1573
+ provenance that `akm lint` does not resolve, so a clone shared where the origin
1574
+ bundle is not installed does not report a missing ref. Other bytes of the file
1575
+ stay as written, non-markdown files and workflows are copied unchanged, and the
1576
+ origin is never modified.
1577
+
1561
1578
  **Remote clone:** When the origin in the ref points to a package that is not
1562
1579
  installed locally (e.g. an npm package or local path not in your bundle
1563
1580
  sources), akm fetches it to the cache automatically and extracts the
@@ -3030,11 +3047,13 @@ akm proposal new <type> <name> --file ./prompt.md
3030
3047
  akm proposal new skill code-review --task "PR-style review skill"
3031
3048
  akm proposal new lesson docker-cleanup --file ./prompts/docker-cleanup.md
3032
3049
  akm proposal new skill code-review --path team --task "PR-style review skill" # writes under skills/team/
3050
+ akm proposal new skill code-review --bundle team --task "PR-style review skill" # queued for the `team` bundle
3033
3051
  ```
3034
3052
 
3035
3053
  | Flag | Description |
3036
3054
  | --- | --- |
3037
3055
  | `--path` | Relative subdirectory under the type dir to place the proposed asset in (e.g. `release`). The filename comes from `<name>`. |
3056
+ | `--bundle` | Configured writable bundle to queue the proposal for. The prompt takes its convention facts from that bundle, the proposal records it as its target (so `akm proposal accept` writes there), and it is queued in that bundle's queue (`akm proposal list --queue <bundle>`). An unknown, disabled or read-only bundle is refused, as for `akm remember --bundle`. Default: the working bundle |
3038
3057
  | `--task` | Inline task text |
3039
3058
  | `--file` | Read task text from a UTF-8 file |
3040
3059
  | `--engine` | Override the default execution engine. Any kind works: an LLM engine, an agent CLI or `opencode-sdk` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.10.261006-beta",
3
+ "version": "0.10.261007-beta",
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": [