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
@@ -4,170 +4,43 @@
4
4
  /**
5
5
  * Shared frontmatter parsing utilities.
6
6
  *
7
- * Provides a single, canonical YAML-subset frontmatter parser used by both
8
- * the stash open logic and the metadata generator.
7
+ * Uses the `yaml` library for all YAML parsing so that the full YAML spec
8
+ * (block scalars, multi-line strings, nested objects, flow sequences, escape
9
+ * sequences) is handled correctly without a brittle hand-rolled state machine.
9
10
  */
11
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
10
12
  /**
11
- * Parse YAML-subset frontmatter from a Markdown (or similar) string.
13
+ * Parse YAML frontmatter from a Markdown (or similar) string.
12
14
  *
13
15
  * Returns the parsed key-value data and the remaining body content.
14
- *
15
- * **Limitations**: This is a hand-rolled YAML-subset parser with intentional
16
- * constraints for simplicity and safety:
17
- * - **Top-level values**: string, boolean, and number scalars are supported,
18
- * as well as top-level list-valued keys using YAML block sequences
19
- * (`- item`) or flow arrays (`[a, b, c]`).
20
- * - **List item types**: list items must be scalar values and may be strings,
21
- * booleans, or numbers.
22
- * - **No nested objects beyond one level**: Only a single level of indented
23
- * key-value pairs is supported.
24
- * - **Block scalars**: `|` (literal), `|-` (strip), and `|+` (keep) block
25
- * scalars are supported for multi-line string values as emitted by the
26
- * `yaml` library's `stringify`.
16
+ * Delegates all YAML parsing to the `yaml` library; the only responsibility
17
+ * of this function is extracting the `---…---` block and normalizing the
18
+ * parsed result (e.g. converting YAML timestamp values to ISO date strings).
27
19
  */
