akm-cli 0.7.5 → 0.8.0-rc.3

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 (155) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +86 -0
  3. package/dist/cli.js +1023 -521
  4. package/dist/commands/agent-dispatch.js +107 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +812 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +218 -43
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1161 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +291 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +145 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/vault-key-rules.js +67 -0
  38. package/dist/commands/lint/workflow-linter.js +53 -0
  39. package/dist/commands/lint.js +1 -0
  40. package/dist/commands/proposal.js +8 -7
  41. package/dist/commands/propose.js +71 -28
  42. package/dist/commands/reflect.js +135 -35
  43. package/dist/commands/registry-search.js +2 -2
  44. package/dist/commands/remember.js +54 -0
  45. package/dist/commands/schema-repair.js +130 -0
  46. package/dist/commands/search.js +21 -5
  47. package/dist/commands/show.js +125 -20
  48. package/dist/commands/source-add.js +10 -10
  49. package/dist/commands/source-manage.js +11 -19
  50. package/dist/commands/tasks.js +385 -0
  51. package/dist/commands/url-checker.js +39 -0
  52. package/dist/commands/vault.js +168 -77
  53. package/dist/core/action-contributors.js +25 -0
  54. package/dist/core/asset-ref.js +4 -0
  55. package/dist/core/asset-registry.js +4 -16
  56. package/dist/core/asset-spec.js +10 -0
  57. package/dist/core/common.js +100 -0
  58. package/dist/core/concurrent.js +22 -0
  59. package/dist/core/config.js +233 -133
  60. package/dist/core/events.js +73 -126
  61. package/dist/core/frontmatter.js +0 -6
  62. package/dist/core/markdown.js +17 -0
  63. package/dist/core/memory-improve.js +678 -0
  64. package/dist/core/parse.js +155 -0
  65. package/dist/core/paths.js +101 -3
  66. package/dist/core/proposal-validators.js +61 -0
  67. package/dist/core/proposals.js +49 -38
  68. package/dist/core/state-db.js +731 -0
  69. package/dist/core/time.js +51 -0
  70. package/dist/core/warn.js +59 -1
  71. package/dist/indexer/db-search.js +52 -238
  72. package/dist/indexer/db.js +403 -54
  73. package/dist/indexer/ensure-index.js +61 -0
  74. package/dist/indexer/graph-boost.js +247 -94
  75. package/dist/indexer/graph-db.js +201 -0
  76. package/dist/indexer/graph-dedup.js +99 -0
  77. package/dist/indexer/graph-extraction.js +409 -76
  78. package/dist/indexer/index-context.js +10 -0
  79. package/dist/indexer/indexer.js +456 -290
  80. package/dist/indexer/llm-cache.js +47 -0
  81. package/dist/indexer/matchers.js +124 -160
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +196 -197
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/builders.js +109 -0
  93. package/dist/integrations/agent/config.js +203 -3
  94. package/dist/integrations/agent/index.js +5 -2
  95. package/dist/integrations/agent/model-aliases.js +63 -0
  96. package/dist/integrations/agent/profiles.js +67 -5
  97. package/dist/integrations/agent/prompts.js +77 -72
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +93 -22
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +220 -256
  116. package/dist/output/shapes.js +101 -93
  117. package/dist/output/text.js +256 -17
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/registry/resolve.js +8 -16
  121. package/dist/setup/setup.js +510 -11
  122. package/dist/sources/provider-factory.js +2 -1
  123. package/dist/sources/providers/filesystem.js +16 -23
  124. package/dist/sources/providers/git.js +4 -5
  125. package/dist/sources/providers/website.js +15 -22
  126. package/dist/sources/website-ingest.js +4 -0
  127. package/dist/tasks/backends/cron.js +200 -0
  128. package/dist/tasks/backends/exec-utils.js +25 -0
  129. package/dist/tasks/backends/index.js +32 -0
  130. package/dist/tasks/backends/launchd-template.xml +19 -0
  131. package/dist/tasks/backends/launchd.js +184 -0
  132. package/dist/tasks/backends/schtasks-template.xml +29 -0
  133. package/dist/tasks/backends/schtasks.js +212 -0
  134. package/dist/tasks/parser.js +198 -0
  135. package/dist/tasks/resolveAkmBin.js +84 -0
  136. package/dist/tasks/runner.js +432 -0
  137. package/dist/tasks/schedule.js +208 -0
  138. package/dist/tasks/schema.js +13 -0
  139. package/dist/tasks/validator.js +59 -0
  140. package/dist/wiki/index-template.md +12 -0
  141. package/dist/wiki/ingest-workflow-template.md +54 -0
  142. package/dist/wiki/log-template.md +8 -0
  143. package/dist/wiki/schema-template.md +61 -0
  144. package/dist/wiki/wiki-templates.js +12 -0
  145. package/dist/wiki/wiki.js +10 -61
  146. package/dist/workflows/authoring.js +5 -25
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +59 -91
  149. package/dist/workflows/validator.js +1 -1
  150. package/dist/workflows/workflow-template.md +24 -0
  151. package/docs/README.md +5 -2
  152. package/docs/migration/release-notes/0.7.0.md +1 -1
  153. package/docs/migration/release-notes/0.8.0.md +43 -0
  154. package/package.json +3 -2
  155. package/dist/templates/wiki-templates.js +0 -100
