akm-cli 0.9.6 → 0.9.8-beta.1

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 (135) hide show
  1. package/CHANGELOG.md +408 -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/env/env.js +2 -21
  8. package/dist/commands/health/checks.js +0 -25
  9. package/dist/commands/health/improve-metrics.js +8 -34
  10. package/dist/commands/health/windows.js +0 -4
  11. package/dist/commands/health.js +1 -35
  12. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  13. package/dist/commands/improve/extract.js +36 -32
  14. package/dist/commands/improve/improve-cli.js +1 -1
  15. package/dist/commands/improve/improve-strategies.js +0 -4
  16. package/dist/commands/improve/memory/memory-belief.js +15 -5
  17. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  18. package/dist/commands/improve/memory/memory-improve.js +9 -20
  19. package/dist/commands/improve/preparation.js +53 -37
  20. package/dist/commands/improve/reflect.js +14 -18
  21. package/dist/commands/lint/base-linter.js +182 -19
  22. package/dist/commands/lint/index.js +21 -9
  23. package/dist/commands/migrate/config-extra-params.js +61 -0
  24. package/dist/commands/migrate/dead-residue.js +113 -0
  25. package/dist/commands/migrate/stale-txn.js +49 -0
  26. package/dist/commands/migrate-cli.js +42 -1
  27. package/dist/commands/proposal/proposal.js +1 -21
  28. package/dist/commands/proposal/repository.js +0 -4
  29. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  30. package/dist/commands/read/curate.js +51 -18
  31. package/dist/commands/read/search-cli.js +24 -1
  32. package/dist/commands/read/show.js +2 -1
  33. package/dist/commands/sources/installed-stashes.js +5 -1
  34. package/dist/commands/sources/self-update.js +38 -1
  35. package/dist/commands/sources/sources-cli.js +17 -1
  36. package/dist/commands/tasks/tasks.js +0 -8
  37. package/dist/commands/url-checker.js +20 -28
  38. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  39. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  40. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  41. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  42. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  43. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  44. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  45. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  46. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  47. package/dist/core/adapter/adapters/shared.js +2 -0
  48. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  49. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  50. package/dist/core/adapter/validate-context.js +1 -3
  51. package/dist/core/asset/asset-placement.js +14 -5
  52. package/dist/core/asset/frontmatter.js +212 -0
  53. package/dist/core/asset/memory-archive.js +97 -0
  54. package/dist/core/common.js +66 -2
  55. package/dist/core/config/config-walker.js +6 -10
  56. package/dist/core/config/config.js +11 -8
  57. package/dist/core/config/engine-semantics.js +0 -2
  58. package/dist/core/extra-params.js +17 -13
  59. package/dist/core/improve-result.js +1 -3
  60. package/dist/core/json-schema.js +9 -11
  61. package/dist/core/state/migrations.js +52 -2
  62. package/dist/core/state-db.js +2 -1
  63. package/dist/execution/executable-identity.js +1 -3
  64. package/dist/execution/guarded-source.js +1 -6
  65. package/dist/indexer/bundle-identity-guard.js +6 -1
  66. package/dist/indexer/db/graph-db.js +139 -154
  67. package/dist/indexer/ensure-index.js +11 -19
  68. package/dist/indexer/graph/graph-boost.js +23 -34
  69. package/dist/indexer/graph/graph-extraction.js +12 -2
  70. package/dist/indexer/indexer.js +1 -1
  71. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  72. package/dist/indexer/passes/memory-inference.js +7 -2
  73. package/dist/indexer/scan/drain-dir.js +2 -4
  74. package/dist/indexer/search/db-search.js +3 -3
  75. package/dist/indexer/search/fts-query.js +10 -15
  76. package/dist/indexer/search/search-source.js +0 -13
  77. package/dist/indexer/usage/usage-events.js +9 -1
  78. package/dist/indexer/walk/walker.js +11 -6
  79. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  80. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  81. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  82. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  83. package/dist/integrations/harnesses/index.js +0 -4
  84. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  85. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  86. package/dist/integrations/lockfile.js +0 -14
  87. package/dist/integrations/session-logs/index.js +0 -81
  88. package/dist/llm/client.js +0 -12
  89. package/dist/llm/memory-infer.js +1 -3
  90. package/dist/llm/usage-telemetry.js +1 -3
  91. package/dist/registry/create-provider-registry.js +4 -0
  92. package/dist/registry/factory.js +4 -0
  93. package/dist/registry/resolve.js +23 -8
  94. package/dist/runtime.js +0 -12
  95. package/dist/scripts/akm-migrate-node.js +155 -251
  96. package/dist/scripts/akm-migrate.js +155 -251
  97. package/dist/setup/setup.js +6 -12
  98. package/dist/sources/providers/git-install.js +7 -2
  99. package/dist/sources/providers/tar-utils.js +1 -7
  100. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  101. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -0
  102. package/dist/storage/managed-db.js +20 -7
  103. package/dist/storage/repositories/events-repository.js +0 -81
  104. package/dist/storage/repositories/index-connection.js +45 -3
  105. package/dist/storage/repositories/index-entries-repository.js +0 -17
  106. package/dist/storage/repositories/index-fts-repository.js +24 -30
  107. package/dist/storage/repositories/index-utility-repository.js +0 -57
  108. package/dist/storage/repositories/index-vec-repository.js +25 -27
  109. package/dist/storage/repositories/task-history-repository.js +9 -3
  110. package/dist/tasks/backends/cron.js +49 -9
  111. package/dist/tasks/backends/launchd.js +8 -18
  112. package/dist/tasks/resolve-akm-bin.js +17 -2
  113. package/dist/tasks/run/task-history.js +21 -31
  114. package/dist/tasks/scheduler-invocation.js +8 -1
  115. package/dist/tasks/scheduler-sync.js +1 -6
  116. package/dist/tasks/source/bounded-document.js +1 -14
  117. package/dist/tasks/source/parse-task-source.js +23 -9
  118. package/dist/workflows/exec/child-workflow.js +1 -1
  119. package/dist/workflows/exec/native-executor.js +2 -2
  120. package/dist/workflows/exec/step-work.js +5 -17
  121. package/dist/workflows/exec/worktree.js +40 -6
  122. package/dist/workflows/freeze/task-bindings.js +2 -4
  123. package/dist/workflows/ir/compile.js +3 -14
  124. package/dist/workflows/ir/schema-v4.js +4 -6
  125. package/dist/workflows/ir/schema.js +2 -5
  126. package/dist/workflows/parser.js +23 -26
  127. package/dist/workflows/source-files.js +8 -13
  128. package/dist/workflows/source-ir/schema.js +1 -19
  129. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  130. package/docs/reference/cli.md +7 -1
  131. package/docs/reference/workflow-schema.md +24 -0
  132. package/package.json +2 -5
  133. package/dist/assets/improve-strategies/frequent.json +0 -15
  134. package/dist/assets/improve-strategies/memory-focus.json +0 -15
  135. package/dist/workflows/source-ir/compare.js +0 -17