28
20
  export function parseFrontmatter(raw) {
29
21
  const parsedBlock = parseFrontmatterBlock(raw);
30
22
  if (!parsedBlock) {
31
23
  return { data: {}, content: raw, frontmatter: null, bodyStartLine: 1 };
32
24
  }
33
- const data = {};
34
- let currentKey = null;
35
- /**
36
- * "scalar" | "list" | "object" | "pending" | "block" —
37
- * "pending" means empty value, mode determined by next line.
38
- * "block" means we are accumulating lines for a `|`-block scalar.
39
- */
40
- let mode = "scalar";
41
- let nested = null;
42
- let currentList = null;
43
- /** Lines collected while in "block" mode. */
44
- let blockLines = null;
45
- /** Block scalar chomping: "clip" (|), "strip" (|-), "keep" (|+). */
46
- let blockChomping = "clip";
47
- const flushPending = () => {
48
- // Called when we start a new top-level key and the previous key was still "pending".
49
- // An empty-value key followed by another top-level key means it was an empty scalar.
50
- if (mode === "pending" && currentKey !== null) {
51
- data[currentKey] = "";
52
- }
53
- };
54
- const flushBlock = () => {
55
- // Commit the accumulated block-scalar lines to `data[currentKey]`.
56
- if (mode !== "block" || currentKey === null || blockLines === null)
57
- return;
58
- // De-indent: strip the common 2-space prefix `yaml.stringify` emits.
59
- const deindented = blockLines.map((l) => (l.startsWith(" ") ? l.slice(2) : l));
60
- // Chomping: apply trailing-newline policy.
61
- // "clip" (|): single trailing newline.
62
- // "strip" (|-): no trailing newline.
63
- // "keep" (|+): keep all trailing newlines as-is.
64
- if (blockChomping === "keep") {
65
- data[currentKey] = deindented.join("\n");
66
- }
67
- else if (blockChomping === "strip") {
68
- data[currentKey] = deindented.join("\n").replace(/\n+$/, "");
69
- }
70
- else {
71
- // "clip": exactly one trailing newline
72
- data[currentKey] = `${deindented.join("\n").replace(/\n+$/, "")}\n`;
73
- }
74
- };
75
- for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
76
- // If we are in block-scalar mode, collect indented lines or end the block.
77
- if (mode === "block") {
78
- if (line.startsWith(" ") || line === "") {
79
- // Continuation of the block scalar (indented content or blank line).
80
- blockLines.push(line);
81
- continue;
25
+ let data = {};
26
+ if (parsedBlock.frontmatter.trim()) {
27
+ try {
28
+ const parsed = yamlParse(parsedBlock.frontmatter);
29
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
30
+ // Normalize Date objects: the yaml "core" schema parses YYYY-MM-DD
31
+ // literals as JS Date instances. Convert them back to ISO date strings
32
+ // to preserve the string type that callers (and yaml.stringify on write)
33
+ // expect.
34
+ data = normalizeYamlValues(parsed);
82
35
  }
83
- // Non-indented line ends the block scalar — flush and fall through to
84
- // parse the new line as a top-level key.
85
- flushBlock();
86
- mode = "scalar";
87
- blockLines = null;
88
36
  }
89
- // Block-sequence item: "- value" or " - value" (optional 2-space indent)
90
- // Only match when the current key is in list or pending mode.
91
- const seqItem = line.match(/^(?: {2})?- (.*)$/);
92
- if (seqItem && currentKey !== null && (mode === "list" || mode === "pending")) {
93
- if (mode === "pending") {
94
- // First block-sequence item after an empty-value key — switch to list mode
95
- currentList = [];
96
- data[currentKey] = currentList;
97
- mode = "list";
98
- }
99
- currentList.push(parseYamlScalar(seqItem[1].trim()));
100
- continue;
101
- }
102
- // Plain-style multi-line scalar continuation: a 2-space-indented line that
103
- // is not a sequence item or nested key. YAML plain scalars fold newlines
104
- // into a single space, so we append with a space. This handles LLM-emitted
105
- // descriptions like:
106
- // description: Use 4-colon outer containers when mixing
107
- // nesting depths in markdown-it-container plugins.
108
- // Without this, only the first line is captured and the truncation
109
- // heuristic wrongly flags it as cut off mid-sentence.
110
- if (mode === "scalar" && currentKey !== null && /^ {2}\S/.test(line)) {
111
- data[currentKey] = `${String(data[currentKey])} ${line.trim()}`;
112
- continue;
113
- }
114
- // Indented nested key-value (object under a key with empty value)
115
- const indented = line.match(/^ {2}(\w[\w-]*):\s*(.+)$/);
116
- if (indented && currentKey !== null && (mode === "object" || mode === "pending")) {
117
- if (mode === "pending") {
118
- // First indented k-v after an empty-value key — switch to object mode
119
- nested = {};
120
- data[currentKey] = nested;
121
- mode = "object";
122
- }
123
- nested[indented[1]] = parseYamlScalar(indented[2].trim());
124
- continue;
37
+ catch {
38
+ // Malformed YAML (e.g. unterminated quotes from LLM output corruption).
39
+ // Fall back to line-by-line best-effort extraction so callers still get
40
+ // whatever scalar values they can rather than a completely empty record.
41
+ data = parseFrontmatterLenient(parsedBlock.frontmatter);
125
42
  }
126
- // Top-level key (possibly with inline value)
127
- const top = line.match(/^(\w[\w-]*):\s*(.*)$/);
128
- if (!top) {
129
- continue;
130
- }
131
- // Starting a new top-level key — flush any pending empty-value key
132
- flushPending();
133
- currentKey = top[1];
134
- const value = top[2].trim();
135
- if (value === "|" || value === "|-" || value === "|+") {
136
- // Block scalar header — collect subsequent indented lines.
137
- mode = "block";
138
- blockLines = [];
139
- blockChomping = value === "|-" ? "strip" : value === "|+" ? "keep" : "clip";
140
- nested = null;
141
- currentList = null;
142
- }
143
- else if (value === "") {
144
- // Defer mode decision until we see the next line
145
- mode = "pending";
146
- nested = null;
147
- currentList = null;
148
- // Don't store anything yet — flushPending will set "" if no continuation
149
- }
150
- else if (value.startsWith("[") && value.endsWith("]")) {
151
- // Inline flow array: tags: [ops, networking]
152
- mode = "list";
153
- nested = null;
154
- currentList = null;
155
- currentList = parseFlowArray(value);
156
- data[currentKey] = currentList;
157
- }
158
- else {
159
- mode = "scalar";
160
- nested = null;
161
- currentList = null;
162
- data[currentKey] = parseYamlScalar(value);
163
- }
164
- }
165
- // Flush any in-progress block scalar at end of frontmatter.
166
- if (mode === "block") {
167
- flushBlock();
168
43
  }
169
- // Flush the last key if it was still pending (empty value, no continuation)
170
- flushPending();
171
44
  return {
172
45
  data,
173
46
  content: parsedBlock.content,
@@ -176,29 +49,89 @@ export function parseFrontmatter(raw) {
176
49
  };
177
50
  }
178
51
  /**
179
- * Parse a YAML flow array string like `[a, b, c]` into an array of scalars.
52
+ * Normalize YAML-parsed values to match expected AKM frontmatter types.
53
+ *
54
+ * Two conversions:
55
+ * 1. `Date` → YYYY-MM-DD string: the yaml "core" schema parses bare date
56
+ * scalars like `2026-06-18` as JS Date instances. AKM frontmatter treats
57
+ * `updated:` and similar fields as plain strings.
58
+ * 2. `null` → `""`: the yaml library parses empty-value keys (`key:` with no
59
+ * value) as `null`, but AKM callers historically received `""` from the
60
+ * hand-rolled parser. Convert to preserve backward compatibility.
180
61
  */
181
- function parseFlowArray(value) {
182
- const inner = value.slice(1, -1).trim();
183
- if (!inner)
184
- return [];
185
- return inner.split(",").map((item) => parseYamlScalar(item.trim()));
62
+ function normalizeYamlValues(value) {
63
+ if (value instanceof Date) {
64
+ const y = value.getUTCFullYear();
65
+ const m = String(value.getUTCMonth() + 1).padStart(2, "0");
66
+ const d = String(value.getUTCDate()).padStart(2, "0");
67
+ return `${y}-${m}-${d}`;
68
+ }
69
+ if (value === null)
70
+ return "";
71
+ if (Array.isArray(value))
72
+ return value.map(normalizeYamlValues);
73
+ if (typeof value === "object") {
74
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, normalizeYamlValues(v)]));
75
+ }
76
+ return value;
77
+ }
78
+ /**
79
+ * Best-effort line-by-line frontmatter extraction for malformed YAML.
80
+ *
81
+ * Used as a fallback when yaml.parse throws (e.g. unterminated quotes from LLM
82
+ * output corruption). Extracts simple `key: value` scalar pairs only — nested
83
+ * objects and sequences are skipped. Values that are individually parseable by
84
+ * yaml are normalized; otherwise stored as raw strings.
85
+ */
86
+ function parseFrontmatterLenient(frontmatter) {
87
+ const data = {};
88
+ for (const line of frontmatter.split(/\r?\n/)) {
89
+ const m = line.match(/^([\w][\w-]*):\s*(.*)$/);
90
+ if (!m)
91
+ continue;
92
+ const key = m[1];
93
+ const rawValue = (m[2] ?? "").trim();
94
+ try {
95
+ const singleEntry = yamlParse(`k: ${rawValue}`);
96
+ if (singleEntry !== null && typeof singleEntry === "object" && !Array.isArray(singleEntry)) {
97
+ const v = singleEntry.k;
98
+ data[key] = v === null || v === undefined ? "" : v;
99
+ }
100
+ else {
101
+ data[key] = rawValue;
102
+ }
103
+ }
104
+ catch {
105
+ data[key] = rawValue;
106
+ }
107
+ }
108
+ return data;
186
109
  }
187
110
  export function parseFrontmatterBlock(raw) {
188
111
  // Handle both LF and CRLF line endings throughout.
189
112
  // The closing --- may be preceded by \r\n; capture and strip trailing \r
190
113
  // from the frontmatter block so key parsing sees clean LF-terminated lines.
191
114
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r\n|\r|\n|$)([\s\S]*)$/);
192
- if (!match)
193
- return null;
194
- // Strip any \r characters from the frontmatter block to normalise CRLF → LF
195
- const frontmatter = match[1].replace(/\r/g, "");
196
- const content = match[2];
197
- return {
198
- frontmatter,
199
- content,
200
- bodyStartLine: countLines(raw.slice(0, match[0].length - match[2].length)) + 1,
201
- };
115
+ if (match) {
116
+ // Strip any \r characters from the frontmatter block to normalise CRLF → LF
117
+ const frontmatter = match[1].replace(/\r/g, "");
118
+ const content = match[2];
119
+ return {
120
+ frontmatter,
121
+ content,
122
+ bodyStartLine: countLines(raw.slice(0, match[0].length - match[2].length)) + 1,
123
+ };
124
+ }
125
+ // Empty frontmatter (---\n---): the content-bearing regex above requires at
126
+ // least one character between the fences. Handle the degenerate case so
127
+ // callers can reconstruct `---\nkey: val\n---\n\nbody` from a previously
128
+ // empty-frontmatter file without corrupting it by wrapping the entire raw
129
+ // string as body content.
130
+ const emptyMatch = raw.match(/^---\r?\n---(?:\r\n|\r|\n)([\s\S]*)$/);
131
+ if (emptyMatch) {
132
+ return { frontmatter: "", content: emptyMatch[1], bodyStartLine: 3 };
133
+ }
134
+ return null;
202
135
  }
