akm-cli 0.7.4 → 0.8.0-rc1

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