@@ -23,19 +23,13 @@
23
23
  */
24
24
  import path from "node:path";
25
25
  import { parseFrontmatter } from "../../asset/frontmatter.js";
26
+ import { toPosix } from "../../common.js";
26
27
  import { SCRIPT_EXTENSIONS } from "../../recognition-util.js";
27
- import { hashContent, nonEmptyString, readTags, runBaseValidateChecks } from "./shared.js";
28
+ import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js";
28
29
  /** A generic-files bundle is single-component; its one component is `main`. */
29
30
  const COMPONENT_ID = "main";
30
31
  /** Markdown / plain-text extensions classified as `document`. */
31
32
  const DOCUMENT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".text"]);
32
- /** OKF reserved structural files (D-R6) — excluded from the catch-all, case-insensitive. */
33
- const RESERVED_FILES = new Set(["index.md", "log.md"]);
34
- /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
35
- const MAX_CONTENT_CHARS = 100_000;
36
- function toPosix(p) {
37
- return p.replace(/\\/g, "/");
38
- }
39
33
  function isReserved(base) {
40
34
  return RESERVED_FILES.has(base.toLowerCase());
41
35
  }
@@ -69,7 +63,7 @@ function recognize(c, file) {
69
63
  adapterId: "generic-files",
70
64
  type,
71
65
  name,
72
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
66
+ content: body,
73
67
  };
74
68
  if (type !== "script")
75
69
  doc.ownsPresentation = true;
@@ -73,6 +73,7 @@ import fs from "node:fs";
73
73
  import path from "node:path";
74
74
  import { parse as parseYaml } from "yaml";
75
75
  import { parseFrontmatter, parseFrontmatterBlock } from "../../asset/frontmatter.js";
76
+ import { toPosix } from "../../common.js";
76
77
  import { hashContent, nonEmptyString } from "./shared.js";
77
78
  /** A wiki is a single-component bundle; its one component is conventionally `main` (recognition golden). */
78
79
  const WIKI_COMPONENT_ID = "main";
@@ -86,12 +87,7 @@ const RESERVED_ROOT_FILES = new Set(["schema.md", "index.md", "log.md"]);
86
87
  const RAW_SUBDIR = "raw";
87
88
  /** Content subdirectory holding agent-authored pages. */
88
89
  const PAGES_SUBDIR = "pages";
89
- /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
90
- const MAX_CONTENT_CHARS = 100_000;
91
90
  /** POSIX-normalize separators. */