@@ -46,17 +46,21 @@
46
46
  * be invoked from CI / automation without spinning up an agent harness.
47
47
  */
48
48
  import fs from "node:fs";
49
+ import path from "node:path";
49
50
  import { parseAssetRef } from "../core/asset-ref";
50
- import { resolveStashDir } from "../core/common";
51
+ import { resolveStashDir, timestampForFilename } from "../core/common";
51
52
  import { loadConfig } from "../core/config";
52
53
  import { ConfigError, UsageError } from "../core/errors";
53
54
  import { appendEvent, readEvents } from "../core/events";
54
55
  import { parseFrontmatter } from "../core/frontmatter";
55
56
  import { lintLessonContent } from "../core/lesson-lint";
57
+ import { stripMarkdownFences } from "../core/markdown";
56
58
  import { createProposal } from "../core/proposals";
57
- import { lookup as indexerLookup } from "../indexer/indexer";
58
- import { chatCompletion } from "../llm/client";
59
- import { tryLlmFeature } from "../llm/feature-gate";
59
+ import { warnVerbose } from "../core/warn";
60
+ import { resolveAssetPath } from "../indexer/path-resolver";
61
+ import { chatCompletion, parseEmbeddedJsonResponse } from "../llm/client";
62
+ import { isLlmFeatureEnabled, tryLlmFeature } from "../llm/feature-gate";
63
+ import { assessMemoryKnowledgePromotionCandidate, deriveKnowledgeRef } from "./distill-promotion-policy";
60
64
  // ── Lesson-ref derivation ───────────────────────────────────────────────────
61
65
  /** Derive the proposed lesson ref from the input ref. See module docblock. */
