akm-cli 0.9.6 → 0.9.7

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 (31) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/dist/assets/hints/cli-hints-full.md +3 -3
  3. package/dist/assets/improve-strategies/catchup.json +40 -11
  4. package/dist/assets/improve-strategies/thorough.json +45 -7
  5. package/dist/assets/tasks/improve/akm-improve-frequent.yml +2 -2
  6. package/dist/commands/agent/contribute-cli.js +11 -0
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/improve/improve-strategies.js +0 -4
  9. package/dist/commands/improve/memory/memory-improve.js +2 -1
  10. package/dist/commands/improve/preparation.js +1 -1
  11. package/dist/commands/improve/reflect.js +1 -1
  12. package/dist/commands/lint/base-linter.js +141 -18
  13. package/dist/commands/lint/index.js +17 -4
  14. package/dist/commands/read/curate.js +47 -0
  15. package/dist/commands/read/search-cli.js +24 -1
  16. package/dist/core/asset/asset-placement.js +13 -2
  17. package/dist/core/asset/frontmatter.js +116 -0
  18. package/dist/core/asset/memory-archive.js +97 -0
  19. package/dist/core/config/engine-semantics.js +0 -2
  20. package/dist/scripts/akm-migrate-node.js +13 -7
  21. package/dist/scripts/akm-migrate.js +13 -7
  22. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -0
  23. package/dist/storage/repositories/index-connection.js +45 -3
  24. package/dist/tasks/backends/cron.js +49 -9
  25. package/dist/tasks/resolve-akm-bin.js +17 -2
  26. package/dist/tasks/scheduler-invocation.js +8 -1
  27. package/dist/tasks/source/parse-task-source.js +23 -9
  28. package/docs/reference/cli.md +5 -1
  29. package/package.json +1 -1
  30. package/dist/assets/improve-strategies/frequent.json +0 -15
  31. package/dist/assets/improve-strategies/memory-focus.json +0 -15
@@ -11525,6 +11525,7 @@ var SCRIPT_EXTENSIONS = new Set([
11525
11525
  ".kts"
11526
11526
  ]);
11527
11527
  var WORKFLOW_EXTENSIONS = [".md", ".yml"];
11528
+ var DERIVED_SUFFIX = ".derived";
11528
11529
  var KNOWN_TYPES = [
11529
11530
  "skill",
11530
11531
  "command",
@@ -11685,6 +11686,9 @@ function assetPathForName(assetType, typeRoot, name) {
11685
11686
  }
11686
11687
  function assetPathCandidatesForName(assetType, typeRoot, name) {
11687
11688
  const primary = assetPathForName(assetType, typeRoot, name);
11689
+ if (assetType === "memory" && !name.endsWith(DERIVED_SUFFIX)) {
11690
+ return [primary, assetPathForName(assetType, typeRoot, `${name}${DERIVED_SUFFIX}`)];
11691
+ }
11688
11692
  if (assetType !== "env")
11689
11693
  return [primary];
11690
11694
  const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;
@@ -17606,6 +17610,9 @@ function peekTaskSourceVersion(root) {
17606
17610
  return typeof value === "number" ? value : undefined;
17607
17611
  }
17608
17612
  var TASK_MIGRATE_HINT = "Run `akm migrate apply --dry-run` to preview the task-v3 to task-source-v4 conversion, then run `akm migrate apply`.";
17613
+ function unmigratableVersionError(filePath, version, reason, detail) {
17614
+ return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version} and needs a human decision before it can run — the deterministic migrator cannot convert it automatically (${reason}${detail ? `: ${detail}` : ""}).`, "TASK_SCHEMA_VERSION_UNSUPPORTED", "Review the file and resolve the ambiguity by hand, then it will convert normally; `akm migrate status` reports the same reason.");
17615
+ }
17609
17616
  function unsupportedVersionError(filePath, version) {
17610
17617
  return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
17611
17618
  }
@@ -17625,12 +17632,12 @@ function planInMemoryV4Bytes(version, yaml, filePath, workspaceRoot) {
17625
17632
  } else {
17626
17633
  const v3Outcome = planTaskToV3File(baseInput);
17627
17634
  if (v3Outcome.status !== "changed")
17628
- return;
17635
+ return { reason: v3Outcome.reason, detail: v3Outcome.detail };
17629
17636
  v3Bytes = v3Outcome.after;
17630
17637
  }
17631
17638
  const v4Outcome = planTaskToV4File({ ...baseInput, bytes: v3Bytes });
17632
17639
  if (v4Outcome.status !== "changed")
17633
- return;
17640
+ return { reason: v4Outcome.reason, detail: v4Outcome.detail };
17634
17641
  return v4Outcome.after.toString("utf8");
17635
17642
  }
17636
17643
  function parseTaskSource(input) {
@@ -17638,16 +17645,17 @@ function parseTaskSource(input) {
17638
17645
  const version = peekTaskSourceVersion(root);
17639
17646
  if (version !== undefined && version !== TASK_SOURCE_V4_VERSION) {
17640
17647
  if (version === 2 || version === 3) {
17641
- const v4Yaml = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
17642
- if (v4Yaml !== undefined) {
17648
+ const shimmed = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
17649
+ if (typeof shimmed === "string") {
17643
17650
  const v4 = parseTaskSourceV4({
17644
- yaml: v4Yaml,
17651
+ yaml: shimmed,
17645
17652
  filePath: input.filePath,
17646
17653
  ...input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}
17647
17654
  });
17648
17655
  warn(`akm: task ${input.filePath} uses schema v${version} — auto-read as v4; run \`akm migrate apply\` to rewrite it and silence this`);
17649
17656
  return Object.freeze({ version: 4, v4 });
17650
17657
  }
