akm-cli 0.7.5 → 0.8.0-rc.6

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 (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,479 @@
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
+ // CONTRACT: ref-resolver
5
+ // ----------------------------------------------------------------------------
6
+ // The `refExistsInAnyStash` and `refToRelPath` helpers below are contract-
7
+ // locked: a sister copy lives in the akm-plugins repo at
8
+ // `shared/ref-extraction.ts` (and the runtime-shipped duplicate at
9
+ // `claude/shared/ref-extraction.ts`). Both implementations resolve the same
10
+ // `<type>:<slug>` -> on-disk-asset question and MUST agree on the set of
11
+ // reachable refs for any given stash layout.
12
+ //
13
+ // The lock is enforced by `tests/contracts/ref-resolver-contract.test.ts`,
14
+ // which drives this implementation through a canonical fixture set. The
15
+ // akm-plugins repo ships an equivalent test that drives its copy through the
16
+ // SAME inputs and asserts identical outcomes. Any change to the resolver
17
+ // behavior on either side MUST update both contract tests in lockstep, or one
18
+ // will fail.
19
+ //
20
+ // Cases the contract covers (see fixture in the contract test):
21
+ // - existing memory / knowledge / agent / workflow / skill / vault refs
22
+ // - knowledge subdirectory layout (knowledge/<category>/<slug>.md)
23
+ // - skill multi-file layout (skills/<slug>/SKILL.md)
24
+ // - memory `.derived.md` sibling
25
+ // - vault default vs named (.env vs <name>.env)
26
+ // - namespaced slugs containing `/`
27
+ // - non-existent refs
28
+ // - script type (unresolvable by design — both must return false)
29
+ // ----------------------------------------------------------------------------
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { findSafeInsertionPoint } from "./markdown-insertion";
33
+ // ── Helpers ───────────────────────────────────────────────────────────────────
34
+ function formatDate(d) {
35
+ const y = d.getFullYear();
36
+ const m = String(d.getMonth() + 1).padStart(2, "0");
37
+ const day = String(d.getDate()).padStart(2, "0");
38
+ return `${y}-${m}-${day}`;
39
+ }
40
+ function checkUnquotedColon(frontmatterText) {
41
+ if (!frontmatterText)
42
+ return null;
43
+ for (const line of frontmatterText.split(/\r?\n/)) {
44
+ const match = line.match(/^description:\s*(.*)/);
45
+ if (!match)
46
+ continue;
47
+ const value = match[1].trim();
48
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
49
+ return null;
50
+ }
51
+ if (value.includes(":")) {
52
+ return `description value contains unquoted colon: ${value}`;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ function fixUnquotedColon(raw) {
58
+ return raw.replace(/^(description:\s*)(.*)/m, (_match, prefix, value) => {
59
+ const trimmed = value.trim();
60
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
61
+ return _match;
62
+ }
63
+ const escaped = trimmed.replace(/"/g, '\\"');
64
+ return `${prefix}"${escaped}"`;
65
+ });
66
+ }
67
+ function checkMissingUpdated(data, frontmatterText) {
68
+ return frontmatterText !== null && !("updated" in data);
69
+ }
70
+ function fixMissingUpdated(raw, mtime) {
71
+ const dateStr = formatDate(mtime);
72
+ return raw.replace(/^(---\n[\s\S]*?)\n---/m, `$1\nupdated: ${dateStr}\n---`);
73
+ }
74
+ // ── stale-path helpers ────────────────────────────────────────────────────────
75
+ function checkStalePath(body) {
76
+ const pathRe = /\/home\/[^\s"'`)\]>,]+/g;
77
+ let match;
78
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
79
+ while ((match = pathRe.exec(body)) !== null) {
80
+ const candidate = match[0];
81
+ if (!fs.existsSync(candidate)) {
82
+ return candidate;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ // ── missing-ref helpers ───────────────────────────────────────────────────────
88
+ const REF_RE = /(?:^|[\s`"'(])((agent|command|knowledge|memory|script|skill|workflow|lesson|task|wiki|vault):[^\s"'`)\]>,\n]+)/gm;
89
+ /**
90
+ * Map from ref type to relative path pattern within stashRoot. Returns null to skip.
91
+ *
92
+ * Exported for contract testing — see header CONTRACT block.
93
+ */
94
+ export function refToRelPath(refType, refName) {
95
+ switch (refType) {
96
+ case "agent":
97
+ return path.join("agents", `${refName}.md`);
98
+ case "command":
99
+ return path.join("commands", `${refName}.md`);
100
+ case "knowledge":
101
+ return path.join("knowledge", `${refName}.md`);
102
+ case "memory":
103
+ return path.join("memories", `${refName}.md`);
104
+ case "script":
105
+ return null; // scripts live in nested dirs — skip
106
+ case "skill":
107
+ return path.join("skills", refName, "SKILL.md");
108
+ case "workflow":
109
+ return path.join("workflows", `${refName}.md`);
110
+ case "lesson":
111
+ return path.join("lessons", `${refName}.md`);
112
+ case "task":
113
+ return path.join("tasks", `${refName}.md`);
114
+ case "wiki":
115
+ return path.join("wikis", `${refName}.md`);
116
+ case "vault":
117
+ // Vaults are .env files. The canonical name "default" (or empty) maps to
118
+ // ".env"; any other name maps to "<name>.env". This mirrors the vault
119
+ // asset-spec toAssetPath logic in src/core/asset-spec.ts.
120
+ if (!refName || refName === "default") {
121
+ return path.join("vaults", ".env");
122
+ }
123
+ return path.join("vaults", `${refName}.env`);
124
+ default:
125
+ return null;
126
+ }
127
+ }
128
+ /**
129
+ * Returns true if `relPath` resolves to a real file (or multi-file directory
130
+ * primary) in ANY of the provided stash roots.
131
+ *
132
+ * Exported for contract testing — see header CONTRACT block.
133
+ */
134
+ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
135
+ for (const root of stashRoots) {
136
+ const absPath = path.join(root, relPath);
137
+ if (fs.existsSync(absPath))
138
+ return true;
139
+ // Multi-file skill layout: directory containing SKILL.md
140
+ const bareDir = absPath.replace(/\.md$/, "");
141
+ if (fs.existsSync(bareDir) && fs.existsSync(path.join(bareDir, "SKILL.md")))
142
+ return true;
143
+ // .derived.md variant for memory refs
144
+ if (refType === "memory") {
145
+ const derivedPath = path.join(root, "memories", `${refName}.derived.md`);
146
+ if (fs.existsSync(derivedPath))
147
+ return true;
148
+ }
149
+ // Knowledge-specific: search subdirectories like knowledge/projects/, knowledge/tools/, etc.
150
+ if (refType === "knowledge") {
151
+ try {
152
+ const knowledgeDir = path.join(root, "knowledge");
153
+ if (fs.existsSync(knowledgeDir) && fs.statSync(knowledgeDir).isDirectory()) {
154
+ const entries = fs.readdirSync(knowledgeDir);
155
+ for (const entry of entries) {
156
+ const subPath = path.join(knowledgeDir, entry, `${refName}.md`);
157
+ if (fs.existsSync(subPath))
158
+ return true;
159
+ }
160
+ }
161
+ }
162
+ catch {
163
+ // Ignore errors reading directory
164
+ }
165
+ }
166
+ // Fallback: the refName may already encode the full stash-relative path
167
+ // (e.g. knowledge:skills/foo/references/bar where the file lives at
168
+ // <stash>/skills/foo/references/bar.md, not <stash>/knowledge/skills/...).
169
+ const directPath = path.join(root, `${refName}.md`);
170
+ if (fs.existsSync(directPath))
171
+ return true;
172
+ const directDir = path.join(root, refName);
173
+ if (fs.existsSync(directDir) && fs.existsSync(path.join(directDir, "SKILL.md")))
174
+ return true;
175
+ }
176
+ return false;
177
+ }
178
+ /**
179
+ * Returns an array of {ref, resolvedRelPath} for every local AKM ref in the
180
+ * body that does not resolve to a real file under any of the provided stash roots.
181
+ *
182
+ * Skips false-positive patterns:
183
+ * - Shell variables: memory:$(cmd) or knowledge:${VAR}
184
+ * - ACP type notation: agent::Type (double colons are C++/ACP syntax)
185
+ * - Incomplete/placeholder refs: slug is single character or "**"
186
+ */
187
+ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
188
+ const allRoots = [stashRoot, ...extraStashRoots];
189
+ const missing = [];
190
+ let match;
191
+ const re = new RegExp(REF_RE.source, REF_RE.flags);
192
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
193
+ while ((match = re.exec(body)) !== null) {
194
+ const fullRef = match[1]; // e.g. "workflow:foo" or "local//workflow:foo"
195
+ // Skip shell variables: memory:$(cmd) or knowledge:${VAR}
196
+ if (fullRef.includes("$(") || fullRef.includes("${")) {
197
+ continue;
198
+ }
199
+ // Skip ACP type notation: agent::Type (double colons)
200
+ if (fullRef.includes("::")) {
201
+ continue;
202
+ }
203
+ // Strip leading "local//" prefix if present
204
+ let ref = fullRef;
205
+ if (ref.startsWith("local//")) {
206
+ ref = ref.slice("local//".length);
207
+ }
208
+ else if (fullRef.includes("//")) {
209
+ // Has a remote origin prefix (e.g. "npm:", "github:", "owner/repo//") — skip
210
+ continue;
211
+ }
212
+ // Skip refs that start with obvious remote prefixes
213
+ const colonIdx = ref.indexOf(":");
214
+ if (colonIdx === -1)
215
+ continue;
216
+ const refType = ref.slice(0, colonIdx);
217
+ const refName = ref.slice(colonIdx + 1);
218
+ // Guard against empty names or names that look like paths/URLs
219
+ if (!refName || refName.startsWith("/") || refName.startsWith("~") || refName.startsWith("http")) {
220
+ continue;
221
+ }
222
+ // Skip placeholder/incomplete refs: single character slug or "**"
223
+ if (refName.length <= 1 || refName === "**") {
224
+ continue;
225
+ }
226
+ const relPath = refToRelPath(refType, refName);
227
+ if (relPath === null)
228
+ continue; // type is skipped
229
+ if (!refExistsInAnyStash(relPath, refType, refName, allRoots)) {
230
+ missing.push({ ref: fullRef, resolvedRelPath: relPath });
231
+ }
232
+ }
233
+ return missing;
234
+ }
235
+ // ── frontmatter refs ─────────────────────────────────────────────────────────
236
+ /**
237
+ * Return the `refs:` array from frontmatter when it is present and is an
238
+ * array of strings; otherwise return `null` to signal the caller should
239
+ * fall back to scanning the body. An empty array (`refs: []`) is also
240
+ * treated as authoritative — it explicitly declares "this asset has no
241
+ * outbound refs" and suppresses the body scan.
242
+ *
243
+ * The `refs:` frontmatter key is used by the claude-code session-capture
244
+ * hook (see `shared/ref-extraction.ts` in the akm-plugins repo) to
245
+ * persist a validated outbound-ref list alongside the raw transcript.
246
+ * Hand-written memories rarely populate this key — for those the body
247
+ * scan remains the source of truth.
248
+ *
249
+ * Session-checkpoint memories use a nested frontmatter pattern: `akm
250
+ * remember` wraps the file in `---\n…\n---` and the hook's own
251
+ * `---\nakm_memory_kind: session_checkpoint\n…\n---` block is preserved
252
+ * inside the body. We look in both places so the `refs:` key works
253
+ * regardless of where the producer wrote it.
254
+ */
255
+ function extractFrontmatterRefs(data, body) {
256
+ const fromOuter = readRefsArray(data.refs);
257
+ if (fromOuter !== null)
258
+ return fromOuter;
259
+ const innerData = parseInnerFrontmatterBlock(body);
260
+ if (innerData) {
261
+ const fromInner = readRefsArray(innerData.refs);
262
+ if (fromInner !== null)
263
+ return fromInner;
264
+ }
265
+ return null;
266
+ }
267
+ function readRefsArray(value) {
268
+ if (!Array.isArray(value))
269
+ return null;
270
+ const out = [];
271
+ for (const entry of value) {
272
+ if (typeof entry === "string" && entry.trim())
273
+ out.push(entry.trim());
274
+ }
275
+ return out;
276
+ }
277
+ /**
278
+ * Detect a leading nested frontmatter block in `body` (i.e. a `---\n…\n---`
279
+ * pair that opens within the first few lines of the body). When present,
280
+ * parse a minimal subset of YAML — top-level scalars and block-list
281
+ * arrays — sufficient to recognise the `refs:` key. Anything fancier is
282
+ * silently ignored.
283
+ *
284
+ * This is a deliberately narrow parser: lint must never throw on
285
+ * unexpected YAML, and the only key we care about here is `refs:`.
286
+ */
287
+ function parseInnerFrontmatterBlock(body) {
288
+ // Skip up to three blank/header lines, then require `---` to open the block.
289
+ const lines = body.split(/\r?\n/);
290
+ let i = 0;
291
+ while (i < lines.length && i < 3 && lines[i].trim() === "")
292
+ i += 1;
293
+ if (lines[i] !== "---")
294
+ return null;
295
+ const open = i;
296
+ let close = -1;
297
+ for (let j = open + 1; j < lines.length; j += 1) {
298
+ if (lines[j] === "---") {
299
+ close = j;
300
+ break;
301
+ }
302
+ }
303
+ if (close === -1)
304
+ return null;
305
+ const block = lines.slice(open + 1, close);
306
+ const data = {};
307
+ let currentKey = null;
308
+ let currentList = null;
309
+ for (const line of block) {
310
+ const listItem = line.match(/^(?: {2})?- (.*)$/);
311
+ if (listItem && currentList) {
312
+ currentList.push(listItem[1].trim().replace(/^["'](.*)["']$/, "$1"));
313
+ continue;
314
+ }
315
+ const inlineFlow = line.match(/^(\w[\w-]*):\s*\[(.*)\]\s*$/);
316
+ if (inlineFlow) {
317
+ currentKey = inlineFlow[1];
318
+ const items = inlineFlow[2]
319
+ .split(",")
320
+ .map((s) => s.trim().replace(/^["'](.*)["']$/, "$1"))
321
+ .filter(Boolean);
322
+ data[currentKey] = items;
323
+ currentList = null;
324
+ continue;
325
+ }
326
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
327
+ if (!kv)
328
+ continue;
329
+ currentKey = kv[1];
330
+ const value = kv[2].trim();
331
+ if (value === "") {
332
+ currentList = [];
333
+ data[currentKey] = currentList;
334
+ }
335
+ else {
336
+ data[currentKey] = value.replace(/^["'](.*)["']$/, "$1");
337
+ currentList = null;
338
+ }
339
+ }
340
+ return data;
341
+ }
342
+ // ── BaseLinter ────────────────────────────────────────────────────────────────
343
+ /**
344
+ * Abstract base class providing the two cross-type checks shared by all asset
345
+ * linters: `unquoted-colon` and `missing-updated`.
346
+ *
347
+ * Subclasses call `runBaseChecks(ctx)` and append any type-specific issues.
348
+ * File mutations triggered by base checks are flushed to disk inside this
349
+ * method; subclasses must re-read `ctx.raw` if they need the post-fix content
350
+ * (in practice the base class updates `ctx.raw` in place when `fix` is true).
351
+ */
352
+ export class BaseLinter {
353
+ /**
354
+ * Insert one or more lines into a markdown body at a safe location.
355
+ *
356
+ * "Safe" means: not inside a markdown table, HTML table, fenced code block,
357
+ * or indented code block. If `proposedLineNumber` falls inside one of those
358
+ * regions, the helper pushes the insertion to immediately after the region.
359
+ * This is a regression guard against the class of bug where an auto-fix
360
+ * splits a table fence by injecting a callout between the separator row
361
+ * and the first data row (broke `knowledge/akm-cli-reference.md` in 0.8.0).
362
+ *
363
+ * Subclasses that perform line-based body insertion MUST route through this
364
+ * helper instead of calling `splice` directly. Insertion fixers must NOT
365
+ * touch frontmatter — use `fixMissingUpdated` / `fixUnquotedColon` style
366
+ * regex edits for that case (those already operate inside the `---…---`
367
+ * fence and don't intersect with body line numbers).
368
+ *
369
+ * @param raw Full file contents (frontmatter + body).
370
+ * @param newLines Lines to insert (without trailing newlines).
371
+ * @param proposedLineNumber 0-based line index within `raw` where the
372
+ * caller wants the new content to appear.
373
+ * @returns The mutated file contents with `newLines` spliced at the
374
+ * adjusted safe position.
375
+ */
376
+ insertLinesSafely(raw, newLines, proposedLineNumber) {
377
+ const lines = raw.split(/\r?\n/);
378
+ const safeIdx = findSafeInsertionPoint(lines, proposedLineNumber);
379
+ lines.splice(safeIdx, 0, ...newLines);
380
+ return lines.join("\n");
381
+ }
382
+ runBaseChecks(ctx) {
383
+ const issues = [];
384
+ let currentRaw = ctx.raw;
385
+ let modified = false;
386
+ // ── 1. unquoted-colon ──────────────────────────────────────────────────
387
+ const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
388
+ if (unquotedColonDetail) {
389
+ if (ctx.fix) {
390
+ currentRaw = fixUnquotedColon(currentRaw);
391
+ modified = true;
392
+ issues.push({
393
+ file: ctx.relPath,
394
+ issue: "unquoted-colon",
395
+ detail: unquotedColonDetail,
396
+ fixed: true,
397
+ });
398
+ }
399
+ else {
400
+ issues.push({
401
+ file: ctx.relPath,
402
+ issue: "unquoted-colon",
403
+ detail: unquotedColonDetail,
404
+ fixed: false,
405
+ });
406
+ }
407
+ }
408
+ // ── 2. missing-updated ─────────────────────────────────────────────────
409
+ if (checkMissingUpdated(ctx.data, ctx.frontmatter)) {
410
+ if (ctx.fix) {
411
+ let mtime;
412
+ try {
413
+ mtime = fs.statSync(ctx.filePath).mtime;
414
+ }
415
+ catch {
416
+ mtime = new Date();
417
+ }
418
+ currentRaw = fixMissingUpdated(currentRaw, mtime);
419
+ modified = true;
420
+ issues.push({
421
+ file: ctx.relPath,
422
+ issue: "missing-updated",
423
+ detail: `stamped updated: ${formatDate(mtime)}`,
424
+ fixed: true,
425
+ });
426
+ }
427
+ else {
428
+ issues.push({
429
+ file: ctx.relPath,
430
+ issue: "missing-updated",
431
+ detail: "no updated field in frontmatter",
432
+ fixed: false,
433
+ });
434
+ }
435
+ }
436
+ if (modified) {
437
+ fs.writeFileSync(ctx.filePath, currentRaw, "utf8");
438
+ // Propagate the mutated raw back so subclasses can re-parse if needed
439
+ ctx.raw = currentRaw;
440
+ }
441
+ // ── 3. stale-path ──────────────────────────────────────────────────────
442
+ const stalePathMatch = checkStalePath(ctx.body);
443
+ if (stalePathMatch) {
444
+ issues.push({
445
+ file: ctx.relPath,
446
+ issue: "stale-path",
447
+ detail: `nonexistent path: ${stalePathMatch}`,
448
+ fixed: false,
449
+ });
450
+ }
451
+ // ── 4. missing-ref ─────────────────────────────────────────────────────
452
+ // Carve-out for assets that declare an explicit `refs:` array in
453
+ // frontmatter (e.g. session-checkpoint memories captured by the
454
+ // claude-code hook). The frontmatter array is the *authoritative*
455
+ // ref list — any ref-shaped tokens in the body are treated as
456
+ // literal strings (heredocs, grep patterns, JSON values, regex
457
+ // patterns embedded in tool transcripts). Without this carve-out
458
+ // every session capture produces a fresh batch of `missing-ref`
459
+ // flags on every literal `<type>:<slug>` token in a transcript.
460
+ //
461
+ // The producer guarantees that entries in `refs:` already resolve
462
+ // (it validates against the live stash before writing), so we
463
+ // still run `checkMissingRefs` against the array itself to catch
464
+ // refs that were valid at capture time but later removed from the
465
+ // stash.
466
+ const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
467
+ const refSource = explicitRefs !== null ? explicitRefs.join("\n") : ctx.body;
468
+ const missingRefs = checkMissingRefs(refSource, ctx.stashRoot, ctx.extraStashRoots);
469
+ for (const { ref, resolvedRelPath } of missingRefs) {
470
+ issues.push({
471
+ file: ctx.relPath,
472
+ issue: "missing-ref",
473
+ detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
474
+ fixed: false,
475
+ });
476
+ }
477
+ return issues;
478
+ }
479
+ }
@@ -0,0 +1,49 @@
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
+ import path from "node:path";
5
+ import { BaseLinter } from "./base-linter";
6
+ /**
7
+ * Linter for `commands/` assets.
8
+ *
9
+ * Extra check beyond base:
10
+ * - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
11
+ * absent. Not auto-fixable; detail includes a suggested slug.
12
+ */
13
+ export class CommandLinter extends BaseLinter {
14
+ types = ["commands"];
15
+ lint(ctx) {
16
+ const issues = this.runBaseChecks(ctx);
17
+ const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
18
+ if (missingFieldDetail) {
19
+ const slug = this.#suggestSlug(ctx.filePath);
20
+ issues.push({
21
+ file: ctx.relPath,
22
+ issue: "missing-name-or-type",
23
+ detail: `${missingFieldDetail}; suggested slug: ${slug}`,
24
+ fixed: false,
25
+ });
26
+ }
27
+ return issues;
28
+ }
29
+ #checkMissingNameOrType(data, frontmatterText) {
30
+ if (!frontmatterText)
31
+ return null;
32
+ const missingFields = [];
33
+ if (!("name" in data) || !data.name)
34
+ missingFields.push("name");
35
+ if (!("type" in data) || !data.type)
36
+ missingFields.push("type");
37
+ if (missingFields.length === 0)
38
+ return null;
39
+ return `missing fields: ${missingFields.join(", ")}`;
40
+ }
41
+ #suggestSlug(filePath) {
42
+ return path
43
+ .basename(filePath, ".md")
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9-]+/g, "-")
46
+ .replace(/-+/g, "-")
47
+ .replace(/^-|-$/g, "");
48
+ }
49
+ }
@@ -0,0 +1,16 @@
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
+ import { BaseLinter } from "./base-linter";
5
+ /**
6
+ * Default linter for asset types that have no type-specific rules beyond the
7
+ * base checks (`unquoted-colon`, `missing-updated`).
8
+ *
9
+ * Covers: `lessons`.
10
+ */
11
+ export class DefaultLinter extends BaseLinter {
12
+ types = ["lessons"];
13
+ lint(ctx) {
14
+ return this.runBaseChecks(ctx);
15
+ }
16
+ }