203
136
  function countLines(text) {
204
137
  if (text.length === 0)
@@ -206,7 +139,13 @@ function countLines(text) {
206
139
  return text.split(/\r?\n/).length - 1;
207
140
  }
208
141
  /**
209
- * Parse a simple YAML scalar value (string, boolean, or number).
142
+ * Parse a YAML scalar value (string, boolean, or number).
143
+ *
144
+ * For quoted strings (single or double), delegates to the `yaml` library so
145
+ * escape sequences are handled correctly per spec. The previous hand-rolled
146
+ * `slice(1, -1)` only stripped one layer of quoting and left inner quotes and
147
+ * escape sequences as literal characters in the stored value, causing visible
148
+ * corruption when `yaml.stringify` re-quoted them on the next write.
210
149
  */
211
150
  export function parseYamlScalar(value) {
212
151
  if (value === "")
@@ -219,7 +158,67 @@ export function parseYamlScalar(value) {
219
158
  if (!Number.isNaN(asNumber))
220
159
  return asNumber;
221
160
  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
161
+ try {
162
+ const parsed = yamlParse(value);
163
+ if (typeof parsed === "string")
164
+ return parsed;
165
+ }
166
+ catch {
167
+ // Fall through to raw slice on malformed YAML — better than throwing.
168
+ }
222
169
  return value.slice(1, -1);
223
170
  }
224
171
  return value;
225
172
  }
173
+ // ── Minimum score delta to trigger a frontmatter salience rewrite ─────────────
174
+ const SALIENCE_WRITE_DELTA_THRESHOLD = 0.05;
175
+ /**
176
+ * Idempotently write `salience` and `salienceInputs` fields into the YAML
177
+ * frontmatter of a raw asset string.
178
+ *
179
+ * Skips the write when the existing `salience` field differs from `score` by
180
+ * less than {@link SALIENCE_WRITE_DELTA_THRESHOLD}, to avoid churn for minor
181
+ * floating-point drift. Returns the raw string unchanged when no write is needed
182
+ * or when no frontmatter block is present.
183
+ *
184
+ * The `salienceInputs` field is written for auditability only; no pipeline code
185
+ * reads it back. `state.db :: asset_salience` is the canonical store.
186
+ */
187
+ export function writeSalienceToFrontmatter(raw, score, inputs) {
188
+ const parsed = parseFrontmatterBlock(raw);
189
+ if (!parsed)
190
+ return raw;
191
+ const existingData = parseFrontmatter(raw).data;
192
+ const existingSalience = typeof existingData.salience === "number" ? existingData.salience : undefined;
193
+ if (existingSalience !== undefined && Math.abs(existingSalience - score) < SALIENCE_WRITE_DELTA_THRESHOLD) {
194
+ return raw;
195
+ }
196
+ // Parse existing frontmatter into an object, then set/overwrite salience fields.
197
+ let fm = {};
198
+ if (parsed.frontmatter.trim()) {
199
+ try {
200
+ const p = yamlParse(parsed.frontmatter);
201
+ if (p !== null && typeof p === "object" && !Array.isArray(p)) {
202
+ fm = p;
203
+ }
204
+ }
205
+ catch {
206
+ // Malformed YAML — rebuild from best-effort parse
207
+ fm = parseFrontmatterLenient(parsed.frontmatter);
208
+ }
209
+ }
210
+ fm.salience = roundTo2dp(score);
211
+ fm.salienceInputs = {
212
+ novelty: roundTo2dp(inputs.novelty),
213
+ magnitude: roundTo2dp(inputs.magnitude),
214
+ predictionError: roundTo2dp(inputs.predictionError),
215
+ };
216
+ const newFrontmatter = yamlStringify(fm).trimEnd();
217
+ const body = parsed.content;
218
+ // Preserve original line ending style between frontmatter and body
219
+ const separator = body.startsWith("\n") ? "" : "\n";
220
+ return `---\n${newFrontmatter}\n---\n${separator}${body}`;
221
+ }
222
+ function roundTo2dp(n) {
223
+ return Math.round(n * 100) / 100;
224
+ }
@@ -8,7 +8,15 @@ export function parseMarkdownToc(content) {
8
8
  const headings = [];
9
9
  const parsed = parseFrontmatter(content);
10
10
  const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0;
11
+ let inFence = false;
11
12
  for (let i = start; i < lines.length; i++) {
13
+ // Track fenced code blocks (``` or ~~~) so headings inside them are skipped.
14
+ if (/^\s*(`{3,}|~{3,})/.test(lines[i])) {
15
+ inFence = !inFence;
16
+ continue;
17
+ }
18
+ if (inFence)
19
+ continue;
12
20
  const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
13
21
  if (match) {
14
22
  headings.push({
@@ -0,0 +1,92 @@
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
+ * Canonical HARD authoring rules — the single source of truth shared by the
6
+ * proposal validators (which REJECT violations) and the improve/authoring
7
+ * prompts (which must TELL the agent the same rules, in the same words).
8
+ *
9
+ * Why this module exists: authoring rules were duplicated and drifted across
10
+ * prompt templates. `distill-lesson-system.md` told the model "80–200 chars"
11
+ * while the validator enforced 20–400; reflect's prompt omitted the
12
+ * no-pseudo-frontmatter / single-fence rules entirely, so reflect generated
13
+ * proposals that the gate then rejected and that got stuck in the queue.
14
+ *
15
+ * The fix: the numeric bounds live HERE and are imported by both the validators
16
+ * (`isValidDescription` / `isValidWhenToUse` in proposal-quality-validators.ts)
17
+ * and the prompt text (`authoringRulesForType`). The agent-facing rule prose
18
+ * sits next to the bounds it describes, so a developer changing a validator
19
+ * sees the prompt copy that must change with it. `tests/authoring-rules-*`
20
+ * asserts the two representations stay consistent.
21
+ *
22
+ * SCOPE: only HARD rules (a validator rejects the proposal if violated) belong
23
+ * here. Soft/style conventions (voice, paragraph count, "include a # Title")
24
+ * are user-editable and flow through the separate `standardsContext` seam
25
+ * (stash `category: convention` facts) — NOT this module.
26
+ */
27
+ // ── Canonical numeric bounds (imported by the validators — do not duplicate) ──
28
+ /** `description` length bounds (chars). Enforced by `isValidDescription`. */
29
+ export const DESCRIPTION_MIN_CHARS = 20;
30
+ export const DESCRIPTION_MAX_CHARS = 400;
31
+ /** `when_to_use` length bounds (chars). Enforced by `isValidWhenToUse`. */
32
+ export const WHEN_TO_USE_MIN_CHARS = 15;
33
+ export const WHEN_TO_USE_MAX_CHARS = 400;
34
+ // ── Agent-facing rule prose (mirrors the validator checks one-for-one) ────────
35
+ /**
36
+ * Rules that apply to any markdown asset authored with YAML frontmatter + a
37
+ * body. Enforced by `detectDoubleFrontmatter` (currently fires for lesson
38
+ * proposals, but the rules are universally correct, so we state them for every
39
+ * type to prevent the same defect class elsewhere).
40
+ */
41
+ const FRONTMATTER_BODY_RULES = [
42
+ "Emit EXACTLY TWO `---` fence lines — the opening and closing of the YAML frontmatter. Do NOT use `---` as a horizontal rule anywhere in the body.",
43
+ "Do NOT restate `description:` or `when_to_use:` inside the body (no `**description:** …` or `**when_to_use:** …` lines). Those keys belong in the frontmatter ONLY.",
44
+ ];
45
+ /** Rules for the `description` frontmatter field. Enforced by `isValidDescription`. */
46
+ const DESCRIPTION_RULES = [
47
+ `\`description\` must be ${DESCRIPTION_MIN_CHARS}–${DESCRIPTION_MAX_CHARS} characters of plain-prose sentence — no leading digit or markdown marker, balanced backticks, and it must NOT end with \`:\`, \`;\`, or \`,\` (those read as truncation).`,
48
+ '`description` must NOT be a section-heading fragment (e.g. "Overview", "Key points", "Summary"), a code fragment (must not start with `def`/`function`/`class`/`const`/…), or end on a hanging connector word ("a", "the", "and", "to", …).',
49
+ "`description` must NOT merely restate the asset's ref/name; write what the asset actually does.",
50
+ '`description` should NOT start with "When" — that phrasing belongs in `when_to_use`.',
51
+ ];
52
+ /** Rules for the `when_to_use` frontmatter field. Enforced by `isValidWhenToUse`. */
53
+ const WHEN_TO_USE_RULES = [
54
+ `\`when_to_use\` is REQUIRED and must be ${WHEN_TO_USE_MIN_CHARS}–${WHEN_TO_USE_MAX_CHARS} characters describing a concrete trigger. Never write the circular fallback "When working with <name>".`,
55
+ "`description` and `when_to_use` must be different from each other.",
56
+ ];
57
+ /**
58
+ * Asset types that carry a `description` and a body where the
59
+ * frontmatter/body rules apply. (Types without those — if any are added later —
60
+ * simply fall through to the cross-cutting block.)
61
+ */
62
+ const DESCRIPTION_TYPES = new Set(["lesson", "knowledge", "memory", "skill", "command", "agent", "workflow", "fact"]);
63
+ /** Types where `when_to_use` is a HARD requirement (validator rejects if absent). */
64
+ const WHEN_TO_USE_TYPES = new Set(["lesson"]);
65
+ /**
66
+ * Build the hard-rules block for a given asset type, ready to inject as a prompt
67
+ * section. Returns `""` for an unknown type (no over-claiming). The block is
68
+ * deterministic so prompt snapshots stay stable.
69
+ *
70
+ * Inject this VERBATIM into every improve/authoring prompt that creates or edits
71
+ * an asset of `type`, so the agent is told exactly what the gate will reject.
72
+ */
73
+ /**
74
+ * Whether an asset of `type` carries a `description` that the validator
75
+ * (`isValidDescription` / `validateProposalFrontmatter`) treats as required.
76
+ * Reflect uses this to decide when a description-synthesis instruction (and the
77
+ * deterministic reflect-side fallback) must fire for a description-less source.
78
+ */
79
+ export function requiresDescription(type) {
80
+ return DESCRIPTION_TYPES.has(type);
81
+ }
82
+ export function authoringRulesForType(type) {
83
+ const rules = [...FRONTMATTER_BODY_RULES];
84
+ if (DESCRIPTION_TYPES.has(type))
85
+ rules.push(...DESCRIPTION_RULES);
86
+ if (WHEN_TO_USE_TYPES.has(type))
87
+ rules.push(...WHEN_TO_USE_RULES);
88
+ if (rules.length === 0)
89
+ return "";
90
+ const heading = `Hard authoring rules for ${type} assets (the validator REJECTS proposals that violate these):`;
91
+ return [heading, ...rules.map((r) => `- ${r}`)].join("\n");
92
+ }
@@ -44,11 +44,6 @@ export function isRemoteUrl(value) {
44
44
  value.startsWith("ssh://") ||
45
45
  value.startsWith("git://"));
46
46
  }
47
- export function filterNonEmptyStrings(value) {
48
- if (!Array.isArray(value))
49
- return undefined;
50
- return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
51
- }
52
47
  // ── Validators ──────────────────────────────────────────────────────────────
53
48
  /**
54
49
  * Returns true if `type` is a known asset type — either a built-in from
@@ -169,7 +169,8 @@ function migrateProcessEntryToImprove(result, processName, legacy) {
169
169
  * (canonical string sentinel for this release) or when `configVersion` is a
170
170
  * number ≥ 2 AND the config carries no recognized legacy keys.
171
171
  */
172
- export function migrateConfigShape(raw) {
172
+ export function migrateConfigShape(raw, opts) {
173
+ const emitWarn = opts?.warn ?? warn;
173
174
  const hasLegacyKeys = Object.hasOwn(raw, "features") ||
174
175
  (isObj(raw.llm) && (Object.hasOwn(raw.llm, "endpoint") || Object.hasOwn(raw.llm, "features"))) ||
175
176
  isObj(raw.agent) ||
@@ -199,11 +200,11 @@ export function migrateConfigShape(raw) {
199
200
  if (Array.isArray(result.stashes)) {
200
201
  if (!Array.isArray(result.sources)) {
201
202
  result.sources = result.stashes;
202
- console.warn("[akm config-migrate] Legacy `stashes[]` config key renamed to `sources[]`. " +
203
+ emitWarn("[akm config-migrate] Legacy `stashes[]` config key renamed to `sources[]`. " +
203
204
  "Re-save your config to remove the deprecation notice.");
204
205
  }
205
206
  else {
206
- console.warn("[akm config-migrate] Both `stashes[]` and `sources[]` present; `stashes[]` dropped (sources takes precedence).");
207
+ emitWarn("[akm config-migrate] Both `stashes[]` and `sources[]` present; `stashes[]` dropped (sources takes precedence).");
207
208
  }
208
209
  delete result.stashes;
209
210
  changed = true;
@@ -218,7 +219,7 @@ export function migrateConfigShape(raw) {
218
219
  for (const entry of sources) {
219
220
  if (isObj(entry) && entry.type === "openviking") {
220
221
  const name = typeof entry.name === "string" && entry.name ? entry.name : "unnamed";
221
- console.warn(`[akm config-migrate] Source "${name}" (type: openviking) is no longer supported. ` +
222
+ emitWarn(`[akm config-migrate] Source "${name}" (type: openviking) is no longer supported. ` +
222
223
  "Remove it from your config, or replace with a `website`/`git` source. " +
223
224
  "Entry dropped from sources[].");
224
225
  renamed = true;
@@ -349,11 +350,11 @@ export function migrateConfigShape(raw) {
349
350
  const camelKey = toCamelCase(legacyKey);
350
351
  if (typeof legacyVal === "boolean" || isObj(legacyVal)) {
351
352
  migrateProcessEntryToImprove(result, camelKey, legacyVal);
352
- warn(`[akm config-migrate] Unknown features.improve.${legacyKey} migrated to ` +
353
+ emitWarn(`[akm config-migrate] Unknown features.improve.${legacyKey} migrated to ` +
353
354
  `profiles.improve.default.processes.${camelKey}. Please verify the new location.`);
354
355
  }
355
356
  else {
356
- warn(`[akm config-migrate] features.improve.${legacyKey} has an unrecognized value type ` +
357
+ emitWarn(`[akm config-migrate] features.improve.${legacyKey} has an unrecognized value type ` +
357
358
  `(${typeof legacyVal}); dropping. Please re-add it under profiles.improve.* manually.`);
358
359
  }
359
360
  }
@@ -404,11 +405,11 @@ export function migrateConfigShape(raw) {
404
405
  const camelKey = toCamelCase(legacyKey);
405
406
  const ok = migrateGenericGateToSection(result, "index", camelKey, legacyVal);
406
407
  if (ok) {
407
- warn(`[akm config-migrate] Unknown features.index.${legacyKey} migrated to ` +
408
+ emitWarn(`[akm config-migrate] Unknown features.index.${legacyKey} migrated to ` +
408
409
  `index.${camelKey}. Please verify the new location is correct.`);
409
410
  }
410
411
  else {
411
- warn(`[akm config-migrate] features.index.${legacyKey} has an unrecognized value shape; ` +
412
+ emitWarn(`[akm config-migrate] features.index.${legacyKey} has an unrecognized value shape; ` +
412
413
  `dropping. Please re-add it under index.${camelKey} manually if needed.`);
413
414
  }
414
415
  }
@@ -434,11 +435,11 @@ export function migrateConfigShape(raw) {
434
435
  const camelKey = toCamelCase(legacyKey);
435
436
  const ok = migrateGenericGateToSection(result, "search", camelKey, legacyVal);
436
437
  if (ok) {
437
- warn(`[akm config-migrate] Unknown features.search.${legacyKey} migrated to ` +
438
+ emitWarn(`[akm config-migrate] Unknown features.search.${legacyKey} migrated to ` +
438
439
  `search.${camelKey}. Please verify the new location is correct.`);
439
440
  }
440
441
  else {
441
- warn(`[akm config-migrate] features.search.${legacyKey} has an unrecognized value shape; ` +
442
+ emitWarn(`[akm config-migrate] features.search.${legacyKey} has an unrecognized value shape; ` +
442
443
  `dropping. Please re-add it under search.${camelKey} manually if needed.`);
443
444
  }
444
445
  }
@@ -555,7 +556,7 @@ export function migrateConfigShape(raw) {
555
556
  changed = true;
556
557
  }
557
558
  if (improveObj.preset !== undefined) {
558
- console.warn("[akm config-migrate] defaults.improve.preset is no longer supported. " +
559
+ emitWarn("[akm config-migrate] defaults.improve.preset is no longer supported. " +
559
560
  "Use `--profile <name>` (built-ins: default, quick, thorough, memory-focus) instead.");
560
561
  }
561
562
  delete defaultsRaw.improve;