// tools/decomposition-detector.mjs /** * Decomposition detector — router-gate v4 spec §3.8 + v4.1 (Direction 3). * Pure: ловит feature, разбитую на 3+ мелких prompts с overlapping keywords без plan skill. * v4.1: hard-block mutating at 3+ overlapping (was 5+ soft). LLM-judge verdict инъектируется. */ import { keywordOverlapCount, isResetMarker } from './safe-baseline-metering.mjs'; export { isResetMarker }; export const V4_1_DECOMP_THRESHOLD = Object.freeze({ min_overlapping_prompts: 3, min_keyword_intersection: 3, window_size_prompts: 10, hard_block_mutating: true, }); export function keywordIntersection(a, b) { return keywordOverlapCount(a, b); } export function appendHistory(history, entry) { return [...(history || []), entry]; } export function detectDecompositionCandidate(history, currentEntry, threshold = V4_1_DECOMP_THRESHOLD) { const window = (history || []).slice(-threshold.window_size_prompts); const curKws = currentEntry.primary_keywords || []; const overlapping = window.filter( (e) => keywordOverlapCount(e.primary_keywords || [], curKws) >= threshold.min_keyword_intersection, ); const anySkill = [...overlapping, currentEntry].some((e) => e.skill_invoked_this_prompt === true); if (overlapping.length >= threshold.min_overlapping_prompts && !anySkill) { // overlappingKeywords: curKws present in EVERY overlapping prompt const overlappingKeywords = curKws.filter((k) => overlapping.every( (e) => (e.primary_keywords || []).map((x) => String(x).toLowerCase()).includes(String(k).toLowerCase()), ), ); return { candidate: true, overlappingPrompts: overlapping.map((e) => e.prompt_idx), overlappingKeywords, reason: `${overlapping.length + 1} prompts overlapping keywords [${overlappingKeywords.join(', ')}] без writing-plans/brainstorming skill.`, }; } return { candidate: false, overlappingPrompts: [], overlappingKeywords: [] }; } export function decideDecomposition(candidate, llmVerdict, threshold = V4_1_DECOMP_THRESHOLD) { if (!candidate || !candidate.candidate) return { action: 'allow' }; const verdict = typeof llmVerdict === 'string' ? llmVerdict : llmVerdict?.verdict; if (verdict === 'YES') { return { action: threshold.hard_block_mutating ? 'hard_block_mutating' : 'soft_flag', reason: `v4.1 decomp hard-block: ${candidate.reason} LLM-judge confirmed decomposition. Invoke writing-plans skill сейчас.`, }; } // candidate but LLM says legit-distinct → soft surface only return { action: 'soft_flag', reason: candidate.reason }; }