akm-cli 0.9.0-beta.54 → 0.9.0-beta.55
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.
- package/dist/cli.js +5 -3
- package/dist/commands/agent/contribute-cli.js +2 -3
- package/dist/commands/env/env-cli.js +187 -202
- package/dist/commands/env/secret-cli.js +109 -121
- package/dist/commands/feedback-cli.js +152 -155
- package/dist/commands/health/advisories.js +151 -0
- package/dist/commands/health/improve-metrics.js +754 -0
- package/dist/commands/health/llm-usage.js +65 -0
- package/dist/commands/health/md-report.js +103 -0
- package/dist/commands/health/metrics.js +278 -0
- package/dist/commands/health/task-runs.js +135 -0
- package/dist/commands/health/types.js +18 -0
- package/dist/commands/health/windows.js +196 -0
- package/dist/commands/health.js +14 -1624
- package/dist/commands/improve/anti-collapse.js +170 -0
- package/dist/commands/improve/collapse-detector.js +3 -2
- package/dist/commands/improve/consolidate.js +636 -633
- package/dist/commands/improve/dedup.js +1 -1
- package/dist/commands/improve/distill/content-repair.js +202 -0
- package/dist/commands/improve/distill/promote-memory.js +228 -0
- package/dist/commands/improve/distill/quality-gate.js +233 -0
- package/dist/commands/improve/distill-guards.js +127 -0
- package/dist/commands/improve/distill.js +49 -575
- package/dist/commands/improve/extract-cli.js +74 -76
- package/dist/commands/improve/extract.js +6 -4
- package/dist/commands/improve/hot-probation.js +45 -0
- package/dist/commands/improve/improve-auto-accept.js +3 -2
- package/dist/commands/improve/improve-cli.js +14 -13
- package/dist/commands/improve/improve-result-file.js +2 -1
- package/dist/commands/improve/improve.js +6 -5
- package/dist/commands/improve/loop-stages.js +19 -21
- package/dist/commands/improve/preparation.js +4 -2
- package/dist/commands/improve/procedural.js +10 -31
- package/dist/commands/improve/recombine.js +19 -43
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/schema-similarity-gate.js +168 -0
- package/dist/commands/improve/shared.js +48 -0
- package/dist/commands/observability-cli.js +4 -4
- package/dist/commands/proposal/drain-policies.js +2 -2
- package/dist/commands/proposal/drain.js +1 -1
- package/dist/commands/proposal/legacy-import.js +115 -0
- package/dist/commands/proposal/proposal-cli.js +3 -3
- package/dist/commands/proposal/proposal.js +2 -1
- package/dist/commands/proposal/propose.js +1 -1
- package/dist/commands/proposal/repository.js +829 -0
- package/dist/commands/proposal/validators/proposals.js +5 -920
- package/dist/commands/read/remember-cli.js +132 -137
- package/dist/commands/read/search-cli.js +1 -1
- package/dist/commands/registry-cli.js +76 -87
- package/dist/commands/sources/add-cli.js +90 -94
- package/dist/commands/sources/history.js +1 -1
- package/dist/commands/sources/schema-repair.js +1 -1
- package/dist/commands/sources/sources-cli.js +3 -3
- package/dist/commands/sources/stash-cli.js +1 -1
- package/dist/commands/tasks/tasks-cli.js +1 -2
- package/dist/commands/wiki-cli.js +2 -3
- package/dist/core/common.js +3 -3
- package/dist/core/config/config-schema.js +6 -0
- package/dist/core/deep-merge.js +38 -0
- package/dist/core/events.js +2 -1
- package/dist/core/logs-db.js +8 -13
- package/dist/core/paths.js +14 -14
- package/dist/core/state-db.js +13 -1140
- package/dist/indexer/db/db.js +66 -709
- package/dist/indexer/db/entry-mapper.js +41 -0
- package/dist/indexer/db/schema.js +516 -0
- package/dist/indexer/feedback/utility-policy.js +85 -0
- package/dist/indexer/graph/graph-extraction.js +2 -1
- package/dist/indexer/index-writer-lock.js +9 -0
- package/dist/indexer/indexer.js +78 -23
- package/dist/indexer/search/fts-query.js +51 -0
- package/dist/integrations/agent/spawn.js +15 -66
- package/dist/output/text/helpers.js +13 -0
- package/dist/scripts/migrate-storage.js +6891 -7436
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +44 -43
- package/dist/setup/legacy-config.js +106 -0
- package/dist/setup/prompt.js +57 -0
- package/dist/setup/providers.js +14 -0
- package/dist/setup/semantic-assets.js +124 -0
- package/dist/setup/setup.js +24 -1607
- package/dist/setup/steps/connection.js +734 -0
- package/dist/setup/steps/output.js +31 -0
- package/dist/setup/steps/platforms.js +124 -0
- package/dist/setup/steps/semantic.js +27 -0
- package/dist/setup/steps/sources.js +222 -0
- package/dist/setup/steps/stashdir.js +42 -0
- package/dist/setup/steps/tasks.js +152 -0
- package/dist/storage/repositories/canaries-repository.js +107 -0
- package/dist/storage/repositories/consolidation-repository.js +38 -0
- package/dist/storage/repositories/embeddings-repository.js +72 -0
- package/dist/storage/repositories/events-repository.js +187 -0
- package/dist/storage/repositories/extract-sessions-repository.js +96 -0
- package/dist/storage/repositories/improve-runs-repository.js +130 -0
- package/dist/storage/repositories/index-db.js +4 -7
- package/dist/storage/repositories/proposals-repository.js +220 -0
- package/dist/storage/repositories/recombine-repository.js +213 -0
- package/dist/storage/repositories/task-history-repository.js +93 -0
- package/dist/storage/sqlite-pragmas.js +3 -3
- package/dist/tasks/runner.js +2 -1
- package/package.json +1 -1
- package/dist/commands/improve/homeostatic.js +0 -497
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
* be invoked from CI / automation without spinning up an agent harness.
|
|
52
52
|
*/
|
|
53
53
|
import fs from "node:fs";
|
|
54
|
-
import path from "node:path";
|
|
55
54
|
import distillKnowledgeSystemPrompt from "../../assets/prompts/distill-knowledge-system.md" with { type: "text" };
|
|
56
55
|
import distillLessonSystemPrompt from "../../assets/prompts/distill-lesson-system.md" with { type: "text" };
|
|
57
56
|
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
@@ -59,7 +58,7 @@ import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
|
|
|
59
58
|
import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/frontmatter.js";
|
|
60
59
|
import { stripMarkdownFences } from "../../core/asset/markdown.js";
|
|
61
60
|
import { authoringRulesForType } from "../../core/authoring-rules.js";
|
|
62
|
-
import { resolveStashDir
|
|
61
|
+
import { resolveStashDir } from "../../core/common.js";
|
|
63
62
|
import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
|
|
64
63
|
import { ConfigError, UsageError } from "../../core/errors.js";
|
|
65
64
|
import { appendEvent, readEvents } from "../../core/events.js";
|
|
@@ -72,13 +71,18 @@ import { closeDatabase, getAllEntries, openIndexDatabase } from "../../indexer/d
|
|
|
72
71
|
import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
|
|
73
72
|
import { chatCompletion, parseEmbeddedJsonResponse } from "../../llm/client.js";
|
|
74
73
|
import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
|
|
75
|
-
import { createProposal, isProposalSkipped, listProposals, } from "../proposal/
|
|
76
|
-
import { akmSearch } from "../read/search.js";
|
|
74
|
+
import { createProposal, isProposalSkipped, listProposals, } from "../proposal/repository.js";
|
|
77
75
|
import { stripFrontmatterBody as stripBodyForFidelity } from "./dedup.js";
|
|
78
|
-
import {
|
|
76
|
+
import { autoRepairLessonFrontmatter, autoSwapDescriptionWhenToUse, collectLessonQualityFindings, repairLessonDescriptionTruncation, } from "./distill/content-repair.js";
|
|
77
|
+
import { promoteMemoryToKnowledge } from "./distill/promote-memory.js";
|
|
78
|
+
import { fetchTopSimilarLessons, persistOutputEncodingSalience, runLessonQualityJudge, writeQualityRejection, } from "./distill/quality-gate.js";
|
|
79
|
+
import { buildClsContext, checkDistillFidelity } from "./distill-guards.js";
|
|
80
|
+
import { deriveKnowledgeRef } from "./distill-promotion-policy.js";
|
|
79
81
|
import { buildRefVocabulary, scoreEncodingSalience } from "./encoding-salience.js";
|
|
80
|
-
import { buildClsContext, checkDistillFidelity } from "./homeostatic.js";
|
|
81
82
|
import { computeSalience, upsertAssetSalience } from "./salience.js";
|
|
83
|
+
// Re-exported for `reflect.ts`, which applies the same LLM-as-judge gate to
|
|
84
|
+
// reflect proposals (R-5 / #374).
|
|
85
|
+
export { runLessonQualityJudge };
|
|
82
86
|
/**
|
|
83
87
|
* Asset-ref types that `akm distill` structurally refuses as inputs.
|
|
84
88
|
*
|
|
@@ -123,7 +127,6 @@ export function deriveLessonRef(inputRef) {
|
|
|
123
127
|
.replace(/^-|-$/g, "");
|
|
124
128
|
return `lesson:${safe}-lesson`;
|
|
125
129
|
}
|
|
126
|
-
import { repairTruncatedDescription } from "../../core/text-truncation.js";
|
|
127
130
|
// ── Content quality validators ──────────────────────────────────────────────
|
|
128
131
|
//
|
|
129
132
|
// The actual implementations now live in `core/proposal-quality-validators.ts`
|
|
@@ -406,218 +409,6 @@ export function buildDistillPrompt(input) {
|
|
|
406
409
|
}
|
|
407
410
|
return lines.join("\n");
|
|
408
411
|
}
|
|
409
|
-
// ── D-4 / #390: Top-3 similar lessons retrieval ──────────────────────────────
|
|
410
|
-
/**
|
|
411
|
-
* Default implementation: use akmSearch to find top-N similar lesson assets.
|
|
412
|
-
* Returns empty array when search fails or returns no results.
|
|
413
|
-
* Requires embedding configured for semantic similarity; degrades gracefully.
|
|
414
|
-
*/
|
|
415
|
-
async function fetchTopSimilarLessons(query, n, _stashDir) {
|
|
416
|
-
try {
|
|
417
|
-
const result = await akmSearch({
|
|
418
|
-
query,
|
|
419
|
-
type: "lesson",
|
|
420
|
-
limit: n,
|
|
421
|
-
skipLogging: true,
|
|
422
|
-
eventSource: "improve",
|
|
423
|
-
});
|
|
424
|
-
const hits = result?.hits ?? [];
|
|
425
|
-
return hits
|
|
426
|
-
.filter((h) => "path" in h && typeof h.path === "string")
|
|
427
|
-
.slice(0, n)
|
|
428
|
-
.map((h) => {
|
|
429
|
-
let content = "";
|
|
430
|
-
try {
|
|
431
|
-
if (h.path && fs.existsSync(h.path)) {
|
|
432
|
-
content = fs.readFileSync(h.path, "utf8");
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
catch {
|
|
436
|
-
/* best-effort */
|
|
437
|
-
}
|
|
438
|
-
return { ref: h.ref, content };
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
catch {
|
|
442
|
-
return [];
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
// ── LLM-as-judge quality gate (P2-B) ────────────────────────────────────────
|
|
446
|
-
/**
|
|
447
|
-
* D-4 / #390: Build the LLM-as-judge prompt.
|
|
448
|
-
*
|
|
449
|
-
* When similarLessons are provided (top-3 by embedding similarity), they are
|
|
450
|
-
* included in the context so the judge can lower the score for near-duplicates.
|
|
451
|
-
* Voyager arXiv:2305.16291 — skill library admission requires similarity check
|
|
452
|
-
* against the existing library. A-MEM arXiv:2502.12110 — new notes are checked
|
|
453
|
-
* against existing notes before linking.
|
|
454
|
-
*/
|
|
455
|
-
function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
|
|
456
|
-
const lines = [
|
|
457
|
-
"You are evaluating a proposed lesson asset for an akm knowledge base.",
|
|
458
|
-
"",
|
|
459
|
-
"Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
|
|
460
|
-
"1. NOVELTY: Does the lesson add information not already present in the source asset?",
|
|
461
|
-
"2. ACTIONABILITY: Can an agent follow this lesson without additional context?",
|
|
462
|
-
"3. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
|
|
463
|
-
"",
|
|
464
|
-
"Source asset content:",
|
|
465
|
-
"```",
|
|
466
|
-
sourceContent.slice(0, 2000),
|
|
467
|
-
"```",
|
|
468
|
-
];
|
|
469
|
-
if (similarLessons && similarLessons.length > 0) {
|
|
470
|
-
lines.push("");
|
|
471
|
-
lines.push("Existing similar lessons (top-3 by similarity). Rate lower if the proposed lesson is substantially similar to any of these:");
|
|
472
|
-
for (const sl of similarLessons) {
|
|
473
|
-
lines.push(`\nExisting lesson ref: ${sl.ref}`);
|
|
474
|
-
lines.push("```");
|
|
475
|
-
lines.push(sl.content.slice(0, 500));
|
|
476
|
-
lines.push("```");
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
lines.push("");
|
|
480
|
-
lines.push("Proposed lesson content:");
|
|
481
|
-
lines.push("```");
|
|
482
|
-
lines.push(lessonContent.slice(0, 1000));
|
|
483
|
-
lines.push("```");
|
|
484
|
-
lines.push("");
|
|
485
|
-
lines.push('Return ONLY valid JSON, no prose: {"score": <average score 1-5 as float>, "reason": "<one sentence>"}');
|
|
486
|
-
return lines.join("\n");
|
|
487
|
-
}
|
|
488
|
-
/**
|
|
489
|
-
* Run the LLM-as-judge quality gate on a proposal's content.
|
|
490
|
-
*
|
|
491
|
-
* Exported so reflect.ts can apply the same gate to reflect proposals (R-5 / #374).
|
|
492
|
-
* Gated by the flag name `lesson_quality_gate` (or its alias
|
|
493
|
-
* `proposal_quality_gate`) via {@link isLlmFeatureEnabled} — which reads
|
|
494
|
-
* `profiles.improve.default.processes.distill.qualityGate.enabled` (and the
|
|
495
|
-
* corresponding `.reflect.qualityGate.enabled` for proposals).
|
|
496
|
-
*
|
|
497
|
-
* Fail-open: returns `pass: true` on timeout, parse failure, or missing LLM.
|
|
498
|
-
*/
|
|
499
|
-
export async function runLessonQualityJudge(config, lessonContent, sourceContent, chat,
|
|
500
|
-
/** D-4 / #390: top-3 similar existing lessons for dedup check. */
|
|
501
|
-
similarLessons) {
|
|
502
|
-
const llmConfig = getDefaultLlmConfig(config);
|
|
503
|
-
if (!llmConfig) {
|
|
504
|
-
return { pass: true, score: -1, reason: "no LLM configured — passing through" };
|
|
505
|
-
}
|
|
506
|
-
const judgeLlmConfig = llmConfig.judgeModel ? { ...llmConfig, model: llmConfig.judgeModel } : llmConfig;
|
|
507
|
-
const JUDGE_TIMEOUT_MS = 8_000;
|
|
508
|
-
try {
|
|
509
|
-
const raw = await Promise.race([
|
|
510
|
-
chat(judgeLlmConfig, [
|
|
511
|
-
{ role: "system", content: "Return only valid JSON. No prose." },
|
|
512
|
-
{ role: "user", content: buildJudgePrompt(lessonContent, sourceContent, similarLessons) },
|
|
513
|
-
]),
|
|
514
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("judge timeout")), JUDGE_TIMEOUT_MS)),
|
|
515
|
-
]);
|
|
516
|
-
const parsed = parseEmbeddedJsonResponse(raw);
|
|
517
|
-
if (!parsed || typeof parsed.score !== "number") {
|
|
518
|
-
return { pass: true, score: -1, reason: "judge parse failed — passing through" };
|
|
519
|
-
}
|
|
520
|
-
// D-5 / #388: Three-band system (MT-Bench arXiv:2306.05685 — ~±0.5 judge variance).
|
|
521
|
-
// >= 3.5: auto-queue as pending (pass: true)
|
|
522
|
-
// 2.5–3.5: review-needed band — uncertain, escalate to human (reviewNeeded: true)
|
|
523
|
-
// < 2.5: auto-reject (pass: false)
|
|
524
|
-
const score = parsed.score;
|
|
525
|
-
const reason = parsed.reason ?? "";
|
|
526
|
-
if (score >= 3.5) {
|
|
527
|
-
return { pass: true, score, reason };
|
|
528
|
-
}
|
|
529
|
-
if (score >= 2.5) {
|
|
530
|
-
// Uncertainty band: treat as failed for auto-queuing but flag for review.
|
|
531
|
-
return { pass: false, score, reason, reviewNeeded: true };
|
|
532
|
-
}
|
|
533
|
-
return { pass: false, score, reason };
|
|
534
|
-
}
|
|
535
|
-
catch {
|
|
536
|
-
return { pass: true, score: -1, reason: "judge failed — passing through" };
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
// ── Quality-rejection helper ─────────────────────────────────────────────────
|
|
540
|
-
/**
|
|
541
|
-
* Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
|
|
542
|
-
* quality-rejected event, and return the `quality_rejected` envelope.
|
|
543
|
-
*
|
|
544
|
-
* @param stash - Root stash directory.
|
|
545
|
-
* @param inputRef - The original input ref (for the event).
|
|
546
|
-
* @param lessonRef - The proposed lesson/knowledge ref.
|
|
547
|
-
* @param content - The raw content that failed the quality gate.
|
|
548
|
-
* @param score - Quality score from the judge.
|
|
549
|
-
* @param reason - Human-readable rejection reason.
|
|
550
|
-
* @param extraMeta - Optional additional metadata for the event.
|
|
551
|
-
*/
|
|
552
|
-
function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}, eligibilitySource) {
|
|
553
|
-
// D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
|
|
554
|
-
const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
|
|
555
|
-
const rejectDir = path.join(stash, ".akm", "distill-rejected");
|
|
556
|
-
fs.mkdirSync(rejectDir, { recursive: true });
|
|
557
|
-
const ts = timestampForFilename();
|
|
558
|
-
fs.writeFileSync(path.join(rejectDir, `${ts}-${lessonRef}.md`), `---\nscore: ${score}\nreason: ${reason}\noutcome: ${outcome}\n---\n\n${content}`, "utf8");
|
|
559
|
-
appendEvent({
|
|
560
|
-
eventType: "distill_invoked",
|
|
561
|
-
ref: inputRef,
|
|
562
|
-
metadata: {
|
|
563
|
-
outcome,
|
|
564
|
-
lessonRef,
|
|
565
|
-
score,
|
|
566
|
-
reason,
|
|
567
|
-
...extraMeta,
|
|
568
|
-
// Attribution tagging: stamp the eligibility lane so distill_invoked can be
|
|
569
|
-
// sliced by lane downstream. See EligibilitySource.
|
|
570
|
-
...(eligibilitySource ? { eligibilitySource } : {}),
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
return {
|
|
574
|
-
schemaVersion: 1,
|
|
575
|
-
ok: true,
|
|
576
|
-
outcome,
|
|
577
|
-
inputRef,
|
|
578
|
-
lessonRef,
|
|
579
|
-
score,
|
|
580
|
-
reason,
|
|
581
|
-
...extraMeta,
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* G4 — content-score a distilled OUTPUT (lesson/knowledge proposal body) and
|
|
586
|
-
* persist it to state.db :: asset_salience with `encoding_source: "content"`.
|
|
587
|
-
*
|
|
588
|
-
* Lessons are refused as distill INPUTS (`DISTILL_REFUSED_INPUT_TYPES`), so
|
|
589
|
-
* this creation-time write is their only chance to earn a real content-derived
|
|
590
|
-
* encoding score instead of sitting on the type-weight stub forever. Best-effort:
|
|
591
|
-
* never blocks or fails the proposal flow.
|
|
592
|
-
*/
|
|
593
|
-
function persistOutputEncodingSalience(ref, body, existingRefVocabulary,
|
|
594
|
-
// Operator opt-out (improve.salience.outcomeWeightEnabled: false) must apply
|
|
595
|
-
// here too, or distill-written rank_score rows would use WS-2 weights while
|
|
596
|
-
// preparation uses parity weights — inconsistent salience semantics.
|
|
597
|
-
outcomeWeightEnabled) {
|
|
598
|
-
try {
|
|
599
|
-
const parsedRef = parseAssetRef(ref);
|
|
600
|
-
const salienceResult = scoreEncodingSalience({
|
|
601
|
-
body,
|
|
602
|
-
type: parsedRef.type,
|
|
603
|
-
existingRefVocabulary,
|
|
604
|
-
revisionCount: 0, // a freshly distilled output IS a first encounter
|
|
605
|
-
});
|
|
606
|
-
withStateDb((stateDb) => {
|
|
607
|
-
const vector = computeSalience({
|
|
608
|
-
ref,
|
|
609
|
-
type: parsedRef.type,
|
|
610
|
-
retrievalFreq: 0,
|
|
611
|
-
encodingSalience: salienceResult.score,
|
|
612
|
-
outcomeWeightEnabled,
|
|
613
|
-
});
|
|
614
|
-
upsertAssetSalience(stateDb, ref, vector);
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
catch {
|
|
618
|
-
// Best-effort — scoring must never block proposal creation.
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
412
|
// ── Main entry point ────────────────────────────────────────────────────────
|
|
622
413
|
/**
|
|
623
414
|
* Run a single bounded distillation pass for `ref`. Always emits exactly one
|
|
@@ -788,196 +579,34 @@ export async function akmDistill(options) {
|
|
|
788
579
|
eventType: e.eventType,
|
|
789
580
|
...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
|
|
790
581
|
}));
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const mergePrompt = [
|
|
820
|
-
"You are merging two versions of a knowledge document.",
|
|
821
|
-
"Existing content is already committed; new content comes from a memory distillation run.",
|
|
822
|
-
"Choose one of: ADD (combine both), UPDATE (replace existing with new), NOOP (keep existing unchanged).",
|
|
823
|
-
'Return ONLY valid JSON: {"action": "ADD"|"UPDATE"|"NOOP", "content": "<merged markdown if ADD/UPDATE, empty string if NOOP>"}',
|
|
824
|
-
"",
|
|
825
|
-
"## Existing knowledge content",
|
|
826
|
-
"```",
|
|
827
|
-
existingKnowledgeContent.slice(0, 3000),
|
|
828
|
-
"```",
|
|
829
|
-
"",
|
|
830
|
-
"## New content from distillation",
|
|
831
|
-
"```",
|
|
832
|
-
promotion.content.slice(0, 3000),
|
|
833
|
-
"```",
|
|
834
|
-
].join("\n");
|
|
835
|
-
try {
|
|
836
|
-
const mergeLlm = getDefaultLlmConfig(config);
|
|
837
|
-
if (!mergeLlm) {
|
|
838
|
-
throw new ConfigError("LLM is not configured for distillation merge.", "LLM_NOT_CONFIGURED");
|
|
839
|
-
}
|
|
840
|
-
const mergeResponse = await chat(mergeLlm, [
|
|
841
|
-
{ role: "system", content: "Return only valid JSON. No prose." },
|
|
842
|
-
{ role: "user", content: mergePrompt },
|
|
843
|
-
]);
|
|
844
|
-
const mergeResult = parseEmbeddedJsonResponse(mergeResponse);
|
|
845
|
-
if (mergeResult?.action === "NOOP") {
|
|
846
|
-
// Existing content is authoritative — no update needed.
|
|
847
|
-
appendEvent({
|
|
848
|
-
eventType: "distill_invoked",
|
|
849
|
-
ref: inputRef,
|
|
850
|
-
metadata: {
|
|
851
|
-
outcome: "skipped",
|
|
852
|
-
lessonRef: promotion.knowledgeRef,
|
|
853
|
-
message: "D-1: LLM resolved destination conflict as NOOP — existing content kept",
|
|
854
|
-
...eligMeta,
|
|
855
|
-
},
|
|
856
|
-
});
|
|
857
|
-
return {
|
|
858
|
-
schemaVersion: 1,
|
|
859
|
-
ok: true,
|
|
860
|
-
outcome: "skipped",
|
|
861
|
-
inputRef,
|
|
862
|
-
lessonRef: promotion.knowledgeRef,
|
|
863
|
-
message: "Existing knowledge content unchanged (contradiction resolution: NOOP)",
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
if (mergeResult?.action && (mergeResult.action === "ADD" || mergeResult.action === "UPDATE")) {
|
|
867
|
-
if (mergeResult.content?.trim()) {
|
|
868
|
-
resolvedPromotionContent = mergeResult.content;
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
catch {
|
|
873
|
-
// LLM merge failed — fall through with the original promotion content.
|
|
874
|
-
// The reviewer will see both versions in the proposal diff.
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
else if (existingKnowledgeContent && config && !getDefaultLlmConfig(config)) {
|
|
878
|
-
// No LLM configured: include existing content as context in the proposal
|
|
879
|
-
// so the reviewer can do the contradiction resolution manually.
|
|
880
|
-
resolvedPromotionContent = [
|
|
881
|
-
promotion.content,
|
|
882
|
-
"",
|
|
883
|
-
"---",
|
|
884
|
-
"<!-- D-1 / #369: Existing knowledge content is shown below for reviewer reference. -->",
|
|
885
|
-
"<!-- Review: decide whether to ADD (merge), UPDATE (replace), or NOOP (keep existing). -->",
|
|
886
|
-
"",
|
|
887
|
-
"## Existing content (for reviewer reference)",
|
|
888
|
-
"",
|
|
889
|
-
existingKnowledgeContent,
|
|
890
|
-
].join("\n");
|
|
891
|
-
}
|
|
892
|
-
// Apply quality gate to fast-path knowledge promotion (Risk 4 fix).
|
|
893
|
-
// D-5 / #388: Three-band system — review_needed band queues to proposal
|
|
894
|
-
// queue with review_needed outcome rather than auto-rejecting.
|
|
895
|
-
let knowledgeJudgeConfidence;
|
|
896
|
-
if (isLlmFeatureEnabled(config, "lesson_quality_gate")) {
|
|
897
|
-
// D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
|
|
898
|
-
const similarLessons = await fetchSimilarLessonsFn(resolvedPromotionContent.slice(0, 500), 3);
|
|
899
|
-
const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined);
|
|
900
|
-
if (!judgeResult.pass) {
|
|
901
|
-
if (judgeResult.reviewNeeded) {
|
|
902
|
-
// Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.
|
|
903
|
-
return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true }, options.eligibilitySource);
|
|
904
|
-
}
|
|
905
|
-
return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, {}, options.eligibilitySource);
|
|
906
|
-
}
|
|
907
|
-
// Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
|
|
908
|
-
// (no LLM / timeout / parse failure) — leave confidence undefined so
|
|
909
|
-
// the auto-accept gate treats the proposal as unscored and skips it.
|
|
910
|
-
if (judgeResult.score > 0)
|
|
911
|
-
knowledgeJudgeConfidence = judgeResult.score / 5;
|
|
912
|
-
}
|
|
913
|
-
const knowledgeParsed = parseFrontmatter(resolvedPromotionContent);
|
|
914
|
-
const proposalResult = createProposal(stash, {
|
|
915
|
-
ref: promotion.knowledgeRef,
|
|
916
|
-
source: "distill",
|
|
917
|
-
...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
|
|
918
|
-
payload: {
|
|
919
|
-
content: resolvedPromotionContent,
|
|
920
|
-
...(Object.keys(knowledgeParsed.data).length > 0 ? { frontmatter: knowledgeParsed.data } : {}),
|
|
921
|
-
},
|
|
922
|
-
...(knowledgeJudgeConfidence !== undefined ? { confidence: knowledgeJudgeConfidence } : {}),
|
|
923
|
-
// Attribution tagging: persist the eligibility lane on the proposal.
|
|
924
|
-
...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
|
|
925
|
-
}, options.ctx);
|
|
926
|
-
if (isProposalSkipped(proposalResult)) {
|
|
927
|
-
appendEvent({
|
|
928
|
-
eventType: "distill_invoked",
|
|
929
|
-
ref: inputRef,
|
|
930
|
-
metadata: {
|
|
931
|
-
outcome: "skipped",
|
|
932
|
-
lessonRef: promotion.knowledgeRef,
|
|
933
|
-
message: proposalResult.message,
|
|
934
|
-
skipReason: proposalResult.reason,
|
|
935
|
-
...eligMeta,
|
|
936
|
-
},
|
|
937
|
-
});
|
|
938
|
-
return {
|
|
939
|
-
schemaVersion: 1,
|
|
940
|
-
ok: true,
|
|
941
|
-
outcome: "skipped",
|
|
942
|
-
inputRef,
|
|
943
|
-
lessonRef: promotion.knowledgeRef,
|
|
944
|
-
message: proposalResult.message,
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
const proposal = proposalResult;
|
|
948
|
-
// G4: content-score the distilled OUTPUT so it carries a real encoding
|
|
949
|
-
// salience (encoding_source='content') from creation.
|
|
950
|
-
persistOutputEncodingSalience(promotion.knowledgeRef, resolvedPromotionContent, existingRefVocabulary, outcomeWeightEnabled);
|
|
951
|
-
appendEvent({
|
|
952
|
-
eventType: "distill_invoked",
|
|
953
|
-
ref: inputRef,
|
|
954
|
-
metadata: {
|
|
955
|
-
outcome: "queued",
|
|
956
|
-
lessonRef: promotion.knowledgeRef,
|
|
957
|
-
proposalRef: promotion.knowledgeRef,
|
|
958
|
-
proposalKind: "knowledge",
|
|
959
|
-
proposalId: proposal.id,
|
|
960
|
-
// R3: judge verdicts are longitudinally queryable, not just a one-shot
|
|
961
|
-
// proposal.confidence write (normalized 1–5 score / 5).
|
|
962
|
-
...(knowledgeJudgeConfidence !== undefined ? { judgeConfidence: knowledgeJudgeConfidence } : {}),
|
|
963
|
-
...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
|
|
964
|
-
...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
|
|
965
|
-
...eligMeta,
|
|
966
|
-
},
|
|
967
|
-
});
|
|
968
|
-
return {
|
|
969
|
-
schemaVersion: 1,
|
|
970
|
-
ok: true,
|
|
971
|
-
outcome: "queued",
|
|
972
|
-
inputRef,
|
|
973
|
-
lessonRef: promotion.knowledgeRef,
|
|
974
|
-
proposalRef: promotion.knowledgeRef,
|
|
975
|
-
proposalKind: "knowledge",
|
|
976
|
-
proposalId: proposal.id,
|
|
977
|
-
proposal,
|
|
978
|
-
...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
|
|
979
|
-
};
|
|
980
|
-
}
|
|
582
|
+
// Memory→knowledge promotion branch (D-1/#369). When the target ref is a
|
|
583
|
+
// reinforced memory, distill graduates it into a knowledge proposal instead
|
|
584
|
+
// of a lesson — the whole branch (LLM contradiction-merge, quality gate,
|
|
585
|
+
// proposal creation, event emit) lives in `promoteMemoryToKnowledge` and is
|
|
586
|
+
// terminal when it fires. A `null` return means "not a promotion candidate";
|
|
587
|
+
// fall through to the ordinary lesson/knowledge distillation path.
|
|
588
|
+
const promotionResult = await promoteMemoryToKnowledge({
|
|
589
|
+
targetKind,
|
|
590
|
+
inputRef,
|
|
591
|
+
assetContent,
|
|
592
|
+
filteredEvents,
|
|
593
|
+
config,
|
|
594
|
+
chat,
|
|
595
|
+
stash,
|
|
596
|
+
lookup,
|
|
597
|
+
fetchSimilarLessonsFn,
|
|
598
|
+
existingRefVocabulary,
|
|
599
|
+
outcomeWeightEnabled,
|
|
600
|
+
eligMeta,
|
|
601
|
+
eligibilitySource: options.eligibilitySource,
|
|
602
|
+
sourceRun: options.sourceRun,
|
|
603
|
+
proposalsCtx: options.ctx,
|
|
604
|
+
exclusionSetSize: exclusionSet.size,
|
|
605
|
+
filteredFeedbackCount,
|
|
606
|
+
feedbackFullyFiltered,
|
|
607
|
+
});
|
|
608
|
+
if (promotionResult)
|
|
609
|
+
return promotionResult;
|
|
981
610
|
const effectiveProposalKind = targetKind === "knowledge" ? "knowledge" : "lesson";
|
|
982
611
|
const effectiveLessonRef = effectiveProposalKind === "knowledge" ? deriveKnowledgeRef(inputRef) : deriveLessonRef(inputRef);
|
|
983
612
|
// Inject last 1–3 rejected proposals for this ref as Reflexion-style
|
|
@@ -1131,136 +760,20 @@ export async function akmDistill(options) {
|
|
|
1131
760
|
// Strip any stray fence the LLM might have added around the markdown.
|
|
1132
761
|
content = stripMarkdownFences(raw);
|
|
1133
762
|
}
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
// from the body and prepend the required frontmatter block.
|
|
1138
|
-
//
|
|
1139
|
-
// IMPORTANT: We do NOT synthesise placeholder strings here. If the body
|
|
1140
|
-
// does not contain text that passes the post-LLM validators
|
|
1141
|
-
// (`isValidDescription` / `isValidWhenToUse`), we leave the field missing
|
|
1142
|
-
// and let the lesson lint reject the proposal as `validation_failed`.
|
|
1143
|
-
// Emitting placeholders like `"Lesson distilled from <ref>"` or
|
|
1144
|
-
// `"When working with <slug>"` is what produced the systematic broken
|
|
1145
|
-
// proposals observed across 323 archived rejections.
|
|
763
|
+
// Lesson-path content normalization (see distill/content-repair): auto-repair
|
|
764
|
+
// missing frontmatter, description↔when_to_use auto-swap, and truncation
|
|
765
|
+
// repair. Knowledge output skips all three (no lesson frontmatter contract).
|
|
1146
766
|
if (effectiveProposalKind !== "knowledge") {
|
|
1147
|
-
|
|
1148
|
-
const fm = (parsed.data ?? {});
|
|
1149
|
-
const missingDesc = typeof fm.description !== "string" || !fm.description.trim();
|
|
1150
|
-
const missingWtu = typeof fm.when_to_use !== "string" || !fm.when_to_use.trim();
|
|
1151
|
-
if (missingDesc || missingWtu) {
|
|
1152
|
-
const body = parsed.content.trim();
|
|
1153
|
-
// Strip markdown formatting tokens from a line so extracted text is clean.
|
|
1154
|
-
const stripMd = (l) => l
|
|
1155
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1156
|
-
.replace(/\*([^*]+)\*/g, "$1")
|
|
1157
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1158
|
-
.replace(/^[#*\->_]+\s*/, "")
|
|
1159
|
-
.replace(/:\s*$/, "")
|
|
1160
|
-
.trim();
|
|
1161
|
-
// Skip lines that look like YAML field assignments (key: value) or frontmatter delimiters.
|
|
1162
|
-
// These appear when the LLM leaks frontmatter content into the body, causing
|
|
1163
|
-
// auto-repair to produce description: "description: Key Takeaways".
|
|
1164
|
-
const isYamlLike = (l) => /^---/.test(l) || /^[a-z_]+:\s/i.test(l);
|
|
1165
|
-
const bodyLines = body.split("\n").map(stripMd);
|
|
1166
|
-
// Extract description: first body line that BOTH looks like prose AND
|
|
1167
|
-
// passes isValidDescription. If nothing qualifies, leave the field
|
|
1168
|
-
// missing — the lint pass will reject the proposal cleanly.
|
|
1169
|
-
let descLine;
|
|
1170
|
-
for (const l of bodyLines) {
|
|
1171
|
-
if (isYamlLike(l))
|
|
1172
|
-
continue;
|
|
1173
|
-
if (l.length <= 10 || l.length >= 400)
|
|
1174
|
-
continue;
|
|
1175
|
-
if (isValidDescription(l, inputRef).ok) {
|
|
1176
|
-
descLine = l;
|
|
1177
|
-
break;
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
// Extract when_to_use: a line starting with "When" / "Use when" / "Apply when"
|
|
1181
|
-
// that ALSO passes isValidWhenToUse (rejects circular fallbacks).
|
|
1182
|
-
let wtuLine;
|
|
1183
|
-
for (const l of bodyLines) {
|
|
1184
|
-
if (!/^(when |use when|apply when)/i.test(l))
|
|
1185
|
-
continue;
|
|
1186
|
-
if (l.length >= 400)
|
|
1187
|
-
continue;
|
|
1188
|
-
if (isValidWhenToUse(l, inputRef).ok) {
|
|
1189
|
-
wtuLine = l;
|
|
1190
|
-
break;
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
const repairedFm = {
|
|
1194
|
-
...fm,
|
|
1195
|
-
...(missingDesc && descLine ? { description: descLine } : {}),
|
|
1196
|
-
...(missingWtu && wtuLine ? { when_to_use: wtuLine } : {}),
|
|
1197
|
-
};
|
|
1198
|
-
const fmLines = Object.entries(repairedFm)
|
|
1199
|
-
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
1200
|
-
.join("\n");
|
|
1201
|
-
// Only rewrite content if we actually have at least one field to write.
|
|
1202
|
-
// Otherwise leave the original content for the lint pass to reject.
|
|
1203
|
-
if (Object.keys(repairedFm).length > 0) {
|
|
1204
|
-
content = assembleAssetFromString(fmLines, body);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
767
|
+
content = autoRepairLessonFrontmatter(content, inputRef);
|
|
1207
768
|
}
|
|
1208
|
-
// Description ↔ when_to_use auto-swap normalization (recover ~93% of
|
|
1209
|
-
// qwen-9b's `^when\b/i` rejections at zero LLM cost). When the LLM emits
|
|
1210
|
-
// a conditional-framed description ("When X happens, do Y") and the
|
|
1211
|
-
// when_to_use field looks like a declarative description (or is empty),
|
|
1212
|
-
// the two fields are mis-fielded — exactly what `isValidDescription`'s
|
|
1213
|
-
// error message says ("that pattern belongs in when_to_use"). We swap
|
|
1214
|
-
// them and revalidate; the swap is committed only if BOTH fields pass
|
|
1215
|
-
// their respective validators afterwards. If revalidation still fails,
|
|
1216
|
-
// we fall through to the existing reject path.
|
|
1217
769
|
let descriptionSwapped = 0;
|
|
1218
770
|
if (effectiveProposalKind !== "knowledge") {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
const wtuRaw = typeof fmSwap.when_to_use === "string" ? fmSwap.when_to_use.trim() : "";
|
|
1223
|
-
const descStartsConditional = /^(when|if)\b/i.test(descRaw);
|
|
1224
|
-
const wtuStartsConditional = /^(when|if)\b/i.test(wtuRaw);
|
|
1225
|
-
if (descStartsConditional && !wtuStartsConditional && wtuRaw.length > 0) {
|
|
1226
|
-
// Try the swap and revalidate. The when_to_use validator requires the
|
|
1227
|
-
// value not match `/^when working with\b/i` (the circular fallback) —
|
|
1228
|
-
// a real description rarely does, so this usually passes.
|
|
1229
|
-
const swappedDescCheck = isValidDescription(wtuRaw, inputRef);
|
|
1230
|
-
const swappedWtuCheck = isValidWhenToUse(descRaw, inputRef);
|
|
1231
|
-
if (swappedDescCheck.ok && swappedWtuCheck.ok) {
|
|
1232
|
-
const swappedFm = {
|
|
1233
|
-
...fmSwap,
|
|
1234
|
-
description: wtuRaw,
|
|
1235
|
-
when_to_use: descRaw,
|
|
1236
|
-
};
|
|
1237
|
-
const swappedFmLines = Object.entries(swappedFm)
|
|
1238
|
-
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
1239
|
-
.join("\n");
|
|
1240
|
-
content = assembleAssetFromString(swappedFmLines, parsedSwap.content);
|
|
1241
|
-
descriptionSwapped = 1;
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
771
|
+
const swapResult = autoSwapDescriptionWhenToUse(content, inputRef);
|
|
772
|
+
content = swapResult.content;
|
|
773
|
+
descriptionSwapped = swapResult.swapped;
|
|
1244
774
|
}
|
|
1245
|
-
// Post-generation truncation repair (#556): if the LLM sliced the
|
|
1246
|
-
// description mid-sentence, deterministically complete it from its own text
|
|
1247
|
-
// / the lesson body BEFORE the lint + quality validators run. No-op
|
|
1248
|
-
// (byte-identical) for already-complete descriptions, so this never alters
|
|
1249
|
-
// a valid proposal. Runs on the lesson path only (knowledge has no
|
|
1250
|
-
// description field gate here).
|
|
1251
775
|
if (effectiveProposalKind !== "knowledge") {
|
|
1252
|
-
|
|
1253
|
-
const fmRepair = (parsedRepair.data ?? {});
|
|
1254
|
-
const descRepairRaw = typeof fmRepair.description === "string" ? fmRepair.description : "";
|
|
1255
|
-
if (descRepairRaw) {
|
|
1256
|
-
const repaired = repairTruncatedDescription(descRepairRaw, parsedRepair.content);
|
|
1257
|
-
if (repaired !== descRepairRaw) {
|
|
1258
|
-
const repairedFmLines = Object.entries({ ...fmRepair, description: repaired })
|
|
1259
|
-
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
1260
|
-
.join("\n");
|
|
1261
|
-
content = assembleAssetFromString(repairedFmLines, parsedRepair.content);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
776
|
+
content = repairLessonDescriptionTruncation(content);
|
|
1264
777
|
}
|
|
1265
778
|
// Parse + lint the lesson before creating the proposal. The lint is the
|
|
1266
779
|
// canonical gate for required frontmatter (v1 spec §13). On failure we
|
|
@@ -1269,49 +782,10 @@ export async function akmDistill(options) {
|
|
|
1269
782
|
const findings = effectiveProposalKind === "knowledge"
|
|
1270
783
|
? validateKnowledgeContent(content, inputRef)
|
|
1271
784
|
: lintLessonContent(content, `distill:${inputRef}`).findings;
|
|
1272
|
-
// Additional quality validators
|
|
1273
|
-
//
|
|
1274
|
-
// modes observed across 323 archived rejected proposals:
|
|
1275
|
-
// - description is a body fragment, section heading, or placeholder
|
|
1276
|
-
// - when_to_use is the circular "When working with <ref>" fallback
|
|
1277
|
-
// - description == when_to_use (LLM duplicated a single sentence)
|
|
1278
|
-
// - body contains a second pseudo-frontmatter block
|
|
785
|
+
// Additional lesson-only quality validators — reject the systematic failure
|
|
786
|
+
// modes seen across 323 archived rejected proposals (see distill/content-repair).
|
|
1279
787
|
if (effectiveProposalKind !== "knowledge" && findings.length === 0) {
|
|
1280
|
-
|
|
1281
|
-
const fmQC = (parsedQC.data ?? {});
|
|
1282
|
-
const descCheck = isValidDescription(fmQC.description, inputRef);
|
|
1283
|
-
if (!descCheck.ok) {
|
|
1284
|
-
findings.push({
|
|
1285
|
-
kind: "invalid-description",
|
|
1286
|
-
field: "description",
|
|
1287
|
-
message: `Distilled lesson for ${inputRef} has an invalid description: ${descCheck.reason}.`,
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
const wtuCheck = isValidWhenToUse(fmQC.when_to_use, inputRef);
|
|
1291
|
-
if (!wtuCheck.ok) {
|
|
1292
|
-
findings.push({
|
|
1293
|
-
kind: "invalid-when_to_use",
|
|
1294
|
-
field: "when_to_use",
|
|
1295
|
-
message: `Distilled lesson for ${inputRef} has an invalid when_to_use: ${wtuCheck.reason}.`,
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
// description and when_to_use must say different things.
|
|
1299
|
-
if (descCheck.ok &&
|
|
1300
|
-
wtuCheck.ok &&
|
|
1301
|
-
typeof fmQC.description === "string" &&
|
|
1302
|
-
typeof fmQC.when_to_use === "string" &&
|
|
1303
|
-
fmQC.description.trim().toLowerCase() === fmQC.when_to_use.trim().toLowerCase()) {
|
|
1304
|
-
findings.push({
|
|
1305
|
-
kind: "description-equals-when_to_use",
|
|
1306
|
-
field: "description",
|
|
1307
|
-
message: `Distilled lesson for ${inputRef} has identical description and when_to_use.`,
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
1310
|
-
// Double-frontmatter / pseudo-frontmatter pollution in the body.
|
|
1311
|
-
const dfm = detectDoubleFrontmatter(content);
|
|
1312
|
-
if (dfm) {
|
|
1313
|
-
findings.push({ kind: dfm.kind, field: "body", message: `Distilled lesson for ${inputRef}: ${dfm.message}` });
|
|
1314
|
-
}
|
|
788
|
+
findings.push(...collectLessonQualityFindings(content, inputRef));
|
|
1315
789
|
}
|
|
1316
790
|
if (findings.length > 0) {
|
|
1317
791
|
appendEvent({
|