akm-cli 0.7.4 → 0.8.0-rc.10

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 (300) hide show
  1. package/CHANGELOG.md +224 -1
  2. package/README.md +22 -6
  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/shared.js +129 -0
  9. package/dist/cli.js +2631 -1440
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +110 -0
  12. package/dist/commands/agent-support.js +68 -0
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +130 -534
  15. package/dist/commands/consolidate.js +2122 -0
  16. package/dist/commands/curate.js +45 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +660 -0
  19. package/dist/commands/distill.js +1081 -73
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +43 -0
  22. package/dist/commands/events.js +15 -24
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +477 -0
  28. package/dist/commands/health.js +1302 -0
  29. package/dist/commands/help/help-accept.md +12 -0
  30. package/dist/commands/help/help-improve.md +69 -0
  31. package/dist/commands/help/help-proposals.md +18 -0
  32. package/dist/commands/help/help-propose.md +17 -0
  33. package/dist/commands/help/help-reject.md +11 -0
  34. package/dist/commands/history.js +54 -46
  35. package/dist/commands/improve-auto-accept.js +97 -0
  36. package/dist/commands/improve-cli.js +217 -0
  37. package/dist/commands/improve-profiles.js +166 -0
  38. package/dist/commands/improve-result-file.js +167 -0
  39. package/dist/commands/improve.js +2373 -0
  40. package/dist/commands/info.js +5 -2
  41. package/dist/commands/init.js +50 -2
  42. package/dist/commands/installed-stashes.js +102 -139
  43. package/dist/commands/knowledge.js +136 -0
  44. package/dist/commands/lint/agent-linter.js +49 -0
  45. package/dist/commands/lint/base-linter.js +479 -0
  46. package/dist/commands/lint/command-linter.js +49 -0
  47. package/dist/commands/lint/default-linter.js +16 -0
  48. package/dist/commands/lint/env-key-rules.js +154 -0
  49. package/dist/commands/lint/index.js +196 -0
  50. package/dist/commands/lint/knowledge-linter.js +16 -0
  51. package/dist/commands/lint/markdown-insertion.js +343 -0
  52. package/dist/commands/lint/memory-linter.js +61 -0
  53. package/dist/commands/lint/registry.js +36 -0
  54. package/dist/commands/lint/skill-linter.js +45 -0
  55. package/dist/commands/lint/task-linter.js +50 -0
  56. package/dist/commands/lint/types.js +4 -0
  57. package/dist/commands/lint/workflow-linter.js +56 -0
  58. package/dist/commands/lint.js +4 -0
  59. package/dist/commands/migration-help.js +3 -0
  60. package/dist/commands/proposal.js +67 -12
  61. package/dist/commands/propose.js +120 -45
  62. package/dist/commands/reflect.js +1104 -60
  63. package/dist/commands/registry-cli.js +150 -0
  64. package/dist/commands/registry-search.js +5 -2
  65. package/dist/commands/remember-cli.js +257 -0
  66. package/dist/commands/remember.js +70 -7
  67. package/dist/commands/schema-repair.js +203 -0
  68. package/dist/commands/search.js +115 -14
  69. package/dist/commands/secret.js +173 -0
  70. package/dist/commands/self-update.js +3 -0
  71. package/dist/commands/show.js +158 -60
  72. package/dist/commands/source-add.js +17 -45
  73. package/dist/commands/source-clone.js +3 -0
  74. package/dist/commands/source-manage.js +14 -19
  75. package/dist/commands/tasks.js +437 -0
  76. package/dist/commands/url-checker.js +42 -0
  77. package/dist/core/action-contributors.js +28 -0
  78. package/dist/core/asset-ref.js +17 -2
  79. package/dist/core/asset-registry.js +12 -17
  80. package/dist/core/asset-serialize.js +88 -0
  81. package/dist/core/asset-spec.js +67 -1
  82. package/dist/core/common.js +182 -0
  83. package/dist/core/concurrent.js +25 -0
  84. package/dist/core/config-io.js +347 -0
  85. package/dist/core/config-migration.js +622 -0
  86. package/dist/core/config-schema.js +534 -0
  87. package/dist/core/config-sources.js +108 -0
  88. package/dist/core/config-types.js +4 -0
  89. package/dist/core/config-walker.js +337 -0
  90. package/dist/core/config.js +364 -968
  91. package/dist/core/errors.js +42 -20
  92. package/dist/core/events.js +105 -135
  93. package/dist/core/file-lock.js +104 -0
  94. package/dist/core/frontmatter.js +75 -8
  95. package/dist/core/lesson-lint.js +3 -0
  96. package/dist/core/markdown.js +20 -0
  97. package/dist/core/memory-belief.js +62 -0
  98. package/dist/core/memory-contradiction-detect.js +274 -0
  99. package/dist/core/memory-improve.js +806 -0
  100. package/dist/core/parse.js +158 -0
  101. package/dist/core/paths.js +280 -14
  102. package/dist/core/proposal-quality-validators.js +380 -0
  103. package/dist/core/proposal-validators.js +69 -0
  104. package/dist/core/proposals.js +512 -42
  105. package/dist/core/state-db.js +1068 -0
  106. package/dist/core/text-truncation.js +107 -0
  107. package/dist/core/time.js +54 -0
  108. package/dist/core/tty.js +59 -0
  109. package/dist/core/warn.js +64 -1
  110. package/dist/core/write-source.js +3 -0
  111. package/dist/indexer/db-backup.js +391 -0
  112. package/dist/indexer/db-search.js +198 -489
  113. package/dist/indexer/db.js +990 -108
  114. package/dist/indexer/ensure-index.js +136 -0
  115. package/dist/indexer/file-context.js +3 -0
  116. package/dist/indexer/graph-boost.js +376 -101
  117. package/dist/indexer/graph-db.js +391 -0
  118. package/dist/indexer/graph-dedup.js +95 -0
  119. package/dist/indexer/graph-extraction.js +550 -114
  120. package/dist/indexer/index-context.js +4 -0
  121. package/dist/indexer/indexer.js +547 -309
  122. package/dist/indexer/llm-cache.js +52 -0
  123. package/dist/indexer/manifest.js +3 -0
  124. package/dist/indexer/matchers.js +167 -160
  125. package/dist/indexer/memory-inference.js +152 -74
  126. package/dist/indexer/metadata-contributors.js +29 -0
  127. package/dist/indexer/metadata.js +275 -196
  128. package/dist/indexer/path-resolver.js +92 -0
  129. package/dist/indexer/project-context.js +192 -0
  130. package/dist/indexer/ranking-contributors.js +331 -0
  131. package/dist/indexer/ranking.js +81 -0
  132. package/dist/indexer/search-fields.js +5 -9
  133. package/dist/indexer/search-hit-enrichers.js +111 -0
  134. package/dist/indexer/search-source.js +44 -10
  135. package/dist/indexer/semantic-status.js +6 -17
  136. package/dist/indexer/staleness-detect.js +447 -0
  137. package/dist/indexer/usage-events.js +12 -9
  138. package/dist/indexer/walker.js +28 -0
  139. package/dist/integrations/agent/builders.js +135 -0
  140. package/dist/integrations/agent/config.js +122 -230
  141. package/dist/integrations/agent/detect.js +3 -0
  142. package/dist/integrations/agent/index.js +7 -13
  143. package/dist/integrations/agent/model-aliases.js +55 -0
  144. package/dist/integrations/agent/profiles.js +70 -5
  145. package/dist/integrations/agent/prompts.js +250 -36
  146. package/dist/integrations/agent/runner.js +151 -0
  147. package/dist/integrations/agent/sdk-runner.js +126 -0
  148. package/dist/integrations/agent/spawn.js +183 -35
  149. package/dist/integrations/github.js +3 -0
  150. package/dist/integrations/lockfile.js +32 -69
  151. package/dist/integrations/session-logs/index.js +69 -0
  152. package/dist/integrations/session-logs/inline-refs.js +35 -0
  153. package/dist/integrations/session-logs/pre-filter.js +152 -0
  154. package/dist/integrations/session-logs/providers/claude-code.js +282 -0
  155. package/dist/integrations/session-logs/providers/opencode.js +258 -0
  156. package/dist/integrations/session-logs/types.js +4 -0
  157. package/dist/llm/call-ai.js +62 -0
  158. package/dist/llm/client.js +79 -88
  159. package/dist/llm/embedder.js +20 -29
  160. package/dist/llm/embedders/cache.js +3 -7
  161. package/dist/llm/embedders/local.js +42 -1
  162. package/dist/llm/embedders/remote.js +20 -8
  163. package/dist/llm/embedders/types.js +3 -7
  164. package/dist/llm/feature-gate.js +95 -48
  165. package/dist/llm/graph-extract.js +676 -72
  166. package/dist/llm/index-passes.js +44 -29
  167. package/dist/llm/memory-infer.js +80 -71
  168. package/dist/llm/metadata-enhance.js +42 -29
  169. package/dist/llm/prompts/extract-session.md +80 -0
  170. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  171. package/dist/output/cli-hints-full.md +292 -0
  172. package/dist/output/cli-hints-short.md +66 -0
  173. package/dist/output/cli-hints.js +7 -311
  174. package/dist/output/context.js +60 -8
  175. package/dist/output/renderers.js +306 -258
  176. package/dist/output/shapes/curate.js +56 -0
  177. package/dist/output/shapes/distill.js +10 -0
  178. package/dist/output/shapes/env-list.js +19 -0
  179. package/dist/output/shapes/events.js +11 -0
  180. package/dist/output/shapes/helpers.js +424 -0
  181. package/dist/output/shapes/history.js +7 -0
  182. package/dist/output/shapes/passthrough.js +102 -0
  183. package/dist/output/shapes/proposal-accept.js +7 -0
  184. package/dist/output/shapes/proposal-diff.js +7 -0
  185. package/dist/output/shapes/proposal-list.js +7 -0
  186. package/dist/output/shapes/proposal-producer.js +11 -0
  187. package/dist/output/shapes/proposal-reject.js +7 -0
  188. package/dist/output/shapes/proposal-show.js +7 -0
  189. package/dist/output/shapes/registry-search.js +6 -0
  190. package/dist/output/shapes/registry.js +30 -0
  191. package/dist/output/shapes/search.js +6 -0
  192. package/dist/output/shapes/secret-list.js +19 -0
  193. package/dist/output/shapes/show.js +6 -0
  194. package/dist/output/shapes/vault-list.js +19 -0
  195. package/dist/output/shapes.js +51 -511
  196. package/dist/output/text/add.js +6 -0
  197. package/dist/output/text/clone.js +6 -0
  198. package/dist/output/text/config.js +6 -0
  199. package/dist/output/text/curate.js +6 -0
  200. package/dist/output/text/distill.js +7 -0
  201. package/dist/output/text/enable-disable.js +7 -0
  202. package/dist/output/text/events.js +10 -0
  203. package/dist/output/text/feedback.js +6 -0
  204. package/dist/output/text/helpers.js +1039 -0
  205. package/dist/output/text/history.js +7 -0
  206. package/dist/output/text/import.js +6 -0
  207. package/dist/output/text/index.js +6 -0
  208. package/dist/output/text/info.js +6 -0
  209. package/dist/output/text/init.js +6 -0
  210. package/dist/output/text/list.js +6 -0
  211. package/dist/output/text/proposal-producer.js +8 -0
  212. package/dist/output/text/proposal.js +11 -0
  213. package/dist/output/text/registry-commands.js +11 -0
  214. package/dist/output/text/registry.js +30 -0
  215. package/dist/output/text/remember.js +6 -0
  216. package/dist/output/text/remove.js +6 -0
  217. package/dist/output/text/save.js +6 -0
  218. package/dist/output/text/search.js +6 -0
  219. package/dist/output/text/show.js +6 -0
  220. package/dist/output/text/update.js +6 -0
  221. package/dist/output/text/upgrade.js +6 -0
  222. package/dist/output/text/vault.js +16 -0
  223. package/dist/output/text/wiki.js +15 -0
  224. package/dist/output/text/workflow.js +14 -0
  225. package/dist/output/text.js +44 -1093
  226. package/dist/registry/build-index.js +3 -0
  227. package/dist/registry/create-provider-registry.js +3 -0
  228. package/dist/registry/factory.js +4 -1
  229. package/dist/registry/origin-resolve.js +3 -0
  230. package/dist/registry/providers/index.js +3 -0
  231. package/dist/registry/providers/skills-sh.js +71 -50
  232. package/dist/registry/providers/static-index.js +53 -48
  233. package/dist/registry/providers/types.js +3 -24
  234. package/dist/registry/resolve.js +11 -16
  235. package/dist/registry/types.js +3 -0
  236. package/dist/scripts/migrate-storage.js +17750 -0
  237. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  238. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  239. package/dist/setup/detect.js +3 -0
  240. package/dist/setup/ripgrep-install.js +3 -0
  241. package/dist/setup/ripgrep-resolve.js +3 -0
  242. package/dist/setup/setup.js +775 -37
  243. package/dist/setup/steps.js +3 -15
  244. package/dist/sources/include.js +3 -0
  245. package/dist/sources/provider-factory.js +5 -12
  246. package/dist/sources/provider.js +3 -20
  247. package/dist/sources/providers/filesystem.js +19 -23
  248. package/dist/sources/providers/git.js +179 -20
  249. package/dist/sources/providers/index.js +3 -0
  250. package/dist/sources/providers/install-types.js +3 -13
  251. package/dist/sources/providers/npm.js +3 -4
  252. package/dist/sources/providers/provider-utils.js +3 -0
  253. package/dist/sources/providers/sync-from-ref.js +3 -11
  254. package/dist/sources/providers/tar-utils.js +3 -0
  255. package/dist/sources/providers/website.js +18 -22
  256. package/dist/sources/resolve.js +3 -0
  257. package/dist/sources/types.js +3 -0
  258. package/dist/sources/website-ingest.js +7 -0
  259. package/dist/tasks/backends/cron.js +203 -0
  260. package/dist/tasks/backends/exec-utils.js +28 -0
  261. package/dist/tasks/backends/index.js +24 -0
  262. package/dist/tasks/backends/launchd-template.xml +19 -0
  263. package/dist/tasks/backends/launchd.js +187 -0
  264. package/dist/tasks/backends/schtasks-template.xml +29 -0
  265. package/dist/tasks/backends/schtasks.js +215 -0
  266. package/dist/tasks/parser.js +211 -0
  267. package/dist/tasks/resolveAkmBin.js +87 -0
  268. package/dist/tasks/runner.js +458 -0
  269. package/dist/tasks/schedule.js +227 -0
  270. package/dist/tasks/schema.js +15 -0
  271. package/dist/tasks/validator.js +62 -0
  272. package/dist/version.js +3 -0
  273. package/dist/wiki/index-template.md +12 -0
  274. package/dist/wiki/ingest-workflow-template.md +54 -0
  275. package/dist/wiki/log-template.md +8 -0
  276. package/dist/wiki/schema-template.md +61 -0
  277. package/dist/wiki/wiki-templates.js +15 -0
  278. package/dist/wiki/wiki.js +13 -61
  279. package/dist/workflows/authoring.js +8 -25
  280. package/dist/workflows/cli.js +3 -0
  281. package/dist/workflows/db.js +141 -2
  282. package/dist/workflows/document-cache.js +3 -10
  283. package/dist/workflows/parser.js +3 -0
  284. package/dist/workflows/renderer.js +11 -3
  285. package/dist/workflows/runs.js +91 -89
  286. package/dist/workflows/schema.js +3 -0
  287. package/dist/workflows/scope-key.js +79 -0
  288. package/dist/workflows/validator.js +4 -8
  289. package/dist/workflows/workflow-template.md +24 -0
  290. package/docs/README.md +10 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.0.md +1 -1
  293. package/docs/migration/release-notes/0.7.4.md +1 -1
  294. package/docs/migration/release-notes/0.7.5.md +20 -0
  295. package/docs/migration/release-notes/0.8.0.md +48 -0
  296. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  297. package/package.json +29 -11
  298. package/dist/commands/install-audit.js +0 -381
  299. package/dist/commands/vault.js +0 -333
  300. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,380 @@
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
+ * Shared content-quality validators consumed by the improve pipeline
6
+ * (`distill`, `consolidate`, `reflect`) and by the `proposal accept` gate.
7
+ *
8
+ * ## Reflect size gate — calibrated blended formula (2026-05-22)
9
+ *
10
+ * ### Distribution baseline (n=844 reflect-eligible stash assets)
11
+ *
12
+ * min=1 p10=371 p25=778 p50=1508 p75=5456 p90=11721 p99=43463 max=298010 bytes
13
+ * Buckets: <500=135 (16%), 500–2000=340 (40%), 2000–8000=222 (26%), >8000=147 (17%)
14
+ *
15
+ * ### Problem with the original fixed-ratio gate
16
+ *
17
+ * - Small sources (~420 bytes, 16th pct): the 200% expansion ceiling fires at
18
+ * only 840 bytes proposed — one good paragraph. Hair-trigger for a terse
19
+ * reference note.
20
+ * - Large sources (~7KB, 75th pct): 200% ceiling = 14KB; reasonable, but a hard
21
+ * cap prevents runaway expansion from LLM hallucinations.
22
+ *
23
+ * ### Blended-bound formula
24
+ *
25
+ * Shrinkage floor (accept if proposed >= lower):
26
+ * lower = max(REFLECT_SHRINK_RATIO_MIN * sourceLen, REFLECT_ABSOLUTE_FLOOR_BYTES)
27
+ * → For tiny sources (sourceLen < 300), the absolute floor dominates so a
28
+ * genuinely tightened note still passes.
29
+ * → For large sources (>1KB), the ratio floor dominates (50% of 7KB = 3.5KB).
30
+ *
31
+ * Expansion ceiling (accept if proposed <= upper):
32
+ * upper = max(REFLECT_EXPAND_RATIO_MAX * sourceLen, REFLECT_ABSOLUTE_CEILING_BYTES)
33
+ * …but always capped at REFLECT_ABSOLUTE_MAX_BYTES.
34
+ * → For small sources (≤778 bytes, p25), the absolute ceiling (2000 bytes)
35
+ * dominates — one substantive paragraph is always acceptable.
36
+ * → For medium/large sources (>1KB), the ratio ceiling dominates.
37
+ * → Any proposal exceeding 25000 bytes is always rejected regardless of ratio.
38
+ *
39
+ * ### Constant calibration rationale
40
+ *
41
+ * REFLECT_ABSOLUTE_FLOOR_BYTES = 150
42
+ * Half of p10 (371) ≈ 185; we set 150 so even very aggressive condensation
43
+ * of a seed note is allowed down to roughly a two-sentence summary.
44
+ *
45
+ * REFLECT_ABSOLUTE_CEILING_BYTES = 2500
46
+ * Raised from 2000 (2026-05-22): small-source rejections at 248–281% on
47
+ * 900–953 byte assets were borderline false positives. 2500 gives a short
48
+ * lesson or command ~1.5KB of room to grow before the absolute kicks in.
49
+ *
50
+ * REFLECT_ABSOLUTE_MAX_BYTES = 25000
51
+ * Below p99 (43463). Catches genuine LLM runaway (whole-chapter insertions)
52
+ * without blocking legitimate large rewrites of large sources.
53
+ *
54
+ * REFLECT_EXPAND_RATIO_MAX = 2.5
55
+ * Raised from 2.0 (2026-05-22): 2× was too tight for dense short assets
56
+ * (lessons, commands) that have legitimate room to grow. 2.5× resolves
57
+ * 248% expansion on a 900-byte lesson while still catching 281%+ on ~1KB
58
+ * assets where the absolute ceiling takes over.
59
+ */
60
+ // ── Reflect-size guard ───────────────────────────────────────────────────────
61
+ import { parseFrontmatter } from "./frontmatter";
62
+ import { detectTruncatedDescription, TRUNCATION_TRAILING_WORDS } from "./text-truncation";
63
+ // ── Description / when_to_use shape ─────────────────────────────────────────
64
+ export const HEADING_FRAGMENT_PATTERNS = [
65
+ /^for example\b/i,
66
+ /^to reduce\b/i,
67
+ /^key (pitfalls|fixes|points|takeaways|considerations|steps|notes|tips|insights|features|benefits|risks)\b/i,
68
+ /^example[s]?$/i,
69
+ /^summary$/i,
70
+ /^overview$/i,
71
+ /^introduction$/i,
72
+ /^takeaways$/i,
73
+ /^conclusion$/i,
74
+ /^notes?$/i,
75
+ /^tips?$/i,
76
+ ];
77
+ export function isValidDescription(value, inputRef, options = {}) {
78
+ if (typeof value !== "string")
79
+ return { ok: false, reason: "description is not a string" };
80
+ const v = value.trim();
81
+ if (!v)
82
+ return { ok: false, reason: "description is empty" };
83
+ if (v.length < 20)
84
+ return { ok: false, reason: `description is too short (${v.length} chars; need ≥20)` };
85
+ if (v.length > 400)
86
+ return { ok: false, reason: `description is too long (${v.length} chars; max 400)` };
87
+ if (/^\s*[\d#*\->`]/.test(v))
88
+ return { ok: false, reason: "description starts with a digit or markdown marker" };
89
+ const last = v.slice(-1);
90
+ if (last === ":" || last === ";" || last === ",")
91
+ return { ok: false, reason: `description ends with truncation indicator "${last}"` };
92
+ const lastWordMatch = v.match(/([A-Za-z']+)[.!?]*$/);
93
+ if (lastWordMatch) {
94
+ const lastWord = lastWordMatch[1].toLowerCase();
95
+ if (TRUNCATION_TRAILING_WORDS.has(lastWord))
96
+ return { ok: false, reason: `description ends with truncation-indicator word "${lastWord}"` };
97
+ }
98
+ if (/^lesson distilled from\b/i.test(v))
99
+ return { ok: false, reason: "description matches the auto-repair placeholder text" };
100
+ for (const re of HEADING_FRAGMENT_PATTERNS) {
101
+ if (re.test(v))
102
+ return { ok: false, reason: `description looks like a section heading: "${v.slice(0, 40)}"` };
103
+ }
104
+ if (/^(def|function|async\s+def|async\s+function|class|const|let|var|export\s+function|export\s+const|export\s+default|import|public|private|protected|fn|func)\s+\S/i.test(v)) {
105
+ const firstWord = v.split(/\s+/)[0] ?? "";
106
+ return {
107
+ ok: false,
108
+ reason: `description starts with code keyword "${firstWord}" — looks like a code fragment, not prose`,
109
+ };
110
+ }
111
+ const backtickCount = (v.match(/`/g) ?? []).length;
112
+ if (backtickCount % 2 !== 0)
113
+ return {
114
+ ok: false,
115
+ reason: `description has ${backtickCount} backticks (unbalanced); likely contains a malformed code fragment`,
116
+ };
117
+ if (/^when\b/i.test(v))
118
+ return { ok: false, reason: "description starts with 'When' — that pattern belongs in when_to_use" };
119
+ if (!options.skipRefTailCheck) {
120
+ const refTail = inputRef.split(":").pop()?.toLowerCase() ?? "";
121
+ if (refTail.length >= 6 && v.toLowerCase().includes(refTail) && v.length < refTail.length + 40)
122
+ return { ok: false, reason: "description appears to just name the input ref" };
123
+ }
124
+ return { ok: true };
125
+ }
126
+ export function isValidWhenToUse(value, inputRef) {
127
+ if (typeof value !== "string")
128
+ return { ok: false, reason: "when_to_use is not a string" };
129
+ const v = value.trim();
130
+ if (!v)
131
+ return { ok: false, reason: "when_to_use is empty" };
132
+ if (v.length < 15)
133
+ return { ok: false, reason: `when_to_use is too short (${v.length} chars; need ≥15)` };
134
+ if (v.length > 400)
135
+ return { ok: false, reason: `when_to_use is too long (${v.length} chars; max 400)` };
136
+ if (/^when working with\b/i.test(v))
137
+ return { ok: false, reason: "when_to_use is the circular 'When working with ...' fallback" };
138
+ const refTail = inputRef.split(":").pop()?.toLowerCase() ?? "";
139
+ if (refTail.length >= 6 && v.toLowerCase().includes(refTail) && v.length < refTail.length + 25)
140
+ return { ok: false, reason: "when_to_use appears to just name the input ref" };
141
+ return { ok: true };
142
+ }
143
+ export function detectDoubleFrontmatter(content) {
144
+ const fenceLines = content.split(/\r?\n/).filter((l) => /^---\s*$/.test(l));
145
+ if (fenceLines.length > 2)
146
+ return {
147
+ kind: "double-frontmatter-fence",
148
+ message: `Content contains ${fenceLines.length} \`---\` fence lines; assets with frontmatter must have exactly 2 (one open, one close).`,
149
+ };
150
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
151
+ const pseudoLine = body
152
+ .split(/\r?\n/)
153
+ .find((l) => /^\s*(\*\*|__)?\s*(description|when_to_use)\s*(\*\*|__)?\s*:/i.test(l));
154
+ if (pseudoLine)
155
+ return {
156
+ kind: "pseudo-frontmatter-in-body",
157
+ message: `Body contains a pseudo-frontmatter restatement: "${pseudoLine.slice(0, 80)}". Fields belong in YAML frontmatter only.`,
158
+ };
159
+ return null;
160
+ }
161
+ export function validateProposalFrontmatter(fm) {
162
+ const desc = fm.description;
163
+ if (typeof desc !== "string" || desc.trim().length === 0)
164
+ return { ok: false, reason: "MISSING_FRONTMATTER_DESCRIPTION" };
165
+ const truncReason = detectTruncatedDescription(desc);
166
+ if (truncReason)
167
+ return { ok: false, reason: `TRUNCATED_DESCRIPTION (${truncReason})` };
168
+ return { ok: true };
169
+ }
170
+ export function hasSupersededStatus(frontmatter) {
171
+ const status = frontmatter?.status;
172
+ return typeof status === "string" && status.trim().toLowerCase() === "superseded";
173
+ }
174
+ export function hasHotCaptureMode(frontmatter) {
175
+ return frontmatter?.captureMode === "hot";
176
+ }
177
+ // ── Consolidate merge size gate ──────────────────────────────────────────────
178
+ /**
179
+ * Ratio lower-bound for merged body vs. the larger source body.
180
+ * Lower than reflect (0.5) because deduplication is expected — two memories
181
+ * with 80-90% overlap legitimately compress to well under 50% of the larger.
182
+ */
183
+ export const MERGE_SHRINK_RATIO_MIN = 0.3;
184
+ /**
185
+ * Absolute floor (chars) for merged body. When sources are short (<~333 chars),
186
+ * `MERGE_SHRINK_RATIO_MIN × largerBodyLen` falls below this and the absolute
187
+ * floor dominates — prevents false positives on very terse memory pairs.
188
+ * Matches the existing `promote_source_too_small` floor of 100 chars.
189
+ */
190
+ export const MERGE_ABSOLUTE_FLOOR_CHARS = 100;
191
+ // ── Reflect size gate ────────────────────────────────────────────────────────
192
+ /** Ratio lower-bound: proposed body must be at least this fraction of source. */
193
+ export const REFLECT_SHRINK_RATIO_MIN = 0.5;
194
+ /** Ratio upper-bound: proposed body must not exceed this fraction of source. */
195
+ export const REFLECT_EXPAND_RATIO_MAX = 2.5;
196
+ /**
197
+ * Below this byte count, ratio checks are too noisy — skip them entirely.
198
+ * Unchanged from the original gate.
199
+ */
200
+ export const REFLECT_SIZE_GUARD_MIN_BYTES = 200;
201
+ /**
202
+ * Absolute shrinkage floor (bytes). Even if `ratio * sourceLen` is lower, a
203
+ * proposed body of at least this many bytes is always accepted on the shrinkage
204
+ * side. Protects against false positives when the source is small (<300 bytes).
205
+ */
206
+ export const REFLECT_ABSOLUTE_FLOOR_BYTES = 150;
207
+ /**
208
+ * Absolute expansion ceiling (bytes). Even if `ratio * sourceLen` is lower, a
209
+ * proposed body up to this many bytes is always accepted on the expansion side.
210
+ * Protects against false positives when the source is small (≤778 bytes, p25).
211
+ */
212
+ export const REFLECT_ABSOLUTE_CEILING_BYTES = 2500;
213
+ /**
214
+ * Hard expansion cap (bytes). Regardless of ratio, a proposed body exceeding
215
+ * this limit is always rejected. Guards against runaway LLM hallucinations on
216
+ * large sources.
217
+ */
218
+ export const REFLECT_ABSOLUTE_MAX_BYTES = 25000;
219
+ /**
220
+ * Calibrated size check: compare proposed body length against source body
221
+ * length using a blended-bound formula.
222
+ *
223
+ * **Shrinkage** — accept if:
224
+ * `proposedLen >= max(REFLECT_SHRINK_RATIO_MIN * sourceLen, REFLECT_ABSOLUTE_FLOOR_BYTES)`
225
+ *
226
+ * **Expansion** — accept if:
227
+ * `proposedLen <= min(max(REFLECT_EXPAND_RATIO_MAX * sourceLen, REFLECT_ABSOLUTE_CEILING_BYTES), REFLECT_ABSOLUTE_MAX_BYTES)`
228
+ *
229
+ * Returns `{ ok: true }` when:
230
+ * - `sourceBody` is absent or `undefined`
231
+ * - source body is shorter than {@link REFLECT_SIZE_GUARD_MIN_BYTES}
232
+ * - the proposed length is within the blended bounds
233
+ */
234
+ export function checkReflectSize(sourceBody, proposedBody) {
235
+ if (typeof sourceBody !== "string")
236
+ return { ok: true };
237
+ const sourceLen = sourceBody.trim().length;
238
+ if (sourceLen < REFLECT_SIZE_GUARD_MIN_BYTES)
239
+ return { ok: true };
240
+ const proposedLen = proposedBody.trim().length;
241
+ const ratio = proposedLen / sourceLen;
242
+ // Shrinkage check: lower bound = max(ratio floor, absolute floor)
243
+ const shrinkFloor = Math.max(REFLECT_SHRINK_RATIO_MIN * sourceLen, REFLECT_ABSOLUTE_FLOOR_BYTES);
244
+ if (proposedLen < shrinkFloor) {
245
+ return { ok: false, code: "EXCESSIVE_SHRINKAGE", ratio };
246
+ }
247
+ // Expansion check: upper bound = min(max(ratio ceiling, absolute ceiling), hard cap)
248
+ const expandCeiling = Math.min(Math.max(REFLECT_EXPAND_RATIO_MAX * sourceLen, REFLECT_ABSOLUTE_CEILING_BYTES), REFLECT_ABSOLUTE_MAX_BYTES);
249
+ if (proposedLen > expandCeiling) {
250
+ return { ok: false, code: "EXCESSIVE_EXPANSION", ratio };
251
+ }
252
+ return { ok: true };
253
+ }
254
+ // ── ProposalValidator entries (registered with proposal-validators.ts) ──────
255
+ const descriptionQualityValidator = {
256
+ name: "description-quality",
257
+ appliesTo(_proposal, ctx) {
258
+ return ctx.parsedRef?.type === "knowledge" || ctx.parsedRef?.type === "memory" || ctx.parsedRef?.type === "lesson";
259
+ },
260
+ validate(proposal) {
261
+ if (typeof proposal.payload?.content !== "string" || proposal.payload.content.trim() === "")
262
+ return [];
263
+ let fm;
264
+ try {
265
+ fm = parseFrontmatter(proposal.payload.content).data;
266
+ }
267
+ catch {
268
+ return [];
269
+ }
270
+ const check = validateProposalFrontmatter(fm);
271
+ if (check.ok)
272
+ return [];
273
+ return [
274
+ {
275
+ kind: "invalid-description",
276
+ message: `Proposal ${proposal.id} (${proposal.ref}) has an invalid description: ${check.reason}.`,
277
+ },
278
+ ];
279
+ },
280
+ };
281
+ const lessonContentQualityValidator = {
282
+ name: "lesson-content-quality",
283
+ appliesTo(_proposal, ctx) {
284
+ return ctx.parsedRef?.type === "lesson";
285
+ },
286
+ validate(proposal) {
287
+ if (typeof proposal.payload?.content !== "string")
288
+ return [];
289
+ let fm;
290
+ try {
291
+ fm = parseFrontmatter(proposal.payload.content).data;
292
+ }
293
+ catch {
294
+ return [];
295
+ }
296
+ const findings = [];
297
+ const descCheck = isValidDescription(fm.description, proposal.ref);
298
+ if (!descCheck.ok)
299
+ findings.push({
300
+ kind: "invalid-description",
301
+ message: `Lesson proposal ${proposal.id} (${proposal.ref}) has an invalid description: ${descCheck.reason}.`,
302
+ });
303
+ const wtuCheck = isValidWhenToUse(fm.when_to_use, proposal.ref);
304
+ if (!wtuCheck.ok)
305
+ findings.push({
306
+ kind: "invalid-when_to_use",
307
+ message: `Lesson proposal ${proposal.id} (${proposal.ref}) has an invalid when_to_use: ${wtuCheck.reason}.`,
308
+ });
309
+ if (descCheck.ok &&
310
+ wtuCheck.ok &&
311
+ typeof fm.description === "string" &&
312
+ typeof fm.when_to_use === "string" &&
313
+ fm.description.trim().toLowerCase() === fm.when_to_use.trim().toLowerCase()) {
314
+ findings.push({
315
+ kind: "description-equals-when_to_use",
316
+ message: `Lesson proposal ${proposal.id} (${proposal.ref}) has identical description and when_to_use.`,
317
+ });
318
+ }
319
+ const dfm = detectDoubleFrontmatter(proposal.payload.content);
320
+ if (dfm)
321
+ findings.push({ kind: dfm.kind, message: `Lesson proposal ${proposal.id} (${proposal.ref}): ${dfm.message}` });
322
+ return findings;
323
+ },
324
+ };
325
+ const sourceNotSupersededValidator = {
326
+ name: "source-not-superseded",
327
+ appliesTo(proposal, ctx) {
328
+ return proposal.source === "consolidate" && !!ctx.source?.frontmatter;
329
+ },
330
+ validate(proposal, ctx) {
331
+ if (hasSupersededStatus(ctx.source?.frontmatter)) {
332
+ return [
333
+ {
334
+ kind: "source-superseded",
335
+ message: `Proposal ${proposal.id} (${proposal.ref}) has a source asset marked status:superseded; superseded memories are not promotable knowledge.`,
336
+ },
337
+ ];
338
+ }
339
+ return [];
340
+ },
341
+ };
342
+ /** Strip an opening frontmatter block (`---\n…\n---`) from `content`, returning the body. */
343
+ function stripFrontmatterBody(content) {
344
+ return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
345
+ }
346
+ const reflectSizeGuardValidator = {
347
+ name: "reflect-size-guard",
348
+ appliesTo(proposal, ctx) {
349
+ return proposal.source === "reflect" && typeof ctx.source?.content === "string";
350
+ },
351
+ validate(proposal, ctx) {
352
+ const sourceBody = stripFrontmatterBody(ctx.source?.content ?? "");
353
+ const proposedBody = typeof proposal.payload?.content === "string" ? stripFrontmatterBody(proposal.payload.content) : "";
354
+ const outcome = checkReflectSize(sourceBody, proposedBody);
355
+ if (outcome.ok)
356
+ return [];
357
+ const pct = (outcome.ratio * 100).toFixed(0);
358
+ const limit = outcome.code === "EXCESSIVE_SHRINKAGE" ? "minimum 50%" : "maximum 250%";
359
+ const cause = outcome.code === "EXCESSIVE_SHRINKAGE"
360
+ ? "Concrete content was likely deleted."
361
+ : "Speculative material was likely added.";
362
+ return [
363
+ {
364
+ kind: outcome.code.toLowerCase(),
365
+ message: `Reflect rejected: ${outcome.code} — proposed body is ${pct}% of source (${limit}) for ref ${proposal.ref}. ${cause}`,
366
+ },
367
+ ];
368
+ },
369
+ };
370
+ /**
371
+ * Full set of quality validators in registration order. Appended onto
372
+ * {@link defaultProposalValidators} so they run inside `validateProposal` on
373
+ * `proposal accept` automatically.
374
+ */
375
+ export const defaultProposalQualityValidators = [
376
+ descriptionQualityValidator,
377
+ lessonContentQualityValidator,
378
+ sourceNotSupersededValidator,
379
+ reflectSizeGuardValidator,
380
+ ];
@@ -0,0 +1,69 @@
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 { parseAssetRef } from "./asset-ref";
5
+ import { parseFrontmatter } from "./frontmatter";
6
+ import { lintLessonContent } from "./lesson-lint";
7
+ import { defaultProposalQualityValidators } from "./proposal-quality-validators";
8
+ const genericProposalValidator = {
9
+ name: "generic-proposal-validator",
10
+ appliesTo: () => true,
11
+ validate(proposal, ctx) {
12
+ const findings = [];
13
+ if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") {
14
+ findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` });
15
+ }
16
+ try {
17
+ ctx.parsedRef = parseAssetRef(proposal.ref);
18
+ }
19
+ catch (err) {
20
+ findings.push({
21
+ kind: "invalid-ref",
22
+ message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`,
23
+ });
24
+ ctx.stop = true;
25
+ return findings;
26
+ }
27
+ if (proposal.payload.content.startsWith("---")) {
28
+ try {
29
+ parseFrontmatter(proposal.payload.content);
30
+ }
31
+ catch (err) {
32
+ findings.push({
33
+ kind: "invalid-frontmatter",
34
+ message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`,
35
+ });
36
+ }
37
+ }
38
+ return findings;
39
+ },
40
+ };
41
+ const lessonProposalValidator = {
42
+ name: "lesson-proposal-validator",
43
+ appliesTo(_proposal, ctx) {
44
+ return ctx.parsedRef?.type === "lesson";
45
+ },
46
+ validate(proposal) {
47
+ return lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`).findings.map((finding) => ({
48
+ kind: finding.kind,
49
+ message: finding.message,
50
+ }));
51
+ },
52
+ };
53
+ export const defaultProposalValidators = [
54
+ genericProposalValidator,
55
+ lessonProposalValidator,
56
+ ...defaultProposalQualityValidators,
57
+ ];
58
+ export function runProposalValidators(proposal, validators = defaultProposalValidators, initialContext = {}) {
59
+ const findings = [];
60
+ const ctx = { ...initialContext };
61
+ for (const validator of validators) {
62
+ if (!validator.appliesTo(proposal, ctx))
63
+ continue;
64
+ findings.push(...validator.validate(proposal, ctx));
65
+ if (ctx.stop)
66
+ break;
67
+ }
68
+ return { ok: findings.length === 0, findings };
69
+ }