92
- function toPosix(p) {
93
- return p.replace(/\\/g, "/");
94
- }
95
91
  /** conceptId = component-root-relative path minus `.md`. */
96
92
  function conceptIdOf(relPath) {
97
93
  return toPosix(relPath).replace(/\.md$/i, "");
@@ -280,7 +276,7 @@ function recognize(c, file) {
280
276
  ownsPresentation: true,
281
277
  type: isRaw ? WIKI_SOURCE_TYPE : (fm.pageKind ?? DEFAULT_PAGE_KIND),
282
278
  name: lastSegment,
283
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
279
+ content: body,
284
280
  };
285
281
  if (fm.description !== undefined)
286
282
  doc.description = fm.description;
@@ -31,7 +31,8 @@
31
31
  import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import { parseFrontmatter } from "../../asset/frontmatter.js";
34
- import { hashContent, nonEmptyString, readTags, runBaseValidateChecks } from "./shared.js";
34
+ import { isRecord, toPosix } from "../../common.js";
35
+ import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js";
35
36
  /** v0.2 frontmatter keys consumed into first-class fields below (§0.1) — excluded from the generic `documentJson` extras fold alongside the v0.1 five, so nothing is duplicated between a first-class field and the opaque extras bag. */
36
37
  const CONSUMED_FRONTMATTER_KEYS = [
37
38
  "type",
@@ -46,17 +47,13 @@ const CONSUMED_FRONTMATTER_KEYS = [
46
47
  "stale_after",
47
48
  "okf_version",
48
49
  ];
49
- /** True for a plain (non-null, non-array) object — the shape every v0.2 mapping (`generated`, one `verified`/`sources` entry) must have. */
50
- function isPlainObject(value) {
51
- return value !== null && typeof value === "object" && !Array.isArray(value);
52
- }
53
50
  /**
54
51
  * Parse one `verified:` actor mapping (`{by, at?}`). Tolerant: a missing/blank
55
52
  * `by` yields `undefined` (the entry is dropped, never rejecting the document
56
53
  * — OKF conformance leniency); `at` is independently optional.
57
54
  */
58
55
  function parseActorMapping(value) {
59
- if (!isPlainObject(value))
56
+ if (!isRecord(value))
60
57
  return undefined;
61
58
  const by = nonEmptyString(value.by);
62
59
  if (by === undefined)
@@ -96,7 +93,7 @@ function parseOkfSources(value) {
96
93
  return undefined;
97
94
  const out = [];
98
95
  for (const item of value) {
99
- if (!isPlainObject(item))
96
+ if (!isRecord(item))
100
97
  continue;
101
98
  const resource = nonEmptyString(item.resource);
102
99
  if (resource === undefined)
@@ -125,15 +122,7 @@ function parseOkfSources(value) {
125
122
  function parseLifecycleStatus(value) {
126
123
  return value === "draft" || value === "stable" || value === "deprecated" ? value : undefined;
127
124
  }
128
- /** Reserved OKF files (case-insensitive) recognized, never indexed as concepts (§5, OKF §1.4). */
129
- const RESERVED_FILES = new Set(["index.md", "log.md"]);
130
- /** Upper bound on the bounded `content` FTS field (§3: "content: FTS 1 (bounded)"). Small fixtures are never truncated. */
131
- const MAX_CONTENT_CHARS = 100_000;
132
- /** POSIX-normalize separators without importing a cycle-participant helper. */
133
- function toPosix(p) {
134
- return p.replace(/\\/g, "/");
135
- }
136
- /** True when `name` (a bare file name) is a reserved OKF file, case-insensitively. */
125
+ /** True when `name` (a bare file name) is a reserved OKF file, case-insensitively (§5, OKF §1.4). */
137
126
  function isReservedFileName(name) {
138
127
  return RESERVED_FILES.has(name.toLowerCase());
139
128
  }
@@ -237,7 +226,7 @@ function recognize(c, file) {
237
226
  // `generated.at` — v0.2's replacement for `timestamp` — takes precedence;
238
227
  // `timestamp` remains a fully valid fallback (the v0.2-permitted legacy
239
228
  // reading, not merely tolerated). Both stay fully optional (never rejects).
240
- const generatedMapping = isPlainObject(data.generated) ? data.generated : undefined;
229
+ const generatedMapping = isRecord(data.generated) ? data.generated : undefined;
241
230
  const generatedAt = generatedMapping ? nonEmptyString(generatedMapping.at) : undefined;
242
231
  const generatedBy = generatedMapping ? nonEmptyString(generatedMapping.by) : undefined;
243
232
  const legacyTimestamp = nonEmptyString(data.timestamp);
@@ -272,7 +261,7 @@ function recognize(c, file) {
272
261
  ownsPresentation: true,
273
262
  type,
274
263
  name,
275
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
264
+ content: body,
276
265
  };
277
266
  if (description !== undefined)
278
267
  doc.description = description;
@@ -69,6 +69,8 @@ export function readTags(value) {
69
69
  const tags = value.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
70
70
  return tags.length > 0 ? tags : undefined;
71
71
  }
72
+ /** Reserved files (case-insensitive) every concrete adapter recognizes but never indexes as a concept. */
73
+ export const RESERVED_FILES = new Set(["index.md", "log.md"]);
72
74
  // ── Base validate checks (port of `BaseLinter.runBaseChecks`) ────────────────
73
75
  function checkMissingUpdated(data, frontmatterText) {
74
76
  return frontmatterText !== null && !("updated" in data);
@@ -49,21 +49,15 @@
49
49
  import path from "node:path";
50
50
  import { createAdapterExtensions, } from "../../../execution/source.js";
51
51
  import { parseFrontmatter } from "../../asset/frontmatter.js";
52
+ import { toPosix } from "../../common.js";
52
53
  import { executionDefaultsFromFrontmatter, renderMarkdownExecutionSource } from "../execution-source.js";
53
54
  import { skillDirectoryDiagnostics } from "./akm-lint.js";
54
- import { hashContent, nonEmptyString, readTags, runBaseValidateChecks } from "./shared.js";
55
- /** OKF reserved structural files (D-R6) — excluded at every depth, case-insensitive. */
56
- const RESERVED_FILES = new Set(["index.md", "log.md"]);
57
- /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
58
- const MAX_CONTENT_CHARS = 100_000;
55
+ import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js";
59
56
  /** The canonical (plural) subdir spellings writes normalize to (open-question-6). */
60
57
  const CANONICAL_COMMAND_DIR = "commands";
61
58
  const CANONICAL_AGENT_DIR = "agents";
62
59
  const CANONICAL_SKILL_DIR = "skills";
63
60
  const SKILL_MANIFEST = "SKILL.md";
64
- function toPosix(p) {
65
- return p.replace(/\\/g, "/");
66
- }
67
61
  function isReserved(base) {
68
62
  return RESERVED_FILES.has(base.toLowerCase());
69
63
  }
@@ -122,7 +116,7 @@ export function recognizeToolDir(layout, c, file) {
122
116
  adapterId: layout.adapterId,
123
117
  type: cls.type,
124
118
  name,
125
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
119
+ content: body,
126
120
  };
127
121
  if (cls.type === "instruction")
128
122
  doc.ownsPresentation = true;
@@ -28,6 +28,7 @@ import fs from "node:fs";
28
28
  import path from "node:path";
29
29
  import { parse as parseYaml } from "yaml";
30
30
  import { parseFrontmatter, parseFrontmatterBlock } from "../../asset/frontmatter.js";
31
+ import { toPosix } from "../../common.js";
31
32
  import { hashContent, nonEmptyString, runBaseValidateChecks } from "./shared.js";
32
33
  /** A snapshot bundle is single-component; its one component is `main`. */
33
34
  const COMPONENT_ID = "main";
@@ -37,11 +38,6 @@ const PAGES_PREFIX = "stash/knowledge/";
37
38
  const MANIFEST_FILE = "manifest.json";
38
39
  /** The tag the snapshot writer stamps on every crawled page. */
39
40
  const WEBSITE_TAG = "website";
40
- /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
41
- const MAX_CONTENT_CHARS = 100_000;
42
- function toPosix(p) {
43
- return p.replace(/\\/g, "/");
44
- }
45
41
  function parseSnapshotFrontmatter(raw) {
46
42
  const out = { tags: [] };
47
43
  const block = parseFrontmatterBlock(raw);
@@ -98,7 +94,7 @@ function recognize(c, file) {
98
94
  ownsPresentation: true,
99
95
  type: "website",
100
96
  name,
101
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
97
+ content: body,
102
98
  };
103
99
  if (fm.description !== undefined)
104
100
  doc.description = fm.description;
@@ -48,9 +48,7 @@ import fs from "node:fs";
48
48
  import path from "node:path";
49
49
  import { assetPathForName, stashDirFor } from "../asset/asset-placement.js";
50
50
  import { typeNameFromConceptId } from "../asset/resolve-ref.js";
51
- function toPosix(p) {
52
- return p.replace(/\\/g, "/");
53
- }
51
+ import { toPosix } from "../common.js";
54
52
  /** Build the overlay map, keyed by POSIX path relative to `root`. */
55
53
  function buildOverlay(root, changes) {
56
54
  const overlay = new Map();
@@ -25,10 +25,8 @@
25
25
  */
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
- import { SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
29
- function toPosix(input) {
30
- return input.replace(/\\/g, "/");
31
- }
28
+ import { toPosix } from "../common.js";
29
+ import { DERIVED_SUFFIX, SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
32
30
  const workflowSpec = {
33
31
  isRelevantFile: (fileName) => WORKFLOW_EXTENSIONS.includes(path.extname(fileName).toLowerCase()),
34
32
  toCanonicalName: (typeRoot, filePath) => {
@@ -247,10 +245,21 @@ export function assetPathForName(assetType, typeRoot, name) {
247
245
  * "default" alias is genuinely dual-owned: both `<dir>/.env` and
248
246
  * `<dir>/default.env` derive the same canonical name (`toCanonicalName`
249
247
  * above), so a physical-owner lookup must consider both without reading
250
- * either file. Every other placement type has exactly one inverse spelling.
248
+ * either file. `memory` has a second, analogous duality (#882): a ref to
249
+ * `<name>` may own either `<name>.md` or the LLM-inferred `<name>.derived.md`
250
+ * twin — `.derived` is a provenance marker on the SAME identity, not part of
251
+ * the name (see `resolveParentRef`/`isDerivedMemory` in
252
+ * `commands/improve/memory/derived-ref.ts`, and the belief-edge identity
253
+ * channel's own `memory:<name>.derived` refs). The plain `.md` file wins when
254
+ * both exist, so it stays `primary` — first in the returned list — and every
255
+ * caller here already prefers the first candidate that exists on disk. Every
256
+ * other placement type has exactly one inverse spelling.
251
257
  */
252
258
  export function assetPathCandidatesForName(assetType, typeRoot, name) {
253
259
  const primary = assetPathForName(assetType, typeRoot, name);
260
+ if (assetType === "memory" && !name.endsWith(DERIVED_SUFFIX)) {
261
+ return [primary, assetPathForName(assetType, typeRoot, `${name}${DERIVED_SUFFIX}`)];
262
+ }
254
263
  if (assetType !== "env")
255
264
  return [primary];
256
265
  const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;
@@ -207,6 +207,218 @@ export function spliceFrontmatterLine(raw, line) {
207
207
  lines.splice(closeIdx, 0, line);
208
208
  return lines.join("\n");
209
209
  }
210
+ /**
211
+ * Strip one layer of matching quotes — frontmatter list items are often quoted
212
+ * refs. Written as an explicit char compare rather than a backreference regex
213
+ * on purpose: `scripts/lint-repository-sql.ts`'s comment/string stripper has no
214
+ * regex-literal awareness, so a literal holding an ODD number of quote
215
+ * characters desyncs its state machine and corrupts every match after it.
216
+ */
217
+ function unquote(value) {
218
+ const trimmed = value.trim();
219
+ if (trimmed.length < 2)
220
+ return trimmed;
221
+ const first = trimmed[0];
222
+ if ((first === '"' || first === "'") && trimmed[trimmed.length - 1] === first)
223
+ return trimmed.slice(1, -1);
224
+ return trimmed;
225
+ }
226
+ /**
227
+ * Remove specific VALUES from one frontmatter list key, preserving every other
228
+ * byte — the counterpart to {@link spliceFrontmatterLine} for the
229
+ * `akm lint --prune-dangling-edges` repair (#884).
230
+ *
231
+ * Handles the three spellings a belief channel appears in: a block sequence
232
+ * (`contradictedBy:\n - a`), an inline flow (`contradictedBy: [a, b]`), and a
233
+ * bare scalar (`contradictedBy: a`). When every value under the key is removed
234
+ * the key itself goes too — an empty `contradictedBy: []` is not the same
235
+ * assertion as no edge at all.
236
+ *
237
+ * Returns the rewritten source, or `null` when `raw` has no well-formed
238
+ * frontmatter block or nothing matched, so the caller can leave the file
239
+ * untouched and report the finding unfixed. Deliberately source-preserving:
240
+ * these are user-authored memories, and a repair must not silently reformat
241
+ * the frontmatter it was not asked to touch.
242
+ */
243
+ export function removeFrontmatterListValues(raw, key, values) {
244
+ const remove = new Set(values.map((v) => unquote(v)));
245
+ if (remove.size === 0)
246
+ return null;
247
+ const lines = raw.split(/\r?\n/);
248
+ if (lines[0]?.trim() !== "---")
249
+ return null;
250
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
251
+ if (closeIdx === -1)
252
+ return null;
253
+ const out = [];
254
+ let changed = false;
255
+ let index = 0;
256
+ while (index < lines.length) {
257
+ const line = lines[index];
258
+ if (index === 0 || index >= closeIdx) {
259
+ out.push(line);
260
+ index += 1;
261
+ continue;
262
+ }
263
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
264
+ if (kv === null || kv[1] !== key) {
265
+ out.push(line);
266
+ index += 1;
267
+ continue;
268
+ }
269
+ const rest = kv[2].trim();
270
+ // Inline flow: contradictedBy: [a, b]
271
+ const flow = rest.match(/^\[(.*)\]$/);
272
+ if (flow !== null) {
273
+ const kept = flow[1]
274
+ .split(",")
275
+ .map((item) => item.trim())
276
+ .filter(Boolean)
277
+ .filter((item) => !remove.has(unquote(item)));
278
+ const original = flow[1].split(",").filter((s) => s.trim().length > 0).length;
279
+ if (kept.length !== original) {
280
+ changed = true;
281
+ if (kept.length > 0)
282
+ out.push(`${key}: [${kept.join(", ")}]`);
283
+ }
284
+ else {
285
+ out.push(line);
286
+ }
287
+ index += 1;
288
+ continue;
289
+ }
290
+ // Bare scalar: contradictedBy: a
291
+ if (rest !== "") {
292
+ if (remove.has(unquote(rest)))
293
+ changed = true;
294
+ else
295
+ out.push(line);
296
+ index += 1;
297
+ continue;
298
+ }
299
+ // Block sequence: the key line, then ` - value` items.
300
+ const header = line;
301
+ const items = [];
302
+ let cursor = index + 1;
303
+ while (cursor < closeIdx) {
304
+ const itemMatch = lines[cursor].match(/^\s+-\s*(.*)$/);
305
+ if (itemMatch === null)
306
+ break;
307
+ items.push(lines[cursor]);
308
+ cursor += 1;
309
+ }
310
+ const kept = items.filter((item) => !remove.has(unquote(item.replace(/^\s*-\s*/, ""))));
311
+ if (kept.length !== items.length) {
312
+ changed = true;
313
+ if (kept.length > 0) {
314
+ out.push(header);
315
+ out.push(...kept);
316
+ }
317
+ }
318
+ else {
319
+ out.push(header);
320
+ out.push(...items);
321
+ }
322
+ index = cursor;
323
+ }
324
+ return changed ? out.join("\n") : null;
325
+ }
326
+ /**
327
+ * Rewrite specific VALUES in one frontmatter list key to new spellings,
328
+ * preserving every other byte — the rename counterpart to
329
+ * {@link removeFrontmatterListValues}, used by `akm lint --fix` to migrate
330
+ * retired `type:slug` xref values (`xrefs:`/`supersededBy:`/`contradictedBy:`)
331
+ * to their conceptId form once resolution confirms the rewritten spelling
332
+ * still points at a real asset.
333
+ *
334
+ * Handles the same three spellings a belief channel appears in: a block
335
+ * sequence, an inline flow, and a bare scalar. `replacements` maps an
336
+ * unquoted OLD value to its NEW value; a value not present in the map is left
337
+ * untouched byte-for-byte (including any quoting it had).
338
+ *
339
+ * Returns the rewritten source, or `null` when `raw` has no well-formed
340
+ * frontmatter block or nothing matched, so the caller can leave the file
341
+ * untouched. Deliberately source-preserving, like its sibling.
342
+ */
343
+ export function rewriteFrontmatterListValue(raw, key, replacements) {
344
+ if (replacements.size === 0)
345
+ return null;
346
+ const lines = raw.split(/\r?\n/);
347
+ if (lines[0]?.trim() !== "---")
348
+ return null;
349
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
350
+ if (closeIdx === -1)
351
+ return null;
352
+ const out = [];
353
+ let changed = false;
354
+ let index = 0;
355
+ while (index < lines.length) {
356
+ const line = lines[index];
357
+ if (index === 0 || index >= closeIdx) {
358
+ out.push(line);
359
+ index += 1;
360
+ continue;
361
+ }
362
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
363
+ if (kv === null || kv[1] !== key) {
364
+ out.push(line);
365
+ index += 1;
366
+ continue;
367
+ }
368
+ const rest = kv[2].trim();
369
+ // Inline flow: xrefs: [a, b]
370
+ const flow = rest.match(/^\[(.*)\]$/);
371
+ if (flow !== null) {
372
+ const items = flow[1].split(",").map((item) => item.trim());
373
+ const nextItems = items.map((item) => {
374
+ if (!item)
375
+ return item;
376
+ const replacement = replacements.get(unquote(item));
377
+ if (replacement === undefined)
378
+ return item;
379
+ changed = true;
380
+ return replacement;
381
+ });
382
+ out.push(items.length > 0 && items[0] !== "" ? `${kv[1]}: [${nextItems.join(", ")}]` : line);
383
+ index += 1;
384
+ continue;
385
+ }
386
+ // Bare scalar: xrefs: a
387
+ if (rest !== "") {
388
+ const replacement = replacements.get(unquote(rest));
389
+ if (replacement === undefined) {
390
+ out.push(line);
391
+ }
392
+ else {
393
+ changed = true;
394
+ out.push(`${kv[1]}: ${replacement}`);
395
+ }
396
+ index += 1;
397
+ continue;
398
+ }
399
+ // Block sequence: the key line, then ` - value` items.
400
+ out.push(line);
401
+ let cursor = index + 1;
402
+ while (cursor < closeIdx) {
403
+ const itemLine = lines[cursor];
404
+ const itemMatch = itemLine.match(/^(\s*-\s*)(.*)$/);
405
+ if (itemMatch === null)
406
+ break;
407
+ const [, prefix, value] = itemMatch;
408
+ const replacement = replacements.get(unquote(value));
409
+ if (replacement === undefined) {
410
+ out.push(itemLine);
411
+ }
412
+ else {
413
+ changed = true;
414
+ out.push(`${prefix}${replacement}`);
415
+ }
416
+ cursor += 1;
417
+ }
418
+ index = cursor;
419
+ }
420
+ return changed ? out.join("\n") : null;
421
+ }
210
422
  /**
211
423
  * Parse a YAML scalar value (string, boolean, or number).
212
424
  *
@@ -0,0 +1,97 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The memory-cleanup archive as a REF-RESOLUTION surface (#884).
6
+ *
7
+ * `analyzeMemoryCleanup`'s prune (`commands/improve/memory/memory-improve.ts`
8
+ * `#archiveMemory`) does not delete a memory: it `rename`s the file under
9
+ * `.akm/memory-cleanup/archive/<stamp>-<ref>/<originalPath>` and writes a
10
+ * sibling `cleanup.md` audit asset carrying `ref` / `originalPath` /
11
+ * `archivedPath`. The bytes and the identity both survive — but the ref stops
12
+ * resolving at its ORIGINAL location, so every inbound belief edge
13
+ * (`contradictedBy` / `supersededBy`) pointing at the pruned memory becomes a
14
+ * `missing-ref` the moment #882 made those channels validatable.
15
+ *
16
+ * That is the #884 defect, and the archive already holds everything needed to
17
+ * fix it: the audit record IS a tombstone. This module reads those tombstones
18
+ * so ref resolution can answer "archived" instead of "missing". Resolution
19
+ * stays non-destructive — pruning never rewrites an unrelated memory's
20
+ * frontmatter, and the contradiction an edge records is preserved rather than
21
+ * erased (the concern #884 raised against a bare edge-scrub).
22
+ *
23
+ * A ref whose target has NO tombstone and no file is genuinely dangling — it
24
+ * was removed by something other than prune (a hand `git rm`, an older
25
+ * release). Those stay reported; clearing them mutates user data and so is
26
+ * gated behind `akm lint --prune-dangling-edges`.
27
+ */
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { parseFrontmatter } from "./frontmatter.js";
31
+ /** Stash-relative root the prune path archives into. Must match `memory-improve.ts#createArchiveDir`. */
32
+ export const MEMORY_ARCHIVE_REL = ".akm/memory-cleanup/archive";
33
+ /** Filename of the per-archive audit asset written alongside the archived memory. */
34
+ const AUDIT_FILENAME = "cleanup.md";
35
+ /**
36
+ * Cache keyed by stash root. A lint sweep resolves thousands of refs against a
37
+ * directory that only the prune path ever writes, so the scan runs once per
38
+ * root instead of once per missing ref.
39
+ */
40
+ const cache = new Map();
41
+ /** @internal Drop the memoized scans — process-global state needs a reset seam for tests (#785). */
42
+ export function resetMemoryArchiveCache() {
43
+ cache.clear();
44
+ }
45
+ /**
46
+ * Every stash-relative `originalPath` archived under `root`, i.e. the set of
47
+ * paths that USED to hold a memory and now hold a tombstone instead.
48
+ *
49
+ * Unreadable or malformed audit records are skipped rather than thrown: a
50
+ * corrupt tombstone must degrade to "this ref is missing" (the pre-#884
51
+ * answer), never break the whole lint sweep.
52
+ */
53
+ export function archivedOriginalPaths(root) {
54
+ const cached = cache.get(root);
55
+ if (cached !== undefined)
56
+ return cached;
57
+ const paths = new Set();
58
+ const archiveRoot = path.join(root, MEMORY_ARCHIVE_REL);
59
+ let entries;
60
+ try {
61
+ entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
62
+ }
63
+ catch {
64
+ cache.set(root, paths); // no archive dir — nothing was ever pruned here
65
+ return paths;
66
+ }
67
+ for (const entry of entries) {
68
+ if (!entry.isDirectory())
69
+ continue;
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(path.join(archiveRoot, entry.name, AUDIT_FILENAME), "utf8");
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ let originalPath;
78
+ try {
79
+ originalPath = parseFrontmatter(raw).data.originalPath;
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (typeof originalPath === "string" && originalPath.trim().length > 0) {
85
+ paths.add(originalPath.trim().replace(/\\/g, "/"));
86
+ }
87
+ }
88
+ cache.set(root, paths);
89
+ return paths;
90
+ }
91
+ /**
92
+ * True when `relPath` (stash-relative, POSIX) names a memory that prune
93
+ * archived — the ref resolves to a tombstone rather than to nothing.
94
+ */
95
+ export function isArchivedRelPath(relPath, root) {
96
+ return archivedOriginalPaths(root).has(relPath.replace(/\\/g, "/"));
97
+ }
@@ -277,8 +277,14 @@ function isValidDirectory(dir) {
277
277
  try {
278
278
  return fs.statSync(dir).isDirectory();
279
279
  }
280
- catch {
281
- return false;
280
+ catch (error) {
281
+ // Genuinely absent — the caller's "not found" fallback applies. Any other
282
+ // stat failure (e.g. EACCES) is not "doesn't exist"; treating it as such
283
+ // produced the wrong message ("Run akm bundle create") for a directory
284
+ // that exists but cannot be read.
285
+ if (hasErrnoCode(error, "ENOENT"))
286
+ return false;
287
+ throw new ConfigError(`Unable to read bundle directory at "${dir}".`, "STASH_DIR_UNREADABLE");
282
288
  }
283
289
  }
284
290
  /**
@@ -356,6 +362,10 @@ function readStashDirFromConfig() {
356
362
  export function toPosix(input) {
357
363
  return input.replace(/\\/g, "/");
358
364
  }
365
+ /** Locale-independent code-point ordering — a stable `Array.prototype.sort` comparator for strings (paths, names, ids). */
366
+ export function compareCodePoints(left, right) {
367
+ return left < right ? -1 : left > right ? 1 : 0;
368
+ }
359
369
  export function hasErrnoCode(error, code) {
360
370
  if (typeof error !== "object" || error === null || !("code" in error))
361
371
  return false;
@@ -873,7 +883,61 @@ export function asNonEmptyString(value) {
873
883
  const trimmed = value.trim();
874
884
  return trimmed.length > 0 ? trimmed : undefined;
875
885
  }
886
+ // ── env-file assignment scanning ─────────────────────────────────────────────
887
+ /** Matches a `KEY=value` assignment line, capturing only the key. */
888
+ export const ENV_ASSIGN_LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
889
+ /** Scan lines and return KEY names in file order, without duplicates. */
890
+ export function scanEnvKeyNames(text) {
891
+ const keys = [];
892
+ const seen = new Set();
893
+ for (const line of text.split(/\r?\n/)) {
894
+ const m = line.match(ENV_ASSIGN_LINE_RE);
895
+ if (!m)
896
+ continue;
897
+ const key = m[1];
898
+ if (!key)
899
+ continue;
900
+ if (seen.has(key))
901
+ continue;
902
+ seen.add(key);
903
+ keys.push(key);
904
+ }
905
+ return keys;
906
+ }
907
+ /** True when `value` contains no lone/unpaired UTF-16 surrogate (every surrogate is part of a valid pair). */
908
+ export function wellFormedUnicode(value) {
909
+ for (let index = 0; index < value.length; index += 1) {
910
+ const code = value.charCodeAt(index);
911
+ if (code >= 0xd800 && code <= 0xdbff) {
912
+ const next = value.charCodeAt(index + 1);
913
+ if (!(next >= 0xdc00 && next <= 0xdfff))
914
+ return false;
915
+ index += 1;
916
+ }
917
+ else if (code >= 0xdc00 && code <= 0xdfff)
918
+ return false;
919
+ }
920
+ return true;
921
+ }
876
922
  // ── Generic data utilities ───────────────────────────────────────────────────
923
+ /**
924
+ * Narrow an unknown value to a plain-ish record: an `object` that is neither
925
+ * `null` nor an `Array`. Does not distinguish a literal `{}` from a `Date`,
926
+ * `Map`, or class instance — callers that need that distinction use a
927
+ * stricter predicate instead.
928
+ */
929
+ export function isRecord(value) {
930
+ return typeof value === "object" && value !== null && !Array.isArray(value);
931
+ }
932
+ /** `JSON.parse` that returns `undefined` instead of throwing. */
933
+ export function tryParseJson(raw) {
934
+ try {
935
+ return JSON.parse(raw);
936
+ }
937
+ catch {
938
+ return undefined;
939
+ }
940
+ }
877
941
  /**
878
942
  * Coerce an unknown value to a filtered, trimmed string array.
879
943
  * Non-strings and empty/whitespace-only entries are dropped.