akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -39,7 +39,7 @@
39
39
  import fs from "node:fs";
40
40
  import path from "node:path";
41
41
  import { getAssetTypes, resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset/asset-spec.js";
42
- import { findSafeInsertionPoint } from "./markdown-insertion.js";
42
+ import { findFenceRegions, findSafeInsertionPoint } from "./markdown-insertion.js";
43
43
  // ── Helpers ───────────────────────────────────────────────────────────────────
44
44
  function formatDate(d) {
45
45
  const y = d.getFullYear();
@@ -65,34 +65,69 @@ function checkUnquotedColon(frontmatterText) {
65
65
  return null;
66
66
  }
67
67
  function fixUnquotedColon(raw) {
68
- return raw.replace(/^(description:\s*)(.*)/m, (_match, prefix, value) => {
69
- const trimmed = value.trim();
70
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
71
- return _match;
72
- }
73
- const escaped = trimmed.replace(/"/g, '\\"');
74
- return `${prefix}"${escaped}"`;
75
- });
68
+ const lines = raw.split(/\r?\n/);
69
+ if (lines[0]?.trim() !== "---")
70
+ return raw;
71
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
72
+ if (closeIdx === -1)
73
+ return raw;
74
+ for (let i = 1; i < closeIdx; i++) {
75
+ const m = lines[i].match(/^(description:\s*)(.*)/);
76
+ if (!m)
77
+ continue;
78
+ const prefix = m[1];
79
+ const value = m[2].trim();
80
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
81
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2))
82
+ continue;
83
+ lines[i] = `${prefix}"${value.replace(/"/g, '\\"')}"`;
84
+ break;
85
+ }
86
+ return lines.join("\n");
76
87
  }
77
88
  function checkMissingUpdated(data, frontmatterText) {
78
89
  return frontmatterText !== null && !("updated" in data);
79
90
  }
80
91
  function fixMissingUpdated(raw, mtime) {
81
92
  const dateStr = formatDate(mtime);
82
- return raw.replace(/^(---\n[\s\S]*?)\n---/m, `$1\nupdated: ${dateStr}\n---`);
93
+ const lines = raw.split(/\r?\n/);
94
+ if (lines[0]?.trim() !== "---")
95
+ return raw;
96
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
97
+ if (closeIdx === -1)
98
+ return raw;
99
+ lines.splice(closeIdx, 0, `updated: ${dateStr}`);
100
+ return lines.join("\n");
83
101
  }
84
102
  // ── stale-path helpers ────────────────────────────────────────────────────────