17658
+ throw unmigratableVersionError(input.filePath, version, shimmed.reason, shimmed.detail);
17651
17659
  }
17652
17660
  throw unsupportedVersionError(input.filePath, version);
17653
17661
  }
@@ -27990,9 +27998,7 @@ var BUILTIN_IMPROVE_STRATEGY_NAMES = [
27990
27998
  "default",
27991
27999
  "quick",
27992
28000
  "thorough",
27993
- "memory-focus",
27994
28001
  "graph-refresh",
27995
- "frequent",
27996
28002
  "consolidate",
27997
28003
  "catchup",
27998
28004
  "reflect-distill",
@@ -11524,6 +11524,7 @@ var SCRIPT_EXTENSIONS = new Set([
11524
11524
  ".kts"
11525
11525
  ]);
11526
11526
  var WORKFLOW_EXTENSIONS = [".md", ".yml"];
11527
+ var DERIVED_SUFFIX = ".derived";
11527
11528
  var KNOWN_TYPES = [
11528
11529
  "skill",
11529
11530
  "command",
@@ -11684,6 +11685,9 @@ function assetPathForName(assetType, typeRoot, name) {
11684
11685
  }
11685
11686
  function assetPathCandidatesForName(assetType, typeRoot, name) {
11686
11687
  const primary = assetPathForName(assetType, typeRoot, name);
11688
+ if (assetType === "memory" && !name.endsWith(DERIVED_SUFFIX)) {
11689
+ return [primary, assetPathForName(assetType, typeRoot, `${name}${DERIVED_SUFFIX}`)];
11690
+ }
11687
11691
  if (assetType !== "env")
11688
11692
  return [primary];
11689
11693
  const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;
@@ -17528,6 +17532,9 @@ function peekTaskSourceVersion(root) {
17528
17532
  return typeof value === "number" ? value : undefined;
17529
17533
  }
17530
17534
  var TASK_MIGRATE_HINT = "Run `akm migrate apply --dry-run` to preview the task-v3 to task-source-v4 conversion, then run `akm migrate apply`.";
17535
+ function unmigratableVersionError(filePath, version, reason, detail) {
17536
+ return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version} and needs a human decision before it can run \u2014 the deterministic migrator cannot convert it automatically (${reason}${detail ? `: ${detail}` : ""}).`, "TASK_SCHEMA_VERSION_UNSUPPORTED", "Review the file and resolve the ambiguity by hand, then it will convert normally; `akm migrate status` reports the same reason.");
17537
+ }
17531
17538
  function unsupportedVersionError(filePath, version) {
17532
17539
  return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
17533
17540
  }
@@ -17547,12 +17554,12 @@ function planInMemoryV4Bytes(version, yaml, filePath, workspaceRoot) {
17547
17554
  } else {
17548
17555
  const v3Outcome = planTaskToV3File(baseInput);
17549
17556
  if (v3Outcome.status !== "changed")
17550
- return;
17557
+ return { reason: v3Outcome.reason, detail: v3Outcome.detail };
17551
17558
  v3Bytes = v3Outcome.after;
17552
17559
  }
17553
17560
  const v4Outcome = planTaskToV4File({ ...baseInput, bytes: v3Bytes });
17554
17561
  if (v4Outcome.status !== "changed")
17555
- return;
17562
+ return { reason: v4Outcome.reason, detail: v4Outcome.detail };
17556
17563
  return v4Outcome.after.toString("utf8");
17557
17564
  }
17558
17565
  function parseTaskSource(input) {
@@ -17560,16 +17567,17 @@ function parseTaskSource(input) {
17560
17567
  const version = peekTaskSourceVersion(root);
17561
17568
  if (version !== undefined && version !== TASK_SOURCE_V4_VERSION) {
17562
17569
  if (version === 2 || version === 3) {
17563
- const v4Yaml = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
17564
- if (v4Yaml !== undefined) {
17570
+ const shimmed = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
17571
+ if (typeof shimmed === "string") {
17565
17572
  const v4 = parseTaskSourceV4({
17566
- yaml: v4Yaml,
17573
+ yaml: shimmed,
17567
17574
  filePath: input.filePath,
17568
17575
  ...input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}
17569
17576
  });
17570
17577
  warn(`akm: task ${input.filePath} uses schema v${version} \u2014 auto-read as v4; run \`akm migrate apply\` to rewrite it and silence this`);
17571
17578
  return Object.freeze({ version: 4, v4 });
17572
17579
  }
17580
+ throw unmigratableVersionError(input.filePath, version, shimmed.reason, shimmed.detail);
17573
17581
  }
