{"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAGrD,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC7B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC3B,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,EACpB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;AA0DzC,2FAA2F;AAC3F,wBAAgB,aAAa,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,GAClB,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAwC5B;AAED,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,YAAY,GAAG,aAAa,CA6BxE;AAED,8FAA8F;AAC9F,eAAO,MAAM,eAAe,EAAE,aAAqC,CAAC","sourcesContent":["/**\n * Is this already written down?\n *\n * The answer decides the most useful distinction the digest makes — `new` vs\n * `restated` vs `has-skill` — and it used to be decided by bag-of-words\n * overlap: count how many content words of the proposal appear anywhere in a\n * rule line, call it covered above 0.6. That is wrong in both directions and\n * for the same reason, namely that it does not read. It calls \"always run tests\n * before pushing\" covered by a line about \"running the test suite in CI\", and\n * it misses a real paraphrase that happens to pick different vocabulary.\n *\n * Both mistakes are expensive. A false `restated` accuses a rule that is\n * working of not working, and tells the reader to rewrite something fine. A\n * false `new` proposes a rule they already have, which is how a context file\n * grows duplicates.\n *\n * So a model reads the rules and the proposals together and matches them. One\n * call for the whole batch, because the question is small and the corpus is the\n * same for every item — the context file is a few thousand tokens and does not\n * want re-sending once per proposal.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/** One thing to look up, identified by the label the reduce step grouped on. */\nexport interface CoverageQuery {\n\tlabel: string;\n\ttext: string;\n}\n\n/**\n * Decide coverage for a batch. Injectable so the pipeline can be tested without\n * a model, and so a run with no model configured can degrade to \"everything is\n * new\" rather than failing.\n */\nexport type CoverageJudge = (\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n\tsignal?: AbortSignal,\n) => Promise<Map<string, CoverageMatch>>;\n\n/** Rule lines sent per call. A context file longer than this is already the problem. */\nconst MAX_RULE_LINES = 400;\n/** Skills sent per call. */\nconst MAX_SKILLS = 120;\n/** Description characters per skill — the opening says what it does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\nconst MAX_RESPONSE_TOKENS = 2_000;\n\nconst COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.\n\nYou are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.\n\nEach rule reads \\`[scope] Heading > Subheading > line\\`. The scope is \\`repo\\` (binds work in this project) or \\`user\\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.\n\nFor each proposal, decide:\n- \"rule\" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.\n- \"skill\" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.\n- \"new\" — nothing covers it.\n\nJudge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:\n- proposal \"always use bun, never npm\" vs rule \"install dependencies with bun\" → covered (rule)\n- proposal \"run tests before pushing\" vs rule \"CI runs the test suite on every PR\" → NOT covered, these are different instructions to different actors\n- proposal \"prefer table output\" vs rule \"use tables in documentation\" → NOT covered unless the scope matches\n\nPrefer \"new\" when genuinely unsure. A false \"covered\" tells the reader to rewrite a rule that is fine; a false \"new\" merely proposes something they can reject.\n\nRules win over skills when both match: rewriting a line is more actionable than sharpening a description.\n\nOutput STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:\n{\"verdicts\":[{\"label\":\"use-bun-not-npm\",\"verdict\":\"rule\",\"ruleIndex\":3},{\"label\":\"scaffold-route\",\"verdict\":\"skill\",\"skillIndex\":1},{\"label\":\"prefer-tables\",\"verdict\":\"new\"}]}`;\n\nfunction buildPrompt(queries: CoverageQuery[], index: CoverageIndex): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\"RULES:\");\n\tconst rules = index.ruleLines.slice(0, MAX_RULE_LINES);\n\tif (rules.length === 0) lines.push(\"(none)\");\n\tfor (const [i, rule] of rules.entries()) {\n\t\tlines.push(`${i}. ${rule}`);\n\t}\n\n\tlines.push(\"\", \"SKILLS:\");\n\tconst skills = index.skills.slice(0, MAX_SKILLS);\n\tif (skills.length === 0) lines.push(\"(none)\");\n\tfor (const [i, skill] of skills.entries()) {\n\t\tlines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);\n\t}\n\n\tlines.push(\"\", \"PROPOSALS:\");\n\tfor (const query of queries) {\n\t\tlines.push(`- label: ${query.label}\\n  text: ${query.text}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Read the verdict list back, ignoring anything malformed rather than failing the run. */\nexport function parseVerdicts(\n\tresponse: string,\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n): Map<string, CoverageMatch> {\n\tconst out = new Map<string, CoverageMatch>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { verdicts?: unknown })?.verdicts;\n\tif (!Array.isArray(raw)) return out;\n\n\tconst known = new Set(queries.map((q) => q.label));\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst verdict = item as Record<string, unknown>;\n\t\tconst label = typeof verdict.label === \"string\" ? verdict.label.trim().toLowerCase() : \"\";\n\t\t// A label the batch did not ask about is a hallucinated row; dropping it is\n\t\t// safer than letting it mark some other proposal covered.\n\t\tif (!label || !known.has(label)) continue;\n\n\t\tif (verdict.verdict === \"rule\") {\n\t\t\tconst at = typeof verdict.ruleIndex === \"number\" ? index.ruleLines[verdict.ruleIndex] : undefined;\n\t\t\t// An out-of-range index means the model decided \"covered\" but cannot show\n\t\t\t// which line. Treat that as `new`: the reader cannot act on an unnamed rule.\n\t\t\tif (at) out.set(label, { rule: at });\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.verdict === \"skill\") {\n\t\t\tconst at = typeof verdict.skillIndex === \"number\" ? index.skills[verdict.skillIndex] : undefined;\n\t\t\tif (at) out.set(label, { skill: at.name });\n\t\t\tcontinue;\n\t\t}\n\t\tout.set(label, {});\n\t}\n\treturn out;\n}\n\nexport interface CoverageDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge {\n\treturn async (queries, index, signal) => {\n\t\t// Nothing to match against means nothing can be covered, and the call would\n\t\t// be pure cost.\n\t\tif (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {\n\t\t\treturn new Map();\n\t\t}\n\n\t\tconst response = await completeSimple(\n\t\t\tdeps.model,\n\t\t\t{\n\t\t\t\tsystemPrompt: COVERAGE_SYSTEM_PROMPT,\n\t\t\t\tmessages: [\n\t\t\t\t\t{ role: \"user\", content: [{ type: \"text\", text: buildPrompt(queries, index) }], timestamp: Date.now() },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t);\n\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthrow new Error(response.errorMessage || \"coverage call failed\");\n\t\t}\n\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\\n\");\n\t\treturn parseVerdicts(text, queries, index);\n\t};\n}\n\n/** Everything is new. Used when no model is available, so the run still produces a digest. */\nexport const noCoverageJudge: CoverageJudge = async () => new Map();\n"]}