62
66
  export function deriveLessonRef(inputRef) {
@@ -75,7 +79,7 @@ export function deriveLessonRef(inputRef) {
75
79
  return `lesson:${safe}-lesson`;
76
80
  }
77
81
  // ── Prompt assembly ─────────────────────────────────────────────────────────
78
- const SYSTEM_PROMPT = [
82
+ const LESSON_SYSTEM_PROMPT = [
79
83
  "You are the akm `distill` distiller.",
80
84
  "Given an asset and recent feedback events about it, produce a single",
81
85
  "concise *lesson* an agent should remember next time it works on this",
@@ -92,6 +96,28 @@ const SYSTEM_PROMPT = [
92
96
  "Both `description` and `when_to_use` MUST be non-empty single-line strings.",
93
97
  "Output ONLY the lesson file contents — no prose, no fences, no preamble.",
94
98
  ].join("\n");
99
+ const KNOWLEDGE_SYSTEM_PROMPT = [
100
+ "You are the akm `distill` distiller.",
101
+ "Given an asset and recent feedback events about it, produce a concise",
102
+ "*knowledge* markdown document capturing the durable, reusable facts.",
103
+ "Prefer stable guidance over narrative recap.",
104
+ "If you include YAML frontmatter, keep it compatible with normal knowledge",
105
+ "assets (for example `description`, `tags`, `sources`, `observed_at`).",
106
+ "Include a meaningful markdown body.",
107
+ "Output ONLY the knowledge file contents — no prose, no fences, no preamble.",
108
+ ].join("\n");
109
+ function validateKnowledgeContent(content, inputRef) {
110
+ const parsed = parseFrontmatter(content);
111
+ if (parsed.content.trim().length > 0)
112
+ return [];
113
+ return [
114
+ {
115
+ kind: "missing-body",
116
+ field: "body",
117
+ message: `Distilled knowledge for ${inputRef} must include a non-empty markdown body.`,
118
+ },
119
+ ];
120
+ }
95
121
  /** Pure: build the user-prompt body. Exported for tests. */
96
122
  export function buildDistillPrompt(input) {
97
123
  const lines = [];
@@ -99,8 +125,9 @@ export function buildDistillPrompt(input) {
99
125
  lines.push("");
100
126
  lines.push("Asset content:");
101
127
  if (input.assetContent) {
128
+ const body = input.assetContent.trim().slice(0, 3000);
102
129
  lines.push("```");
103
- lines.push(input.assetContent.trim());
130
+ lines.push(body);
104
131
  lines.push("```");
105
132
  }
106
133
  else {
@@ -118,9 +145,96 @@ export function buildDistillPrompt(input) {
118
145
  }
119
146
  }
120
147
  lines.push("");
121
- lines.push("Produce the lesson markdown file now.");
148
+ lines.push(`Produce the ${input.proposalKind === "knowledge" ? "knowledge" : "lesson"} markdown file now.`);
122
149
  return lines.join("\n");
123
150
  }
151
+ // ── LLM-as-judge quality gate (P2-B) ────────────────────────────────────────
152
+ function buildJudgePrompt(lessonContent, sourceContent) {
153
+ return [
154
+ "You are evaluating a proposed lesson asset for an akm knowledge base.",
155
+ "",
156
+ "Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
157
+ "1. NOVELTY: Does the lesson add information not already present in the source asset?",
158
+ "2. ACTIONABILITY: Can an agent follow this lesson without additional context?",
159
+ "3. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
160
+ "",
161
+ "Source asset content:",
162
+ "```",
163
+ sourceContent.slice(0, 2000),
164
+ "```",
165
+ "",
166
+ "Proposed lesson content:",
167
+ "```",
168
+ lessonContent.slice(0, 1000),
169
+ "```",
170
+ "",
171
+ 'Return ONLY valid JSON, no prose: {"score": <average score 1-5 as float>, "reason": "<one sentence>"}',
172
+ ].join("\n");
173
+ }
174
+ async function runLessonQualityJudge(config, lessonContent, sourceContent, chat) {
175
+ if (!config.llm) {
176
+ return { pass: true, score: -1, reason: "no LLM configured — passing through" };
177
+ }
178
+ const judgeLlmConfig = config.llm.judgeModel ? { ...config.llm, model: config.llm.judgeModel } : config.llm;
179
+ const JUDGE_TIMEOUT_MS = 8_000;
180
+ try {
181
+ const raw = await Promise.race([
182
+ chat(judgeLlmConfig, [
183
+ { role: "system", content: "Return only valid JSON. No prose." },
184
+ { role: "user", content: buildJudgePrompt(lessonContent, sourceContent) },
185
+ ]),
186
+ new Promise((_, reject) => setTimeout(() => reject(new Error("judge timeout")), JUDGE_TIMEOUT_MS)),
187
+ ]);
188
+ const parsed = parseEmbeddedJsonResponse(raw);
189
+ if (!parsed || typeof parsed.score !== "number") {
190
+ return { pass: true, score: -1, reason: "judge parse failed — passing through" };
191
+ }
192
+ return { pass: parsed.score >= 3, score: parsed.score, reason: parsed.reason ?? "" };
193
+ }
194
+ catch {
195
+ return { pass: true, score: -1, reason: "judge failed — passing through" };
196
+ }
197
+ }
198
+ // ── Quality-rejection helper ─────────────────────────────────────────────────
199
+ /**
200
+ * Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
201
+ * quality-rejected event, and return the `quality_rejected` envelope.
202
+ *
203
+ * @param stash - Root stash directory.
204
+ * @param inputRef - The original input ref (for the event).
205
+ * @param lessonRef - The proposed lesson/knowledge ref.
206
+ * @param content - The raw content that failed the quality gate.
207
+ * @param score - Quality score from the judge.
208
+ * @param reason - Human-readable rejection reason.
209
+ * @param extraMeta - Optional additional metadata for the event.
210
+ */
211
+ function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}) {
212
+ const rejectDir = path.join(stash, ".akm", "distill-rejected");
213
+ fs.mkdirSync(rejectDir, { recursive: true });
214
+ const ts = timestampForFilename();
215
+ fs.writeFileSync(path.join(rejectDir, `${ts}-${lessonRef}.md`), `---\nscore: ${score}\nreason: ${reason}\n---\n\n${content}`, "utf8");
216
+ appendEvent({
217
+ eventType: "distill_invoked",
218
+ ref: inputRef,
219
+ metadata: {
220
+ outcome: "quality_rejected",
221
+ lessonRef,
222
+ score,
223
+ reason,
224
+ ...extraMeta,
225
+ },
226
+ });
227
+ return {
228
+ schemaVersion: 1,
229
+ ok: true,
230
+ outcome: "quality_rejected",
231
+ inputRef,
232
+ lessonRef,
233
+ score,
234
+ reason,
235
+ ...extraMeta,
236
+ };
237
+ }
124
238
  // ── Main entry point ────────────────────────────────────────────────────────
125
239
  /**
126
240
  * Run a single bounded distillation pass for `ref`. Always emits exactly one
@@ -134,7 +248,7 @@ export async function akmDistill(options) {
134
248
  }
135
249
  // Validate the ref shape up front so a typo never reaches the LLM.
136
250
  parseAssetRef(inputRef);
137
- const lessonRef = deriveLessonRef(inputRef);
251
+ const targetKind = options.proposalKind ?? "lesson";
138
252
  const config = options.config ?? loadConfig();
139
253
  const stash = options.stashDir ?? resolveStashDir();
140
254
  const chat = options.chat ?? chatCompletion;
@@ -175,9 +289,64 @@ export async function akmDistill(options) {
175
289
  eventType: e.eventType,
176
290
  ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
177
291
  }));
178
- const userPrompt = buildDistillPrompt({ inputRef, assetContent, feedback });
292
+ const promotion = targetKind === "lesson"
293
+ ? null
294
+ : assessMemoryKnowledgePromotionCandidate({
295
+ inputRef,
296
+ assetContent,
297
+ feedbackEvents: filteredEvents.map((event) => ({
298
+ ...(event.metadata !== undefined ? { metadata: event.metadata } : {}),
299
+ })),
300
+ });
301
+ if (promotion?.promote && promotion.content && (targetKind === "knowledge" || targetKind === "auto")) {
302
+ // Apply quality gate to fast-path knowledge promotion (Risk 4 fix).
303
+ if (isLlmFeatureEnabled(config, "lesson_quality_gate")) {
304
+ const judgeResult = await runLessonQualityJudge(config, promotion.content, assetContent ?? "", chat);
305
+ if (!judgeResult.pass) {
306
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, promotion.content, judgeResult.score, judgeResult.reason);
307
+ }
308
+ }
309
+ const knowledgeParsed = parseFrontmatter(promotion.content);
310
+ const proposal = createProposal(stash, {
311
+ ref: promotion.knowledgeRef,
312
+ source: "distill",
313
+ ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
314
+ payload: {
315
+ content: promotion.content,
316
+ ...(Object.keys(knowledgeParsed.data).length > 0 ? { frontmatter: knowledgeParsed.data } : {}),
317
+ },
318
+ }, options.ctx);
319
+ appendEvent({
320
+ eventType: "distill_invoked",
321
+ ref: inputRef,
322
+ metadata: {
323
+ outcome: "queued",
324
+ lessonRef: promotion.knowledgeRef,
325
+ proposalRef: promotion.knowledgeRef,
326
+ proposalKind: "knowledge",
327
+ proposalId: proposal.id,
328
+ ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
329
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
330
+ },
331
+ });
332
+ return {
333
+ schemaVersion: 1,
334
+ ok: true,
335
+ outcome: "queued",
336
+ inputRef,
337
+ lessonRef: promotion.knowledgeRef,
338
+ proposalRef: promotion.knowledgeRef,
339
+ proposalKind: "knowledge",
340
+ proposalId: proposal.id,
341
+ proposal,
342
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
343
+ };
344
+ }
345
+ const effectiveProposalKind = targetKind === "knowledge" ? "knowledge" : "lesson";
346
+ const effectiveLessonRef = effectiveProposalKind === "knowledge" ? deriveKnowledgeRef(inputRef) : deriveLessonRef(inputRef);
347
+ const userPrompt = buildDistillPrompt({ inputRef, assetContent, feedback, proposalKind: effectiveProposalKind });
179
348
  const messages = [
180
- { role: "system", content: SYSTEM_PROMPT },
349
+ { role: "system", content: effectiveProposalKind === "knowledge" ? KNOWLEDGE_SYSTEM_PROMPT : LESSON_SYSTEM_PROMPT },
181
350
  { role: "user", content: userPrompt },
182
351
  ];
183
352
  // Single bounded LLM call. The wrapper handles the gate-check, 30 s
@@ -190,14 +359,21 @@ export async function akmDistill(options) {
190
359
  throw new ConfigError("No LLM connection configured. Set `llm.endpoint` and `llm.model` in the akm config.", "LLM_NOT_CONFIGURED");
191
360
  }
192
361
  return chat(config.llm, messages);
193
- }, null);
362
+ }, null, {
363
+ onFallback: (evt) => {
364
+ // Log the fallback reason; the caller (raw === null path) handles
365
+ // emitting the distill_invoked event so we don't double-emit here.
366
+ warnVerbose(`[akm] LLM fallback for ${evt.feature}: ${evt.reason}`);
367
+ },
368
+ });
194
369
  if (raw === null || raw.trim() === "") {
195
370
  appendEvent({
196
371
  eventType: "distill_invoked",
197
372
  ref: inputRef,
198
373
  metadata: {
199
374
  outcome: "skipped",
200
- lessonRef,
375
+ lessonRef: effectiveLessonRef,
376
+ proposalKind: effectiveProposalKind,
201
377
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
202
378
  },
203
379
  });
@@ -206,7 +382,9 @@ export async function akmDistill(options) {
206
382
  ok: true,
207
383
  outcome: "skipped",
208
384
  inputRef,
209
- lessonRef,
385
+ lessonRef: effectiveLessonRef,
386
+ proposalRef: effectiveLessonRef,
387
+ proposalKind: effectiveProposalKind,
210
388
  message: "feedback distillation is disabled or the LLM call failed; no proposal created.",
211
389
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
212
390
  };
@@ -217,27 +395,40 @@ export async function akmDistill(options) {
217
395
  // canonical gate for required frontmatter (v1 spec §13). On failure we
218
396
  // surface a structured error and exit non-zero — but still emit
219
397
  // `distill_invoked` so the failure is observable.
220
- const lintReport = lintLessonContent(content, `distill:${inputRef}`);
221
- if (lintReport.findings.length > 0) {
398
+ const findings = effectiveProposalKind === "knowledge"
399
+ ? validateKnowledgeContent(content, inputRef)
400
+ : lintLessonContent(content, `distill:${inputRef}`).findings;
401
+ if (findings.length > 0) {
222
402
  appendEvent({
223
403
  eventType: "distill_invoked",
224
404
  ref: inputRef,
225
405
  metadata: {
226
406
  outcome: "validation_failed",
227
- lessonRef,
228
- findingKinds: lintReport.findings.map((f) => f.kind),
407
+ lessonRef: effectiveLessonRef,
408
+ proposalKind: effectiveProposalKind,
409
+ findingKinds: findings.map((f) => f.kind),
229
410
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
230
411
  },
231
412
  });
232
- const message = lintReport.findings.map((f) => f.message).join("\n");
233
- throw new UsageError(`Distilled lesson failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Lessons require non-empty `description` and `when_to_use` frontmatter fields. See v1 spec §13.");
413
+ const message = findings.map((f) => f.message).join("\n");
414
+ throw new UsageError(`Distilled ${effectiveProposalKind} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", effectiveProposalKind === "knowledge"
415
+ ? "Knowledge proposals require a non-empty markdown body."
416
+ : "Lessons require non-empty `description` and `when_to_use` frontmatter fields. See v1 spec §13.");
417
+ }
418
+ // LLM-as-judge quality gate (P2-B). Only active when the feature flag is
419
+ // explicitly enabled. Fail-open: judge failures always pass through.
420
+ if (isLlmFeatureEnabled(config, "lesson_quality_gate")) {
421
+ const judgeResult = await runLessonQualityJudge(config, content, assetContent ?? "", chat);
422
+ if (!judgeResult.pass) {
423
+ return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {});
424
+ }
234
425
  }
235
426
  // Round-trip the parsed frontmatter so the proposal carries it as a
236
427
  // structured payload alongside the raw content (matches the shape used by
237
428
  // other proposal sources).
238
429
  const parsed = parseFrontmatter(content);
239
430
  const proposal = createProposal(stash, {
240
- ref: lessonRef,
431
+ ref: effectiveLessonRef,
241
432
  source: "distill",
242
433
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
243
434
  payload: {
@@ -250,7 +441,9 @@ export async function akmDistill(options) {
250
441
  ref: inputRef,
251
442
  metadata: {
252
443
  outcome: "queued",
253
- lessonRef,
444
+ lessonRef: effectiveLessonRef,
445
+ proposalRef: effectiveLessonRef,
446
+ proposalKind: effectiveProposalKind,
254
447
  proposalId: proposal.id,
255
448
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
256
449
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
@@ -261,7 +454,9 @@ export async function akmDistill(options) {
261
454
  ok: true,
262
455
  outcome: "queued",
263
456
  inputRef,
264
- lessonRef,
457
+ lessonRef: effectiveLessonRef,
458
+ proposalRef: effectiveLessonRef,
459
+ proposalKind: effectiveProposalKind,
265
460
  proposalId: proposal.id,
266
461
  proposal,
267
462
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
@@ -269,25 +464,5 @@ export async function akmDistill(options) {
269
464
  }
270
465
  // ── Helpers ─────────────────────────────────────────────────────────────────
271
466
  async function defaultLookup(ref) {
272
- try {
273
- const entry = await indexerLookup(parseAssetRef(ref));
274
- return entry?.filePath ?? null;
275
- }
276
- catch {
277
- return null;
278
- }
279
- }
280
- /** Best-effort fence stripping. Keeps the body intact when no fence is present. */
281
- function stripMarkdownFences(raw) {
282
- // Strip <think>…</think> reasoning blocks first — local LLMs (e.g. Qwen3)
283
- // emit these before the content, which breaks YAML frontmatter detection.
284
- const stripped = raw
285
- .trim()
286
- .replace(/<think>[\s\S]*?<\/think>/gi, "")
287
- .trim();
288
- // Only strip outer triple-fence pairs — leave inner code blocks alone.
289
- const fence = stripped.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/i);
290
- if (fence)
291
- return fence[1].trim();
292
- return stripped;
467
+ return resolveAssetPath(ref, { mode: "index-only" });
293
468
  }
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { writeFileAtomic } from "../core/common";
4
+ export function writeEvalCase(stashDir, evalCase) {
5
+ const evalDir = path.join(stashDir, ".akm", "eval-cases");
6
+ fs.mkdirSync(evalDir, { recursive: true });
7
+ const fileName = `${evalCase.slug}.md`;
8
+ const filePath = path.join(evalDir, fileName);
9
+ const content = `---
10
+ ref: ${evalCase.ref}
11
+ failureReason: ${evalCase.failureReason}
12
+ assetType: ${evalCase.assetType}
13
+ rejectedAt: ${evalCase.rejectedAt}
14
+ source: ${evalCase.source}
15
+ ---
16
+
17
+ # Eval Case: ${evalCase.ref}
18
+
19
+ **Failure reason:** ${evalCase.failureReason}
20
+ **Source:** ${evalCase.source}
21
+ **Asset type:** ${evalCase.assetType}
22
+
23
+ This case was automatically captured when a distillation or proposal was rejected.
24
+ Use it as a regression test: future improve runs on this ref should not produce
25
+ output that would be rejected for the same reason.
26
+ `;
27
+ writeFileAtomic(filePath, content);
28
+ return filePath;
29
+ }
30
+ export function countEvalCases(stashDir) {
31
+ const evalDir = path.join(stashDir, ".akm", "eval-cases");
32
+ if (!fs.existsSync(evalDir))
33
+ return 0;
34
+ try {
35
+ return fs.readdirSync(evalDir).filter((f) => f.endsWith(".md")).length;
36
+ }
37
+ catch {
38
+ return 0;
39
+ }
40
+ }
@@ -10,6 +10,7 @@
10
10
  import { parseAssetRef } from "../core/asset-ref";
11
11
  import { UsageError } from "../core/errors";
12
12
  import { readEvents, tailEvents } from "../core/events";
13
+ import { parseSinceToIso } from "../core/time";
13
14
  /**
14
15
  * Parse `--since` accepting either a byte-offset cursor (`@offset:<int>`) for
15
16
  * cross-process resumption, or a timestamp / epoch-ms (the existing form).
@@ -30,7 +31,7 @@ function parseSinceFlag(since) {
30
31
  }
31
32
  return { sinceOffset: value };
32
33
  }
33
- return { since: normalizeSince(trimmed) };
34
+ return { since: parseSinceToIso(trimmed) };
34
35
  }
35
36
  function validateRef(ref) {
36
37
  if (ref === undefined)
@@ -42,28 +43,6 @@ function validateRef(ref) {
42
43
  parseAssetRef(trimmed);
43
44
  return trimmed;
44
45
  }
45
- function normalizeSince(since) {
46
- if (since === undefined)
47
- return undefined;
48
- const trimmed = since.trim();
49
- if (!trimmed) {
50
- throw new UsageError("--since cannot be empty.", "INVALID_FLAG_VALUE");
51
- }
52
- // Accept ISO timestamp (preferred), epoch ms, or plain date.
53
- if (/^\d+$/.test(trimmed)) {
54
- const ms = Number.parseInt(trimmed, 10);
55
- const d = new Date(ms);
56
- if (Number.isNaN(d.getTime())) {
57
- throw new UsageError(`Invalid --since value: ${since}`, "INVALID_FLAG_VALUE");
58
- }
59
- return d.toISOString();
60
- }
61
- const parsed = new Date(trimmed);
62
- if (Number.isNaN(parsed.getTime())) {
63
- throw new UsageError(`Invalid --since value: ${since}. Expected ISO timestamp (e.g. 2026-04-01T00:00:00Z) or epoch ms.`, "INVALID_FLAG_VALUE");
64
- }
65
- return parsed.toISOString();
66
- }
67
46
  export function akmEventsList(options = {}) {
68
47
  const ref = validateRef(options.ref);
69
48
  const parsed = parseSinceFlag(options.since);