17574
17582
  throw unsupportedVersionError(input.filePath, version);
17575
17583
  }
@@ -27912,9 +27920,7 @@ var BUILTIN_IMPROVE_STRATEGY_NAMES = [
27912
27920
  "default",
27913
27921
  "quick",
27914
27922
  "thorough",
27915
- "memory-focus",
27916
27923
  "graph-refresh",
27917
- "frequent",
27918
27924
  "consolidate",
27919
27925
  "catchup",
27920
27926
  "reflect-distill",
@@ -23,6 +23,9 @@ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
23
23
  const QUEUE_EXPANSION_FACTOR = 5;
24
24
  const MAX_PAGES_DEFAULT = 50;
25
25
  const MAX_DEPTH_DEFAULT = 3;
26
+ /** Byte cap for the `llms.txt` manifest itself — a curated link list, never a large file. */
27
+ const LLMS_TXT_BYTE_CAP = 512 * 1024;
28
+ const LLMS_TXT_BODY_TIMEOUT_MS = 15_000;
26
29
  /**
27
30
  * Per-page body cap for website scraping. HTML pages this large are
28
31
  * almost never useful as agent knowledge sources and a runaway server
@@ -531,6 +534,38 @@ async function crawlWebsite(startUrl, options) {
531
534
  ? createAllowAllRobotsPolicy()
532
535
  : createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts: options.allowPrivateHosts, signal: crawlSignal }));
533
536
  await assertStartUrlAllowedByRobots(robots, start, options.rawStartUrl);
537
+ // llms.txt fast path: an increasing number of doc sites publish a curated,
538
+ // deduplicated link list at `/llms.txt` specifically for tools like this
539
+ // one. When present, use it as the crawl frontier instead of discovering
540
+ // links by parsing HTML — each linked page still goes through the exact
541
+ // same robots-compliant, host-guarded `fetchWebsitePage` call below, so
542
+ // ingested pages stay individually addressable. Gated to origin-root start
543
+ // URLs only (mirrors `extractGithubRepository`'s repo-root restriction):
544
+ // adding a specific page must fetch that page, not silently pull in the
545
+ // whole site's manifest.
546
+ if (isOriginRootUrl(start)) {
547
+ const manifest = await fetchLlmsManifest(start, robots, {
548
+ allowPrivateHosts: options.allowPrivateHosts,
549
+ signal: crawlSignal,
550
+ });
551
+ if (manifest) {
552
+ warn("[akm] Using llms.txt manifest from %s", manifest.manifestUrl);
553
+ queue.length = 0;
554
+ for (const link of manifest.links) {
555
+ // A manifest can name arbitrary hosts; only same-origin links are
556
+ // honored by default, same as links discovered mid-crawl below.
557
+ if (link.origin !== allowedOrigin)
558
+ continue;
559
+ const candidate = normalizeCrawlUrl(link.toString());
560
+ if (!candidate)
561
+ continue;
562
+ // depth = maxDepth: fetch each manifest page individually, but don't
563
+ // treat it as a fresh BFS seed — the manifest is already the
564
+ // author-curated set of pages worth ingesting.
565
+ queue.push({ url: candidate, rawUrl: link.toString(), depth: options.maxDepth, deferrals: 0 });
566
+ }
567
+ }
568
+ }
534
569
  // Counts actual `fetchWebsitePage` invocations (regardless of outcome) so
535
570
  // Crawl-delay pacing skips the first fetch and never charges a delay slot
536
571
  // to a URL that robots.txt skipped without ever being fetched (C-11).
@@ -870,6 +905,97 @@ function buildMarkdownSnapshot(page, slug, tags) {
870
905
  "",
871
906
  ].join("\n");
872
907
  }
908
+ /**
909
+ * True for a start URL that names an origin's root (no path, no query).
910
+ * Matches how `extractGithubRepository` restricts its own special-case match
911
+ * to repository-root URLs — the llms.txt probe must not fire for a
912
+ * user-supplied deep link, or `akm bundle add <site>/guides/foo` would
913
+ * silently ingest the whole site's manifest instead of the page requested.
914
+ */
915
+ export function isOriginRootUrl(url) {
916
+ return url.pathname === "/" && !url.search;
917
+ }
918
+ /**
919
+ * Parses the `llms.txt` link-list format: list items shaped like
920
+ * `- [title](path) - description` (the description, and its separator, are
921
+ * ignored — only the link target is needed). Any line that isn't a markdown
922
+ * link list item — headings, the leading `# Title`/`> summary` lines, prose —
923
+ * is simply not a link line and is skipped.
924
+ */
925
+ export function parseLlmsTxtLinks(text, baseUrl) {
926
+ const links = [];
927
+ const seen = new Set();
928
+ for (const line of text.split(/\r?\n/)) {
929
+ const match = line.trim().match(/^-\s*\[[^\]]*\]\(([^)\s]+)\)/);
930
+ const href = match?.[1];
931
+ if (!href)
932
+ continue;
933
+ let resolved;
934
+ try {
935
+ resolved = new URL(href, baseUrl);
936
+ }
937
+ catch {
938
+ continue;
939
+ }
940
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:")
941
+ continue;
942
+ const key = resolved.toString();
943
+ if (seen.has(key))
944
+ continue;
945
+ seen.add(key);
946
+ links.push(resolved);
947
+ }
948
+ return links;
949
+ }
950
+ /**
951
+ * Probes `<origin>/llms.txt` and, if present, returns its parsed link list.
952
+ * Reuses `fetchWebsiteResponse` so the manifest fetch itself gets the exact
953
+ * same SSRF host guard, redirect handling, and (via `robots`) robots.txt
954
+ * compliance as any other page fetch — this is still a fetch against a
955
+ * user-supplied host, no different from the rest of the crawl.
956
+ *
957
+ * `llms-full.txt` (the single-file concatenation of every page) is
958
+ * deliberately NOT read here. Its `## <path>` separators are ambiguous — page
959
+ * content legitimately contains `##` headings too — so recovered page
960
+ * boundaries can't be trusted, whereas per-page fetches through the existing
961
+ * pipeline are cheap, bounded by this author-curated list, and produce
962
+ * cleanly addressable assets. See the issue's "alternatives considered".
963
+ */
964
+ async function fetchLlmsManifest(start, robots, options) {
965
+ const manifestUrl = new URL("/llms.txt", start.origin).toString();
966
+ const decision = await resolveCrawlRobotsDecision(robots, manifestUrl);
967
+ if (!decision.allowed)
968
+ return null;
969
+ let fetched;
970
+ try {
971
+ fetched = await fetchWebsiteResponse(decision.fetchUrl, 0, {
972
+ allowPrivateHosts: options.allowPrivateHosts,
973
+ signal: options.signal,
974
+ robots,
975
+ });
976
+ }
977
+ catch {
978
+ return null;
979
+ }
980
+ if (!fetched.response.ok) {
981
+ await fetched.response.body?.cancel().catch(() => undefined);
982
+ return null;
983
+ }
984
+ let text;
985
+ try {
986
+ text = await readBodyWithByteCap(fetched.response, LLMS_TXT_BYTE_CAP, {
987
+ bodyTimeoutMs: LLMS_TXT_BODY_TIMEOUT_MS,
988
+ signal: options.signal,
989
+ });
990
+ }
991
+ catch {
992
+ return null;
993
+ }
994
+ const links = parseLlmsTxtLinks(text, fetched.finalUrl);
995
+ if (links.length === 0)
996
+ return null;
997
+ return { manifestUrl: fetched.finalUrl, links };
998
+ }
873
999
  function normalizeCrawlUrl(rawUrl) {
874
1000
  try {
875
1001
  const parsed = new URL(rawUrl);
@@ -10,10 +10,12 @@
10
10
  * import their opener from a sibling here instead of reaching up into the
11
11
  * indexer — inverting the old storage→indexer arrow.
12
12
  */
13
+ import fs from "node:fs";
13
14
  import { createRequire } from "node:module";
14
15
  import { ConfigError } from "../../core/errors.js";
15
16
  import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
16
17
  import { getDbPath } from "../../core/paths.js";
18
+ import { warn } from "../../core/warn.js";
17
19
  import { openDatabase } from "../database.js";
18
20
  import { openManagedDatabase } from "../managed-db.js";
19
21
  import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
@@ -21,9 +23,23 @@ import { openSqliteReadSnapshot } from "../sqlite-read-snapshot.js";
21
23
  import { isCanonicalIndexGeneration } from "./index-entry-schema.js";
22
24
  import { ensureSchema } from "./index-schema.js";
23
25
  import { loadVecExtension, warnIfVecMissing } from "./index-vec-repository.js";
26
+ /**
27
+ * Whether `error` is SQLite reporting on-disk corruption (`SQLITE_CORRUPT`,
28
+ * "database disk image is malformed") rather than a permission, lock, or
29
+ * schema problem. Matched on both `code` (bun:sqlite, better-sqlite3) and
30
+ * message text, since driver error shapes are not perfectly uniform.
31
+ */
32
+ function isCorruptionError(error) {
33
+ const code = error?.code;
34
+ if (code === "SQLITE_CORRUPT")
35
+ return true;
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return message.includes("database disk image is malformed") || message.includes("SQLITE_CORRUPT");
38
+ }
24
39
  export function openIndexDatabase(dbPath, options) {
25
- return openManagedDatabase({
26
- path: dbPath ?? getDbPath(),
40
+ const resolvedPath = dbPath ?? getDbPath();
41
+ const spec = {
42
+ path: resolvedPath,
27
43
  init: (db) => {
28
44
  // Try to load sqlite-vec extension
29
45
  loadVecExtension(db);
@@ -41,7 +57,33 @@ export function openIndexDatabase(dbPath, options) {
41
57
  // Warn once at init if using JS fallback with many entries
42
58
  warnIfVecMissing(db, { once: true });
43
59
  },
44
- });
60
+ };
61
+ try {
62
+ return openManagedDatabase(spec);
63
+ }
64
+ catch (error) {
65
+ // index.db is a derived cache, fully regenerable from the stash on disk
66
+ // (see src/core/state-db.ts's "Why a separate database from index.db"
67
+ // note) — so real on-disk corruption is recovered by deleting the file
68
+ // and rebuilding, not by surfacing a raw SQLITE_CORRUPT to the caller or
69
+ // quietly falling through to an unreadable index. This mirrors the
70
+ // existing stale-version-marker rebuild below, one layer further down
71
+ // (that path opens fine and rewrites tables in place; corruption prevents
72
+ // even opening, so the file itself has to go first).
73
+ if (!isCorruptionError(error))
74
+ throw error;
75
+ warn(`Index database is corrupt at ${resolvedPath} — rebuilding.`);
76
+ for (const suffix of ["", "-wal", "-shm"]) {
77
+ try {
78
+ fs.rmSync(`${resolvedPath}${suffix}`, { force: true });
79
+ }
80
+ catch {
81
+ // Best-effort cleanup; the retried open below still fails loudly if
82
+ // the file could not actually be removed.
83
+ }
84
+ }
85
+ return openManagedDatabase(spec);
86
+ }
45
87
  }
46
88
  /**
47
89
  * Read the operator-configured embedding dimension from the on-disk config.
@@ -31,7 +31,7 @@ import { getTaskLogDir } from "../../core/paths.js";
31
31
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
32
32
  import { parseSchedule, translateToCron } from "../schedule.js";
33
33
  import { assertSchedulerExecutionEvidenceDigest, assertSchedulerExpectationIdentity, assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, assertSchedulerNativeArtifactOwner, assertSchedulerRemovalArtifact, assertSchedulerRollbackArtifactCardinality, schedulerBindingNativeId, schedulerLogicalBindingId, schedulerLogicalBindingOwner, schedulerNativeArtifactKey, } from "../scheduler-binding.js";
34
- import { buildScheduledBindingInvocation, parseScheduledBindingArgv, resolveScheduledTaskContext, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
34
+ import { buildScheduledBindingInvocation, parsePublicSchedulerInvocation, parseScheduledBindingArgv, resolveScheduledTaskContext, SCHEDULER_CONTEXT_ARG, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
35
35
  import { nodeFs, throwIfNotOk } from "./exec-utils.js";
36
36
  const BEGIN = (id) => `# akm:task ${assertCronValue(id)} BEGIN`;
37
37
  const END = (id) => `# akm:task ${assertCronValue(id)} END`;
@@ -105,7 +105,7 @@ export function CRON_BACKEND(options = {}) {
105
105
  replaceCrontab(exec, existing, next);
106
106
  },
107
107
  list() {
108
- return [...inspectCronState(readCrontab(exec)).installed];
108
+ return [...inspectCronState(readCrontab(exec), defaultContextPath).installed];
109
109
  },
110
110
  listForRebind() {
111
111
  const existing = readCrontab(exec);
@@ -123,14 +123,14 @@ export function CRON_BACKEND(options = {}) {
123
123
  });
124
124
  },
125
125
  listNativeArtifacts() {
126
- return [...inspectCronState(readCrontab(exec)).artifacts];
126
+ return [...inspectCronState(readCrontab(exec), defaultContextPath).artifacts];
127
127
  },
128
128
  inspectBindings() {
129
- return inspectCronState(readCrontab(exec));
129
+ return inspectCronState(readCrontab(exec), defaultContextPath);
130
130
  },
131
131
  snapshotBindings(ids) {
132
132
  const crontab = readCrontab(exec);
133
- const inspection = inspectCronState(crontab);
133
+ const inspection = inspectCronState(crontab, defaultContextPath);
134
134
  const keys = new Set(ids.map(schedulerNativeArtifactKey));
135
135
  return Object.freeze({
136
136
  kind: CRON_SNAPSHOT,
@@ -144,7 +144,7 @@ export function CRON_BACKEND(options = {}) {
144
144
  throw new ConfigError("Invalid cron scheduler snapshot.", "INVALID_CONFIG_FILE");
145
145
  }
146
146
  const existing = readCrontab(exec);
147
- const current = inspectCronState(existing);
147
+ const current = inspectCronState(existing, defaultContextPath);
148
148
  const safeNativeIds = [];
149
149
  const errors = [];
150
150
  if (expectedCurrent) {
@@ -192,7 +192,7 @@ export function CRON_BACKEND(options = {}) {
192
192
  },
193
193
  };
194
194
  }
195
- function inspectCronState(crontab) {
195
+ function inspectCronState(crontab, fallbackContextPath) {
196
196
  const installed = [];
197
197
  const artifacts = [];
198
198
  for (const { id, body } of listBlocks(crontab)) {
@@ -207,7 +207,14 @@ function inspectCronState(crontab) {
207
207
  signature: fingerprint,
208
208
  ...(parsed.target !== undefined ? { target: parsed.target } : {}),
209
209
  binding: parsed.binding,
210
- contextPath: parsed.contextPath,
210
+ // A legacy (pre-`--scheduler-context`) row has no real descriptor
211
+ // path to report — `extractLegacyCronInvocation` leaves it "". Fall
212
+ // back to the current default so downstream consumers (context
213
+ // validation in `akm task prune`/`explain`, `sync`'s reuse of an
214
+ // existing binding's contextPath) see a real, resolvable descriptor
215
+ // rather than an empty path, since the row is about to be reconciled
216
+ // to a current one anyway (#881).
217
+ contextPath: parsed.contextPath || fallbackContextPath,
211
218
  };
212
219
  Object.defineProperty(ref, "nativeId", { value: id });
213
220
  Object.defineProperty(ref, "invocation", { value: Object.freeze([...parsed.invocation]) });
@@ -323,7 +330,40 @@ export function extractCronInvocation(body) {
323
330
  const redirectIndex = fields.indexOf(">>", commandStart);
324
331
  if (redirectIndex === -1)
325
332
  return undefined;
326
- return parseScheduledBindingArgv(fields.slice(commandStart, redirectIndex));
333
+ const tail = fields.slice(commandStart, redirectIndex);
334
+ const parsed = parseScheduledBindingArgv(tail);
335
+ if (parsed)
336
+ return parsed;
337
+ // Rows written by akm < 0.9.2 (before `--scheduler-context` existed) have
338
+ // no context argument at all — just the akm argv immediately followed by
339
+ // the public `task run …` / `workflow run …` tail. `extractCronInvocation`
340
+ // only ever runs on a body already isolated between this backend's own
341
+ // `# akm:task … BEGIN/END` sentinels (see `parseBlocks`), so recognizing
342
+ // this older shape here doesn't extend trust to any unmarked crontab
343
+ // line — it only lets sync see and reconcile a row akm already owns
344
+ // instead of treating it as absent and colliding with the still-present
345
+ // artifact (#881). Guarded on the marker's absence so a row that DOES
346
+ // carry `--scheduler-context` but fails to parse for some other reason
347
+ // is never silently reinterpreted as legacy.
348
+ if (tail.includes(SCHEDULER_CONTEXT_ARG))
349
+ return undefined;
350
+ return extractLegacyCronInvocation(tail);
351
+ }
352
+ function extractLegacyCronInvocation(tail) {
353
+ for (let index = 0; index < tail.length - 1; index += 1) {
354
+ if ((tail[index] === "task" || tail[index] === "workflow") && tail[index + 1] === "run") {
355
+ const publicInvocation = parsePublicSchedulerInvocation(tail.slice(index));
356
+ if (!publicInvocation)
357
+ return undefined;
358
+ return {
359
+ binding: tail.slice(0, index),
360
+ contextPath: "",
361
+ invocation: publicInvocation.invocation,
362
+ ...(publicInvocation.target !== undefined ? { target: publicInvocation.target } : {}),
363
+ };
364
+ }
365
+ }
366
+ return undefined;
327
367
  }
328
368
  /** Reverse {@link quoteForCron} for a single whitespace-free token. */
329
369
  function splitCronShellWords(value) {
@@ -10,7 +10,11 @@
10
10
  * Resolution order:
11
11
  *
12
12
  * 1. `process.execPath` alone for a Bun standalone executable.
13
- * 2. Absolute Node plus the public `dist/akm` package launcher.
13
+ * 2. Absolute Node plus the public `dist/akm` package launcher — eligible
14
+ * when it is the active npm global install, or when this process
15
+ * cannot write to the directory containing it (a read-only mount, e.g.
16
+ * an image-baked install, gives the same "won't change out from under
17
+ * the scheduler" guarantee npm-global ownership does).
14
18
  * 3. Absolute runtime plus the source/build CLI entry, classified as a
15
19
  * checkout that requires explicit `--rebind` for scheduler writes.
16
20
  *
@@ -51,12 +55,13 @@ export function resolveAkmInvocation(options = {}) {
51
55
  }
52
56
  }
53
57
  const npmGlobal = !checkout && packageBelongsToNpmGlobalRoot(launcherPath, npmGlobalRoot);
58
+ const readOnlyInstall = !checkout && !npmGlobal && !(options.isPathWritable ?? isPathWritable)(path.dirname(launcherPath));
54
59
  const kind = checkout ? "checkout" : npmGlobal ? "npm" : "package-local";
55
60
  return {
56
61
  argv: [absoluteInvocationPath(nodePath), absoluteInvocationPath(launcherPath)],
57
62
  via: kind,
58
63
  kind,
59
- eligible: npmGlobal,
64
+ eligible: npmGlobal || readOnlyInstall,
60
65
  };
61
66
  }
62
67
  const checkoutEntry = resolveCheckoutEntry(options.cliEntryUrl ?? import.meta.url, runtime, mainPath);
@@ -117,6 +122,16 @@ function isCheckoutLauncher(file) {
117
122
  const packageRoot = path.dirname(path.dirname(launcher));
118
123
  return fs.existsSync(path.join(packageRoot, ".git"));
119
124
  }
125
+ /** Whether this process can write to `dir` — false also covers a read-only mount. */
126
+ function isPathWritable(dir) {
127
+ try {
128
+ fs.accessSync(dir, fs.constants.W_OK);
129
+ return true;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
120
135
  function packageBelongsToNpmGlobalRoot(launcherPath, npmGlobalRoot) {
121
136
  if (!npmGlobalRoot)
122
137
  return false;
@@ -189,7 +189,14 @@ export function parseScheduledBindingArgv(argv) {
189
189
  ...(publicInvocation.target !== undefined ? { target: publicInvocation.target } : {}),
190
190
  };
191
191
  }
192
- function parsePublicSchedulerInvocation(invocation) {
192
+ /**
193
+ * Parse just the public `task run …` / `workflow run …` tail, with no
194
+ * `--scheduler-context` wrapper. Exported so a backend can recognize a
195
+ * pre-`--scheduler-context` invocation still sitting inside akm's own
196
+ * ownership-marked block (see `extractCronInvocation` in
197
+ * `src/tasks/backends/cron.ts`) without re-implementing this grammar.
198
+ */
199
+ export function parsePublicSchedulerInvocation(invocation) {
193
200
  if (invocation[0] === "task" && invocation[1] === "run" && invocation[2]) {
194
201
  try {
195
202
  if (normaliseTaskConceptId(invocation[2]) !== invocation[2])
@@ -17,7 +17,7 @@
17
17
  * | root `version` | outcome |
18
18
  * |------------------------|-----------------------------------------------------------------|
19
19
  * | `4` | `parseTaskSourceV4Document` — the new grammar (row B-13) |
20
- * | `2` or `3` | in-memory read shim (below): the SAME pure planners `akm migrate apply` uses (`./task-to-v3.ts`, `./task-to-v4.ts`) convert the bytes already in hand to v4 in memory; the result is parsed and returned with a one-line stderr deprecation warning. If the deterministic conversion itself fails (an unmigratable shape), falls back to `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming the migrator (B-14/B-15) — the shim removes friction for the deterministic case, it never hides a real problem |
20
+ * | `2` or `3` | in-memory read shim (below): the SAME pure planners `akm migrate apply` uses (`./task-to-v3.ts`, `./task-to-v4.ts`) convert the bytes already in hand to v4 in memory; the result is parsed and returned with a one-line stderr deprecation warning. If the deterministic conversion itself fails (an unmigratable shape — the file needs a human decision, not a re-run), falls back to `TASK_SCHEMA_VERSION_UNSUPPORTED` naming the specific blocked reason (issue #869) — the shim removes friction for the deterministic case, it never hides a real problem |
21
21
  * | any other number | `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming the migrator (B-14/B-15) |
22
22
  * | absent / not a number | `parseTaskSourceV4Document` — its own `TASK_SOURCE_INVALID` "version is required and must be 4" / "must be exactly 4" wording (row B-16) |
23
23
  *
@@ -57,6 +57,18 @@ export function peekTaskSourceVersion(root) {
57
57
  return typeof value === "number" ? value : undefined;
58
58
  }
59
59
  const TASK_MIGRATE_HINT = "Run `akm migrate apply --dry-run` to preview the task-v3 to task-source-v4 conversion, then run `akm migrate apply`.";
60
+ /**
61
+ * Thrown only when the deterministic conversion itself could not produce a
62
+ * task source v4 document — a case where a person must decide the intended
63
+ * behavior (e.g. an ambiguous shell command), not one the migrator can just
64
+ * be re-run to fix. `reason`/`detail` are the SAME blocked outcome
65
+ * `akm migrate status`/`apply` reports for this file, so the message names
66
+ * the actual decision instead of pointing at a command that will report the
67
+ * identical block.
68
+ */
69
+ function unmigratableVersionError(filePath, version, reason, detail) {
70
+ return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version} and needs a human decision before it can run — the deterministic migrator cannot convert it automatically (${reason}${detail ? `: ${detail}` : ""}).`, "TASK_SCHEMA_VERSION_UNSUPPORTED", "Review the file and resolve the ambiguity by hand, then it will convert normally; `akm migrate status` reports the same reason.");
71
+ }
60
72
  function unsupportedVersionError(filePath, version) {
61
73
  return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
62
74
  }
@@ -64,9 +76,10 @@ function unsupportedVersionError(filePath, version) {
64
76
  * Plan the SAME bytes already in hand through the pure v3->v4 (and, for v2,
65
77
  * chained v2->v3->v4) migration planner(s) — never touches disk, never
66
78
  * writes the file, never re-reads it from disk. Returns the produced v4
67
- * YAML text, or `undefined` when the deterministic conversion cannot
68
- * proceed (an unmigratable v2/v3 shape) — the caller falls back to the same
69
- * hard error this gate threw before the shim existed.
79
+ * YAML text, or the blocked reason/detail when the deterministic conversion
80
+ * cannot proceed (an unmigratable v2/v3 shape) — the caller falls back to
81
+ * the same hard error this gate threw before the shim existed, now naming
82
+ * that reason.
70
83
  */
71
84
  function planInMemoryV4Bytes(version, yaml, filePath, workspaceRoot) {
72
85
  const bytes = Buffer.from(yaml, "utf8");
@@ -89,12 +102,12 @@ function planInMemoryV4Bytes(version, yaml, filePath, workspaceRoot) {
89
102
  else {
90
103
  const v3Outcome = planTaskToV3File(baseInput);
91
104
  if (v3Outcome.status !== "changed")
92
- return undefined;
105
+ return { reason: v3Outcome.reason, detail: v3Outcome.detail };
93
106
  v3Bytes = v3Outcome.after;
94
107
  }
95
108
  const v4Outcome = planTaskToV4File({ ...baseInput, bytes: v3Bytes });
96
109
  if (v4Outcome.status !== "changed")
97
- return undefined;
110
+ return { reason: v4Outcome.reason, detail: v4Outcome.detail };
98
111
  return v4Outcome.after.toString("utf8");
99
112
  }
100
113
  /** Parse task source YAML, routing per the terminal table above. */
@@ -103,16 +116,17 @@ export function parseTaskSource(input) {
103
116
  const version = peekTaskSourceVersion(root);
104
117
  if (version !== undefined && version !== TASK_SOURCE_V4_VERSION) {
105
118
  if (version === 2 || version === 3) {
106
- const v4Yaml = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
107
- if (v4Yaml !== undefined) {
119
+ const shimmed = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
120
+ if (typeof shimmed === "string") {
108
121
  const v4 = parseTaskSourceV4({
109
- yaml: v4Yaml,
122
+ yaml: shimmed,
110
123
  filePath: input.filePath,
111
124
  ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
112
125
  });
113
126
  warn(`akm: task ${input.filePath} uses schema v${version} — auto-read as v4; run \`akm migrate apply\` to rewrite it and silence this`);
114
127
  return Object.freeze({ version: 4, v4 });
115
128
  }
129
+ throw unmigratableVersionError(input.filePath, version, shimmed.reason, shimmed.detail);
116
130
  }
117
131
  throw unsupportedVersionError(input.filePath, version);
118
132
  }
@@ -2032,7 +2032,9 @@ when one exists.
2032
2032
  Scan bundle markdown files for structural issues: unquoted colons, missing
2033
2033
  `updated` field, orphaned stubs, placeholder stubs, missing `name`/`type`,
2034
2034
  stale paths, and broken refs — in body text and in
2035
- `refs`/`xrefs`/`supersededBy`/`contradictedBy` frontmatter. Also reports
2035
+ `refs`/`xrefs`/`supersededBy`/`contradictedBy` frontmatter. A belief edge
2036
+ pointing at a memory that `akm improve` pruned resolves through the archive
2037
+ tombstone under `.akm/memory-cleanup/archive/` and is not reported (#884). Also reports
2036
2038
  `dangerous-env-key` findings for env files (the same key set `akm bundle add`
2037
2039
  enforces — see [Dangerous env key audit](#dangerous-env-key-audit) — but
2038
2040
  non-blocking here; `lint` only warns). `--type workflows` structurally parses
@@ -2046,6 +2048,7 @@ akm lint --fix # Auto-fix Tier-1 issues in place
2046
2048
  akm lint --type workflows # Only lint one asset type
2047
2049
  akm lint --dir ~/other-bundle # Override the bundle root (default: from config)
2048
2050
  akm lint --fail-on-flagged # CI-friendly: exit non-zero when summary.flagged > 0
2051
+ akm lint --prune-dangling-edges # Opt-in: drop belief edges whose target is gone
2049
2052
  ```
2050
2053
 
2051
2054
  | Flag | Description |
@@ -2054,6 +2057,7 @@ akm lint --fail-on-flagged # CI-friendly: exit non-zero when summary.flagge
2054
2057
  | `--dir` | Override the bundle root directory (default: from config) |
2055
2058
  | `--type` | Only lint assets of this type (e.g. `workflows`, `tasks`, `memories`). **akm bundles only** — every other adapter validates the whole bundle and warns on stderr that the flag had no effect. |
2056
2059
  | `--fail-on-flagged` | Exit non-zero when `summary.flagged > 0`. Default: exit 0 regardless of findings. |
2060
+ | `--prune-dangling-edges` | Opt-in repair (#884): drop `supersededBy`/`contradictedBy` entries whose target has neither a file nor a prune tombstone. **Not** implied by `--fix` — it edits well-formed files to delete a belief-graph claim, so review a plain `akm lint` report first. Clears the same `writable: false` gate `--fix` does. |
2057
2061
 
2058
2062
  Returns `fixed[]` and `flagged[]` arrays plus a `summary: { fixed, flagged }`
2059
2063
  count. Each entry carries `file`, `issue`, `detail`, and whether it was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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": [