85
103
  function checkStalePath(body) {
86
- const pathRe = /\/home\/[^\s"'`)\]>,]+/g;
104
+ const pathRe = /(?:\/home\/|\/tmp\/|\/var\/|\/root\/|\/opt\/)[^\s"'`)\]>,\n]+/g;
87
105
  let match;
106
+ const stale = [];
88
107
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
89
108
  while ((match = pathRe.exec(body)) !== null) {
90
109
  const candidate = match[0];
91
110
  if (!fs.existsSync(candidate)) {
92
- return candidate;
111
+ stale.push(candidate);
93
112
  }
94
113
  }
95
- return null;
114
+ return stale;
115
+ }
116
+ // ── fence-strip helper ────────────────────────────────────────────────────────
117
+ /**
118
+ * Returns `body` with all fenced code block lines replaced by empty strings,
119
+ * so that ref-shaped tokens inside ``` examples are not flagged as broken refs.
120
+ */
121
+ function stripFencedBlocks(body) {
122
+ const lines = body.split(/\r?\n/);
123
+ const regions = findFenceRegions(lines);
124
+ if (regions.length === 0)
125
+ return body;
126
+ for (const { start, end } of regions) {
127
+ for (let i = start; i <= end; i++)
128
+ lines[i] = "";
129
+ }
130
+ return lines.join("\n");
96
131
  }
97
132
  // ── missing-ref helpers ───────────────────────────────────────────────────────
98
133
  /**
@@ -122,9 +157,8 @@ const REF_RE = new RegExp(`(?:^|[\\s\`"'(])((${buildRefTypeAlternation()}):[^\\s
122
157
  * preserved to keep pre-0.9 behaviour byte-identical:
123
158
  * - `script`: returns null (scripts live in nested dirs with arbitrary
124
159
  * extensions — unresolvable by the slug-based walker, as the contract pins).
125
- * - `task`: the registry stores tasks as `<id>.yml`, but the missing-ref
126
- * linter has always resolved `task:` refs against `tasks/<id>.md`; that
127
- * behaviour is held constant here (non-env/secret behaviour is unchanged).
160
+ * - `task`: M1 fix tasks are stored as `<id>.yml` on disk, so resolve
161
+ * `task:` refs against `tasks/<id>.yml` to match actual on-disk layout.
128
162
  *
129
163
  * Exported for contract testing — see header CONTRACT block.
130
164
  */
@@ -132,9 +166,9 @@ export function refToRelPath(refType, refName) {
132
166
  // script is intentionally unresolvable (contract-pinned).
133
167
  if (refType === "script")
134
168
  return null;
135
- // Preserve the legacy `.md` resolution for tasks.
169
+ // M1: tasks are stored as .yml on disk; resolve task: refs against tasks/<id>.yml.
136
170
  if (refType === "task")
137
- return path.join(TYPE_DIRS.task ?? "tasks", `${refName}.md`);
171
+ return path.join(TYPE_DIRS.task ?? "tasks", `${refName}.yml`);
138
172
  const typeDir = TYPE_DIRS[refType];
139
173
  if (!typeDir)
140
174
  return null; // unknown type — skip
@@ -169,9 +203,11 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
169
203
  try {
170
204
  const knowledgeDir = path.join(root, "knowledge");
171
205
  if (fs.existsSync(knowledgeDir) && fs.statSync(knowledgeDir).isDirectory()) {
172
- const entries = fs.readdirSync(knowledgeDir);
206
+ const entries = fs.readdirSync(knowledgeDir, { withFileTypes: true });
173
207
  for (const entry of entries) {
174
- const subPath = path.join(knowledgeDir, entry, `${refName}.md`);
208
+ if (!entry.isDirectory())
209
+ continue;
210
+ const subPath = path.join(knowledgeDir, entry.name, `${refName}.md`);
175
211
  if (fs.existsSync(subPath))
176
212
  return true;
177
213
  }
@@ -207,8 +243,10 @@ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
207
243
  const missing = [];
208
244
  let match;
209
245
  const re = new RegExp(REF_RE.source, REF_RE.flags);
246
+ // C1: Strip fenced code blocks so example refs inside ``` are not flagged.
247
+ const scanBody = stripFencedBlocks(body);
210
248
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
211
- while ((match = re.exec(body)) !== null) {
249
+ while ((match = re.exec(scanBody)) !== null) {
212
250
  const fullRef = match[1]; // e.g. "workflow:foo" or "local//workflow:foo"
213
251
  // Skip shell variables: memory:$(cmd) or knowledge:${VAR}
214
252
  if (fullRef.includes("$(") || fullRef.includes("${")) {
@@ -241,6 +279,10 @@ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
241
279
  if (refName.length <= 1 || refName === "**") {
242
280
  continue;
243
281
  }
282
+ // Skip template placeholder refs like skill:<name> or workflow:<my-workflow>
283
+ if (refName.startsWith("<") || refName.includes("<")) {
284
+ continue;
285
+ }
244
286
  const relPath = refToRelPath(refType, refName);
245
287
  if (relPath === null)
246
288
  continue; // type is skipped
@@ -279,7 +321,15 @@ function extractFrontmatterRefs(data, body) {
279
321
  const fromInner = readRefsArray(innerData.refs);
280
322
  if (fromInner !== null)
281
323
  return fromInner;
324
+ // Session-checkpoint bodies are raw transcripts; ref-shaped tokens in the
325
+ // body are literals (grep patterns, JSON, tool output), not live refs.
326
+ // Return [] so missing-ref skips the body scan entirely.
327
+ if (typeof innerData.akm_memory_kind === "string")
328
+ return [];
282
329
  }
330
+ // Same guard for outer frontmatter (e.g. opencode session files).
331
+ if (typeof data.akm_memory_kind === "string")
332
+ return [];
283
333
  return null;
284
334
  }
285
335
  function readRefsArray(value) {
@@ -368,6 +418,47 @@ function parseInnerFrontmatterBlock(body) {
368
418
  * (in practice the base class updates `ctx.raw` in place when `fix` is true).
369
419
  */
370
420
  export class BaseLinter {
421
+ /**
422
+ * Check for missing `name` or `type` fields in frontmatter.
423
+ *
424
+ * Returns a detail string if fields are absent/empty, `null` if all present.
425
+ */
426
+ checkMissingNameOrType(data, frontmatterText) {
427
+ if (!frontmatterText)
428
+ return null;
429
+ const missingFields = [];
430
+ if (!("name" in data) || !data.name)
431
+ missingFields.push("name");
432
+ if (!("type" in data) || !data.type)
433
+ missingFields.push("type");
434
+ if (missingFields.length === 0)
435
+ return null;
436
+ return `missing fields: ${missingFields.join(", ")}`;
437
+ }
438
+ /**
439
+ * Validate that the `type` field value is one of an allowed set.
440
+ *
441
+ * Returns a detail string if the value is present but invalid, `null` if valid or absent.
442
+ */
443
+ checkInvalidTypeValue(data, allowedTypes) {
444
+ if (!("type" in data) || !data.type)
445
+ return null; // absent — covered by checkMissingNameOrType
446
+ const value = String(data.type);
447
+ if (allowedTypes.includes(value))
448
+ return null;
449
+ return `type field has invalid value '${value}'; expected one of: ${allowedTypes.join(", ")}`;
450
+ }
451
+ /**
452
+ * Derive a URL-safe slug from a file path.
453
+ */
454
+ suggestSlug(filePath) {
455
+ return path
456
+ .basename(filePath, ".md")
457
+ .toLowerCase()
458
+ .replace(/[^a-z0-9-]+/g, "-")
459
+ .replace(/-+/g, "-")
460
+ .replace(/^-|-$/g, "");
461
+ }
371
462
  /**
372
463
  * Insert one or more lines into a markdown body at a safe location.
373
464
  *
@@ -401,30 +492,41 @@ export class BaseLinter {
401
492
  const issues = [];
402
493
  let currentRaw = ctx.raw;
403
494
  let modified = false;
495
+ // M8: Parse lint_skip from frontmatter for per-file rule suppression.
496
+ // Accept both an array (`lint_skip: [missing-ref, stale-path]`) and a
497
+ // single scalar (`lint_skip: missing-ref`). Non-string entries are coerced
498
+ // and trimmed so loosely-typed YAML still gates correctly.
499
+ const rawLintSkip = ctx.data?.lint_skip;
500
+ const lintSkip = (Array.isArray(rawLintSkip) ? rawLintSkip : rawLintSkip != null ? [rawLintSkip] : [])
501
+ .map((v) => String(v).trim())
502
+ .filter(Boolean);
503
+ const shouldRun = (issueType) => !lintSkip.includes(issueType);
404
504
  // ── 1. unquoted-colon ──────────────────────────────────────────────────
405
- const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
406
- if (unquotedColonDetail) {
407
- if (ctx.fix) {
408
- currentRaw = fixUnquotedColon(currentRaw);
409
- modified = true;
410
- issues.push({
411
- file: ctx.relPath,
412
- issue: "unquoted-colon",
413
- detail: unquotedColonDetail,
414
- fixed: true,
415
- });
416
- }
417
- else {
418
- issues.push({
419
- file: ctx.relPath,
420
- issue: "unquoted-colon",
421
- detail: unquotedColonDetail,
422
- fixed: false,
423
- });
505
+ if (shouldRun("unquoted-colon")) {
506
+ const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
507
+ if (unquotedColonDetail) {
508
+ if (ctx.fix) {
509
+ currentRaw = fixUnquotedColon(currentRaw);
510
+ modified = true;
511
+ issues.push({
512
+ file: ctx.relPath,
513
+ issue: "unquoted-colon",
514
+ detail: unquotedColonDetail,
515
+ fixed: true,
516
+ });
517
+ }
518
+ else {
519
+ issues.push({
520
+ file: ctx.relPath,
521
+ issue: "unquoted-colon",
522
+ detail: unquotedColonDetail,
523
+ fixed: false,
524
+ });
525
+ }
424
526
  }
425
- }
527
+ } // end shouldRun("unquoted-colon")
426
528
  // ── 2. missing-updated ─────────────────────────────────────────────────
427
- if (checkMissingUpdated(ctx.data, ctx.frontmatter)) {
529
+ if (shouldRun("missing-updated") && checkMissingUpdated(ctx.data, ctx.frontmatter)) {
428
530
  if (ctx.fix) {
429
531
  let mtime;
430
532
  try {
@@ -457,14 +559,23 @@ export class BaseLinter {
457
559
  ctx.raw = currentRaw;
458
560
  }
459
561
  // ── 3. stale-path ──────────────────────────────────────────────────────
460
- const stalePathMatch = checkStalePath(ctx.body);
461
- if (stalePathMatch) {
462
- issues.push({
463
- file: ctx.relPath,
464
- issue: "stale-path",
465
- detail: `nonexistent path: ${stalePathMatch}`,
466
- fixed: false,
467
- });
562
+ // M3: checkStalePath returns all stale matches; push one issue per path.
563
+ // M4: Also scan ctx.frontmatter for stale paths (absolute paths in frontmatter).
564
+ if (shouldRun("stale-path")) {
565
+ const staleInBody = checkStalePath(ctx.body);
566
+ const staleInFrontmatter = ctx.frontmatter ? checkStalePath(ctx.frontmatter) : [];
567
+ for (const candidate of [...staleInBody, ...staleInFrontmatter]) {
568
+ // M4: Suggest portable replacement when path is under stashRoot.
569
+ const portableHint = candidate.startsWith(ctx.stashRoot)
570
+ ? ` (portable form: $AKM_STASH_DIR${candidate.slice(ctx.stashRoot.length)})`
571
+ : "";
572
+ issues.push({
573
+ file: ctx.relPath,
574
+ issue: "stale-path",
575
+ detail: `nonexistent path: ${candidate}${portableHint}`,
576
+ fixed: false,
577
+ });
578
+ }
468
579
  }
469
580
  // ── 4. missing-ref ─────────────────────────────────────────────────────
470
581
  // Carve-out for assets that declare an explicit `refs:` array in
@@ -481,16 +592,18 @@ export class BaseLinter {
481
592
  // still run `checkMissingRefs` against the array itself to catch
482
593
  // refs that were valid at capture time but later removed from the
483
594
  // stash.
484
- const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
485
- const refSource = explicitRefs !== null ? explicitRefs.join("\n") : ctx.body;
486
- const missingRefs = checkMissingRefs(refSource, ctx.stashRoot, ctx.extraStashRoots);
487
- for (const { ref, resolvedRelPath } of missingRefs) {
488
- issues.push({
489
- file: ctx.relPath,
490
- issue: "missing-ref",
491
- detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
492
- fixed: false,
493
- });
595
+ if (shouldRun("missing-ref")) {
596
+ const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
597
+ const refSource = explicitRefs !== null ? explicitRefs.join("\n") : ctx.body;
598
+ const missingRefs = checkMissingRefs(refSource, ctx.stashRoot, ctx.extraStashRoots);
599
+ for (const { ref, resolvedRelPath } of missingRefs) {
600
+ issues.push({
601
+ file: ctx.relPath,
602
+ issue: "missing-ref",
603
+ detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
604
+ fixed: false,
605
+ });
606
+ }
494
607
  }
495
608
  return issues;
496
609
  }
@@ -1,22 +1,25 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import path from "node:path";
5
4
  import { BaseLinter } from "./base-linter.js";
5
+ /** Valid values for the `type` field in command frontmatter. */
6
+ const VALID_COMMAND_TYPES = ["command"];
6
7
  /**
7
8
  * Linter for `commands/` assets.
8
9
  *
9
- * Extra check beyond base:
10
+ * Extra checks beyond base:
10
11
  * - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
11
12
  * absent. Not auto-fixable; detail includes a suggested slug.
13
+ * - `missing-name-or-type` (invalid value): `type` is present but not a
14
+ * recognised command type value.
12
15
  */
13
16
  export class CommandLinter extends BaseLinter {
14
17
  types = ["commands"];
15
18
  lint(ctx) {
16
19
  const issues = this.runBaseChecks(ctx);
17
- const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
20
+ const missingFieldDetail = this.checkMissingNameOrType(ctx.data, ctx.frontmatter);
18
21
  if (missingFieldDetail) {
19
- const slug = this.#suggestSlug(ctx.filePath);
22
+ const slug = this.suggestSlug(ctx.filePath);
20
23
  issues.push({
21
24
  file: ctx.relPath,
22
25
  issue: "missing-name-or-type",
@@ -24,26 +27,18 @@ export class CommandLinter extends BaseLinter {
24
27
  fixed: false,
25
28
  });
26
29
  }
30
+ else {
31
+ // Only validate the value when the field is actually present.
32
+ const invalidTypeDetail = this.checkInvalidTypeValue(ctx.data, VALID_COMMAND_TYPES);
33
+ if (invalidTypeDetail) {
34
+ issues.push({
35
+ file: ctx.relPath,
36
+ issue: "missing-name-or-type",
37
+ detail: invalidTypeDetail,
38
+ fixed: false,
39
+ });
40
+ }
41
+ }
27
42
  return issues;
28
43
  }
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
44
  }
@@ -24,6 +24,7 @@
24
24
  * where the operator legitimately wants to set their editor — accept the
25
25
  * FP and bypass with `--allow-insecure` after review.
26
26
  */
27
+ import fs from "node:fs";
27
28
  import { listKeys } from "../env/env.js";
28
29
  // ── Dangerous key set ─────────────────────────────────────────────────────────
29
30
  export const DANGEROUS_VAULT_KEYS = new Set([
@@ -95,6 +96,10 @@ export const DANGEROUS_VAULT_KEY_PATTERNS = [
95
96
  pattern: /^BASH_FUNC_/,
96
97
  reason: "Shellshock-class bash function injection (CVE-2014-6271)",
97
98
  },
99
+ {
100
+ pattern: /^GIT_CONFIG_/,
101
+ reason: "Git config injection through environment override variables",
102
+ },
98
103
  ];
99
104
  /**
100
105
  * Returns `true` if the given key name is dangerous — either by literal match
@@ -122,16 +127,48 @@ export function isDangerousVaultKey(key) {
122
127
  * @param vaultRef Human-readable vault ref (e.g. `"vault:prod"`) shown in
123
128
  * the finding message.
124
129
  */
130
+ /** Suppression comment token checked case-insensitively on the preceding non-empty line. */
131
+ const SUPPRESSION_COMMENT = "# akm-lint-ok: dangerous-vault-key";
132
+ /**
133
+ * Returns the set of keys suppressed by an inline `# akm-lint-ok: dangerous-vault-key`
134
+ * comment on the line immediately preceding the key assignment in the `.env` file.
135
+ */
136
+ function collectSuppressedKeys(vaultPath) {
137
+ const suppressed = new Set();
138
+ let raw;
139
+ try {
140
+ raw = fs.readFileSync(vaultPath, "utf8");
141
+ }
142
+ catch {
143
+ return suppressed;
144
+ }
145
+ const lines = raw.split(/\r?\n/);
146
+ let prevNonEmpty = "";
147
+ for (const line of lines) {
148
+ const trimmed = line.trim();
149
+ if (trimmed === "")
150
+ continue;
151
+ const keyMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
152
+ if (keyMatch && prevNonEmpty.toLowerCase() === SUPPRESSION_COMMENT) {
153
+ suppressed.add(keyMatch[1]);
154
+ }
155
+ prevNonEmpty = trimmed;
156
+ }
157
+ return suppressed;
158
+ }
125
159
  export function checkVaultForDangerousKeys(vaultPath, relPath, vaultRef) {
126
160
  const { keys } = listKeys(vaultPath);
161
+ const suppressed = collectSuppressedKeys(vaultPath);
127
162
  const issues = [];
128
163
  for (const key of keys) {
129
164
  if (!isDangerousVaultKey(key))
130
165
  continue;
166
+ if (suppressed.has(key))
167
+ continue;
131
168
  issues.push({
132
169
  file: relPath,
133
170
  issue: "dangerous-vault-key",
134
- detail: `Env key \`${key}\` can be used to hijack process execution when injected via \`akm env run\`. Ref: ${vaultRef}. Review this file before running \`akm env run\` commands against untrusted stashes.`,
171
+ detail: `Env key \`${key}\` can be used to hijack process execution when injected via \`akm env run\`. Ref: ${vaultRef}. Review this file before running \`akm env run\` commands against untrusted stashes. (suppress with: ${SUPPRESSION_COMMENT} on previous line)`,
135
172
  fixed: false,
136
173
  });
137
174
  }
@@ -0,0 +1,39 @@
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.js";
5
+ /** Recommended `category` values for facts (see docs/design/fact-asset-type.md). */
6
+ const KNOWN_CATEGORIES = new Set(["personal", "team", "project", "convention", "meta"]);
7
+ /**
8
+ * Linter for `facts/` assets.
9
+ *
10
+ * Extra check beyond base:
11
+ * - `missing-category`: a fact without a non-empty `category` frontmatter
12
+ * key. Category scopes the fact (personal/team/project/convention/meta)
13
+ * and drives how it is surfaced/injected, so it is expected. Reported as a
14
+ * non-fixable warning — we cannot infer the right scope automatically.
15
+ */
16
+ export class FactLinter extends BaseLinter {
17
+ types = ["facts"];
18
+ lint(ctx) {
19
+ const issues = this.runBaseChecks(ctx);
20
+ const category = typeof ctx.data.category === "string" ? ctx.data.category.trim() : "";
21
+ if (!category) {
22
+ issues.push({
23
+ file: ctx.relPath,
24
+ issue: "missing-category",
25
+ detail: "fact is missing a `category` (personal|team|project|convention|meta)",
26
+ fixed: false,
27
+ });
28
+ }
29
+ else if (!KNOWN_CATEGORIES.has(category)) {
30
+ issues.push({
31
+ file: ctx.relPath,
32
+ issue: "missing-category",
33
+ detail: `unrecognized category "${category}" (expected one of: ${[...KNOWN_CATEGORIES].join(", ")})`,
34
+ fixed: false,
35
+ });
36
+ }
37
+ return issues;
38
+ }
39
+ }
@@ -20,6 +20,7 @@ const STASH_SUBDIRS = [
20
20
  "lessons",
21
21
  "tasks",
22
22
  "knowledge",
23
+ "facts",
23
24
  ];
24
25
  // ── Helpers ───────────────────────────────────────────────────────────────────
25
26
  function collectYamlFiles(dir) {
@@ -85,7 +86,8 @@ export function akmLint(options = {}) {
85
86
  const fix = options.fix ?? false;
86
87
  const fixed = [];
87
88
  const flagged = [];
88
- for (const subdir of STASH_SUBDIRS) {
89
+ const dirsToScan = options.typeFilter ? STASH_SUBDIRS.filter((d) => d === options.typeFilter) : STASH_SUBDIRS;
90
+ for (const subdir of dirsToScan) {
89
91
  const dirPath = path.join(stashRoot, subdir);
90
92
  // Tasks are .yml files; everything else is .md
91
93
  const files = subdir === "tasks" ? collectYamlFiles(dirPath) : collectMarkdownFiles(dirPath);
@@ -97,7 +99,9 @@ export function akmLint(options = {}) {
97
99
  if (entry.isDirectory()) {
98
100
  const subdirIssues = linter.lintDirectory(path.join(dirPath, entry.name), stashRoot);
99
101
  for (const issue of subdirIssues) {
100
- if (issue.fixed) {
102
+ // Tristate-safe: only `true` counts as fixed; `false` and "failed"
103
+ // are both flagged.
104
+ if (issue.fixed === true) {
101
105
  fixed.push(issue);
102
106
  }
103
107
  else {
@@ -108,6 +112,9 @@ export function akmLint(options = {}) {
108
112
  }
109
113
  }
110
114
  for (const filePath of files) {
115
+ // Skip registry-cached read-only files — --fix must not mutate them.
116
+ if (filePath.includes("/.cache/") || filePath.includes("/registry/"))
117
+ continue;
111
118
  const relPath = path.relative(stashRoot, filePath);
112
119
  let raw;
113
120
  try {
@@ -142,10 +149,11 @@ export function akmLint(options = {}) {
142
149
  fileDeleted = true;
143
150
  fixed.push(issue);
144
151
  }
145
- else if (issue.fixed) {
152
+ else if (issue.fixed === true) {
146
153
  fixed.push(issue);
147
154
  }
148
155
  else {
156
+ // fixed === false (not fixable / no fix requested) or "failed" (fix attempted but threw)
149
157
  flagged.push(issue);
150
158
  }
151
159
  }
@@ -159,16 +167,26 @@ export function akmLint(options = {}) {
159
167
  // findings go into `flagged`, never `fixed`.
160
168
  const envRoots = [stashRoot, ...extraStashRoots];
161
169
  for (const root of envRoots) {
162
- const dir = path.join(root, "env");
163
- if (!fs.existsSync(dir))
164
- continue;
165
- for (const envPath of collectEnvFiles(dir)) {
166
- const baseName = path.basename(envPath, ".env");
167
- // "default" (or empty) maps to ".env" → env:default
168
- const ref = baseName === "" ? "env:default" : `env:${baseName}`;
169
- const relPath = path.relative(root, envPath);
170
- for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) {
171
- flagged.push(issue);
170
+ // The `env` assets live under `env/` (ref prefix `env:`); whole-file
171
+ // `secret` assets live under `secrets/` (canonical ref prefix `secret:`,
172
+ // singular). Map the scan directory to its canonical ref prefix so the
173
+ // finding's `Ref:` field matches what `akm show`/`akm secret` accept.
174
+ for (const { scanSubdir, refPrefix } of [
175
+ { scanSubdir: "env", refPrefix: "env" },
176
+ { scanSubdir: "secrets", refPrefix: "secret" },
177
+ ]) {
178
+ const dir = path.join(root, scanSubdir);
179
+ if (!fs.existsSync(dir))
180
+ continue;
181
+ for (const envPath of collectEnvFiles(dir)) {
182
+ const baseName = path.basename(envPath, ".env");
183
+ // A dotfile literally named `.env` has an empty baseName — use the full
184
+ // basename so it doesn't collide with `default.env` → refPrefix:default.
185
+ const ref = baseName === "" ? `${refPrefix}:.env` : `${refPrefix}:${baseName}`;
186
+ const relPath = path.relative(root, envPath);
187
+ for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) {
188
+ flagged.push(issue);
189
+ }
172
190
  }
173
191
  }
174
192
  }
@@ -33,7 +33,7 @@ export class MemoryLinter extends BaseLinter {
33
33
  file: ctx.relPath,
34
34
  issue: "orphaned-stub",
35
35
  detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
36
- fixed: false,
36
+ fixed: "failed",
37
37
  });
38
38
  }
39
39
  // Signal caller to skip remaining checks via a sentinel issue
@@ -4,6 +4,7 @@
4
4
  import { AgentLinter } from "./agent-linter.js";
5
5
  import { CommandLinter } from "./command-linter.js";
6
6
  import { DefaultLinter } from "./default-linter.js";
7
+ import { FactLinter } from "./fact-linter.js";
7
8
  import { KnowledgeLinter } from "./knowledge-linter.js";
8
9
  import { MemoryLinter } from "./memory-linter.js";
9
10
  import { SkillLinter } from "./skill-linter.js";
@@ -18,15 +19,19 @@ const LINTERS = [
18
19
  new KnowledgeLinter(),
19
20
  new SkillLinter(),
20
21
  new TaskLinter(),
21
- new DefaultLinter(),
22
+ new FactLinter(),
22
23
  ];
24
+ // Single shared DefaultLinter instance — used both as the explicit "lessons"
25
+ // handler and as the fallback for any unrecognised asset type.
26
+ const DEFAULT_LINTER = new DefaultLinter();
23
27
  const LINTER_MAP = new Map();
24
28
  for (const linter of LINTERS) {
25
29
  for (const t of linter.types) {
26
30
  LINTER_MAP.set(t, linter);
27
31
  }
28
32
  }
29
- const DEFAULT_LINTER = new DefaultLinter();
33
+ // Register "lessons" explicitly so there is only one DefaultLinter instance.
34
+ LINTER_MAP.set("lessons", DEFAULT_LINTER);
30
35
  /**
31
36
  * Return the appropriate linter for the given stash subdirectory name.
32
37
  * Falls back to `DefaultLinter` for unknown types.
@@ -28,9 +28,9 @@ export class TaskLinter extends BaseLinter {
28
28
  if (!("schedule" in ctx.data) || typeof ctx.data.schedule !== "string" || ctx.data.schedule.trim() === "") {
29
29
  missing.push("schedule");
30
30
  }
31
- // enabled: must be present (boolean value of false is valid)
32
- if (!("enabled" in ctx.data)) {
33
- missing.push("enabled");
31
+ // enabled: must be present AND a boolean (value of false is valid, but "yes" / 1 are not)
32
+ if (!("enabled" in ctx.data) || typeof ctx.data.enabled !== "boolean") {
33
+ missing.push("enabled (must be a boolean)");
34
34
  }
35
35
  // At least one of: prompt, workflow, or command
36
36
  const hasTarget = "prompt" in ctx.data || "workflow" in ctx.data || "command" in ctx.data;