From a876ce8fb60c2e49e02a1428695c2def9048863a Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 13:39:30 +0400 Subject: [PATCH 1/2] fix: preserve retrieval receipts and Codex installs --- src/infrastructure/context-pack-command.ts | 30 +++- src/infrastructure/doctor.ts | 32 +++- src/infrastructure/install.ts | 137 +++++++++++--- src/runtime/context-pack-recovery.ts | 20 ++- src/runtime/retrieve.ts | 41 ++++- src/runtime/retrieve/conceptual-fallback.ts | 63 ++++++- src/runtime/stdio/tools.ts | 33 +++- tests/unit/context-pack-command.test.ts | 101 ++++++++++- tests/unit/context-pack-recovery.test.ts | 47 +++++ tests/unit/doctor.test.ts | 122 ++++++++++++- tests/unit/install.test.ts | 98 +++++++++- .../unit/retrieve-conceptual-fallback.test.ts | 141 ++++++++++++++- tests/unit/retrieve-cross-layer-flow.test.ts | 6 + tests/unit/stdio-slice-surface.test.ts | 167 ++++++++++++++++++ 14 files changed, 983 insertions(+), 55 deletions(-) diff --git a/src/infrastructure/context-pack-command.ts b/src/infrastructure/context-pack-command.ts index 19de678e..554f0683 100644 --- a/src/infrastructure/context-pack-command.ts +++ b/src/infrastructure/context-pack-command.ts @@ -1849,6 +1849,33 @@ function queryEvidenceCoverageFromPayload(payload: JsonRecord) { return evaluateQueryEvidenceCoverage(question, nodes) } +/** Keep the public fallback receipt aligned with the snippets serialized to the agent. */ +function reconcileSerializedRetrievalPlanQueryObligations( + payload: JsonRecord, + queryCoverage: ReturnType, +): boolean { + if (!queryCoverage) { + return false + } + + const plan = asJsonRecord(asJsonRecord(payload.pack)?.retrieval_plan) + const obligations = asJsonRecord(plan?.query_obligations) + if (!obligations) { + return false + } + + if ( + obligations.total === queryCoverage.total + && obligations.finally_covered === queryCoverage.covered + ) { + return false + } + + obligations.total = queryCoverage.total + obligations.finally_covered = queryCoverage.covered + return true +} + function reconcileSerializedQueryEvidence( payload: JsonRecord, trimmedFields: string[], @@ -1856,8 +1883,9 @@ function reconcileSerializedQueryEvidence( ): boolean { const evidence = asJsonRecord(payload.evidence) const queryCoverage = queryEvidenceCoverageFromPayload(payload) + const retrievalPlanReconciled = reconcileSerializedRetrievalPlanQueryObligations(payload, queryCoverage) if (!evidence || !queryCoverage || queryCoverage.missing_obligations.length === 0) { - return false + return retrievalPlanReconciled } const baselineMissing = new Set(baselineQueryCoverage?.missing_obligations ?? []) const lostDuringSerialization = queryCoverage.missing_obligations.some((obligation) => !baselineMissing.has(obligation)) diff --git a/src/infrastructure/doctor.ts b/src/infrastructure/doctor.ts index 6916e056..268a7d6c 100644 --- a/src/infrastructure/doctor.ts +++ b/src/infrastructure/doctor.ts @@ -4,11 +4,16 @@ import { join, resolve } from 'node:path' import type { IndexingManifestV1 } from '../contracts/indexing.js' import { watcherStateBlocksGraphReads, type WatcherStateV1 } from '../contracts/watcher-state.js' import { + CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH, CODEX_PROMPT_HOOK_SCRIPT_RELATIVE_PATH, OPENCODE_MCP_SERVER_NAME, OPENCODE_PLUGIN_RELATIVE_PATH, + claudePromptHookCommand, codexPromptHookCommand, + hasManagedClaudePromptHookScript, hasManagedCodexPromptHookScript, + isCurrentMadarClaudePromptHook, + isMadarProjectHook, isMadarCodexMcpConfig, isMadarCodexLegacyHook, isMadarCodexPromptHook, @@ -207,6 +212,28 @@ function findHookEntry(settingsPath: string, hookName: 'PreToolUse' | 'BeforeToo return hookEntries.some(containsOutPathReference) } +function findClaudeHookEntry(settingsPath: string, hookScriptPath: string): boolean { + const settings = readJsonObject(settingsPath) + if (!settings) { + return false + } + + const hooks = settings.hooks + if (!isRecord(hooks)) { + return false + } + + const userPromptSubmit = hooks.UserPromptSubmit + const currentPromptHook = Array.isArray(userPromptSubmit) + && userPromptSubmit.some((hook) => isCurrentMadarClaudePromptHook(hook, claudePromptHookCommand())) + && hasManagedClaudePromptHookScript(hookScriptPath) + const legacyPreToolUse = hooks.PreToolUse + const legacyHook = Array.isArray(legacyPreToolUse) + && legacyPreToolUse.some((hook) => isMadarProjectHook(hook, 'Glob|Grep|Bash|Agent|Read')) + + return currentPromptHook || legacyHook +} + function findCodexHookEntry(settingsPath: string, expectedCommand: string): boolean { const settings = readJsonObject(settingsPath) if (!settings) { @@ -561,7 +588,10 @@ export function buildDoctorReport(options: DoctorCommandOptions = {}): DoctorRep const copilotMcp = readMcpCheck('copilot', resolve(projectDir, '.vscode', 'mcp.json'), 'servers') const claudeRuleConfigured = hasSectionMarker(resolve(projectDir, 'CLAUDE.md')) - const claudeHookConfigured = findHookEntry(resolve(projectDir, '.claude', 'settings.json'), 'PreToolUse') + const claudeHookConfigured = findClaudeHookEntry( + resolve(projectDir, '.claude', 'settings.json'), + resolve(projectDir, CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH), + ) const claudeMcpConfigured = claudeMcp.status === 'ok' const cursorRuleConfigured = existsSync(resolve(projectDir, '.cursor', 'rules', 'madar.mdc')) diff --git a/src/infrastructure/install.ts b/src/infrastructure/install.ts index 21f1eda3..310522f2 100644 --- a/src/infrastructure/install.ts +++ b/src/infrastructure/install.ts @@ -34,6 +34,7 @@ const MANAGED_HOOK_NAME = 'madar' const MANAGED_HOOK_SOURCE = 'madar' export const CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH = '.claude/madar-user-prompt-submit.cjs' const CLAUDE_PROMPT_HOOK_COMMAND = `node ${CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH}` +const CLAUDE_PROMPT_HOOK_SCRIPT_MARKER = '// madar managed Claude UserPromptSubmit hook' export const CODEX_PROMPT_HOOK_SCRIPT_RELATIVE_PATH = '.codex/madar-user-prompt-submit.cjs' const CODEX_PROMPT_HOOK_SCRIPT_MARKER = '// madar managed Codex UserPromptSubmit hook' // SECURITY: Keep this command static. It resolves the project script at runtime so @@ -290,6 +291,27 @@ export function codexPromptHookCommand(): string { return CODEX_PROMPT_HOOK_COMMAND } +export function claudePromptHookCommand(): string { + return CLAUDE_PROMPT_HOOK_COMMAND +} + +export function isCurrentMadarClaudePromptHook(hook: unknown, expectedCommand: string): boolean { + if (!isRecord(hook) || !Array.isArray(hook.hooks)) { + return false + } + + if (hook.name !== MANAGED_HOOK_NAME || hook.source !== MANAGED_HOOK_SOURCE || hook.hooks.length !== 1) { + return false + } + + const entry = hook.hooks[0] + return isRecord(entry) + && entry.type === 'command' + && entry.command === expectedCommand + && Object.keys(entry).every((key) => key === 'type' || key === 'command') + && Object.keys(hook).every((key) => key === 'name' || key === 'source' || key === 'hooks') +} + export function isMadarCodexPromptHook(hook: unknown): boolean { if (!isRecord(hook) || !Array.isArray(hook.hooks) || !hasMadarHookSentinel(hook)) { return false @@ -746,28 +768,52 @@ function settingsHook(profile?: InstallProfile): Record { hooks: [ { type: 'command', - command: CLAUDE_PROMPT_HOOK_COMMAND, + command: claudePromptHookCommand(), }, ], }) } +function legacyClaudePromptHookScript(profile?: InstallProfile): string { + return buildPromptApplicabilityHookScript( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: profile === 'strict' ? STRICT_CONTEXT_PACK_MESSAGE : RETRIEVE_FIRST_MESSAGE, + }, + }), + 'UserPromptSubmit', + ) +} + +function claudePromptHookScript(profile?: InstallProfile): string { + return `${CLAUDE_PROMPT_HOOK_SCRIPT_MARKER}\n${legacyClaudePromptHookScript(profile)}` +} + +export function hasManagedClaudePromptHookScript(scriptPath: string): boolean { + if (!existsSync(scriptPath)) { + return false + } + + const content = readFileSync(scriptPath, 'utf8') + return content === claudePromptHookScript() + || content === claudePromptHookScript('strict') + || content === legacyClaudePromptHookScript() + || content === legacyClaudePromptHookScript('strict') +} + +function assertClaudePromptHookScriptIsSafe(projectDir: string): void { + const hookScriptPath = join(projectDir, CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH) + if (existsSync(hookScriptPath) && !hasManagedClaudePromptHookScript(hookScriptPath)) { + throw new Error(`Refusing to overwrite user-managed Claude hook script at ${hookScriptPath}`) + } +} + function writeClaudePromptHookScript(projectDir: string, profile?: InstallProfile): void { const hookScriptPath = join(projectDir, CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH) + assertClaudePromptHookScriptIsSafe(projectDir) mkdirSync(dirname(hookScriptPath), { recursive: true }) - writeFileSync( - hookScriptPath, - buildPromptApplicabilityHookScript( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'UserPromptSubmit', - additionalContext: profile === 'strict' ? STRICT_CONTEXT_PACK_MESSAGE : RETRIEVE_FIRST_MESSAGE, - }, - }), - 'UserPromptSubmit', - ), - 'utf8', - ) + writeFileSync(hookScriptPath, claudePromptHookScript(profile), 'utf8') } function codexPromptHookScript(): string { @@ -976,6 +1022,22 @@ function writeJson(filePath: string, value: Record): void { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') } +function removeEmptyHookConfigEntries(config: Record): void { + if (!isRecord(config.hooks)) { + return + } + + const hooks = config.hooks + for (const key of ['UserPromptSubmit', 'PreToolUse', 'BeforeTool']) { + if (Array.isArray(hooks[key]) && hooks[key].length === 0) { + delete hooks[key] + } + } + if (Object.keys(hooks).length === 0) { + delete config.hooks + } +} + export function resolveOpencodeConfigPath(projectDir: string): string { const jsonPath = join(projectDir, OPENCODE_JSON_CONFIG_PATH) if (existsSync(jsonPath)) { @@ -1810,7 +1872,11 @@ function uninstallMcpServer(projectDir: string, target: McpConfigTarget): string return undefined } - delete (mcpConfig[serversKey] as Record)[SKILL_SLUG] + const servers = mcpConfig[serversKey] as Record + delete servers[SKILL_SLUG] + if (Object.keys(servers).length === 0) { + delete mcpConfig[serversKey] + } writeJson(mcpJsonPath, mcpConfig) return `${MCP_CONFIG_PATHS[target]} -> MCP server removed` } @@ -1825,23 +1891,34 @@ function installClaudeHook(projectDir: string, profile?: InstallProfile): string writeClaudePromptHookScript(projectDir, profile) const existingIndex = userPromptSubmit.findIndex((hook) => isMadarProjectHook(hook)) + const filteredPreToolUse = preToolUse.filter((hook) => !isMadarProjectHook(hook, 'Glob|Grep|Bash|Agent|Read')) if (existingIndex >= 0) { userPromptSubmit[existingIndex] = settingsHook(profile) - hooks.PreToolUse = preToolUse.filter((hook) => !isMadarProjectHook(hook, 'Glob|Grep|Bash|Agent|Read')) + if (filteredPreToolUse.length === 0) { + delete hooks.PreToolUse + } else { + hooks.PreToolUse = filteredPreToolUse + } writeJson(settingsPath, settings) return '.claude/settings.json -> hook updated' } userPromptSubmit.push(settingsHook(profile)) - hooks.PreToolUse = preToolUse.filter((hook) => !isMadarProjectHook(hook, 'Glob|Grep|Bash|Agent|Read')) + if (filteredPreToolUse.length === 0) { + delete hooks.PreToolUse + } else { + hooks.PreToolUse = filteredPreToolUse + } writeJson(settingsPath, settings) return '.claude/settings.json -> UserPromptSubmit hook registered' } function uninstallClaudeHook(projectDir: string): string | undefined { const hookScriptPath = join(projectDir, CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH) - const removedHookScript = existsSync(hookScriptPath) - rmSync(hookScriptPath, { force: true }) + const removedHookScript = hasManagedClaudePromptHookScript(hookScriptPath) + if (removedHookScript) { + rmSync(hookScriptPath, { force: true }) + } const settingsPath = join(projectDir, '.claude', 'settings.json') if (!existsSync(settingsPath)) { @@ -1859,8 +1936,17 @@ function uninstallClaudeHook(projectDir: string): string | undefined { return removedHookScript ? `${CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH} -> hook script removed` : undefined } - hooks.UserPromptSubmit = filteredUserPromptSubmit - hooks.PreToolUse = filteredPreToolUse + if (filteredUserPromptSubmit.length === 0) { + delete hooks.UserPromptSubmit + } else { + hooks.UserPromptSubmit = filteredUserPromptSubmit + } + if (filteredPreToolUse.length === 0) { + delete hooks.PreToolUse + } else { + hooks.PreToolUse = filteredPreToolUse + } + removeEmptyHookConfigEntries(settings) writeJson(settingsPath, settings) return '.claude/settings.json -> UserPromptSubmit hook removed' } @@ -1903,7 +1989,12 @@ function uninstallGeminiHook(projectDir: string): string | undefined { return undefined } - hooks.BeforeTool = filtered + if (filtered.length === 0) { + delete hooks.BeforeTool + } else { + hooks.BeforeTool = filtered + } + removeEmptyHookConfigEntries(settings) writeJson(settingsPath, settings) return '.gemini/settings.json -> BeforeTool hook removed' } @@ -2534,6 +2625,7 @@ function uninstallCodexHook(projectDir: string): string | undefined { hooks.PreToolUse = filteredPreToolUse } } + removeEmptyHookConfigEntries(hooksConfig) writeJson(hooksPath, hooksConfig) return removedModernHooks @@ -2838,6 +2930,7 @@ export function cursorUninstall(projectDir = '.'): string { export function claudeInstall(projectDir = '.', options: McpInstallOptions = {}): string { const resolvedProjectDir = resolve(projectDir) + assertClaudePromptHookScriptIsSafe(resolvedProjectDir) const messages = [ writeSection(join(resolvedProjectDir, 'CLAUDE.md'), claudeMdSection(options.profile)), installClaudeHook(resolvedProjectDir, options.profile), diff --git a/src/runtime/context-pack-recovery.ts b/src/runtime/context-pack-recovery.ts index 6bd8b80a..16427277 100644 --- a/src/runtime/context-pack-recovery.ts +++ b/src/runtime/context-pack-recovery.ts @@ -1,6 +1,7 @@ import type { ContextPackRecoveryPlan, MadarAnswerabilityState, MadarVerificationTarget } from '../contracts/context-recovery.js' import type { KnowledgeGraph } from '../contracts/graph.js' import { assessMadarResponseEvidence } from './mcp-response-evidence.js' +import { reconcileRetrievalPlanQueryEvidence } from './retrieve/conceptual-fallback.js' import { buildRetrievalEvidencePlanFromResult } from './retrieve/pipeline.js' import type { RetrieveOptions, RetrieveResult } from './retrieve.js' @@ -104,6 +105,17 @@ function resultNodeSignature(result: RetrieveResult): string { return selectedNodeIds(result).sort().join('\u0000') } +function reconcileRetrievalPlan(result: RetrieveResult, question: string): RetrieveResult { + const retrievalPlan = reconcileRetrievalPlanQueryEvidence( + result.retrieval_plan, + question, + result.matched_nodes, + ) + return retrievalPlan === result.retrieval_plan || !retrievalPlan + ? result + : { ...result, retrieval_plan: retrievalPlan } +} + function addTargetCandidates( graph: KnowledgeGraph, result: RetrieveResult, @@ -196,7 +208,7 @@ export function recoverContextPackResult( && options.taskKind !== 'implement' && initial.retrieval_gate?.level !== 0 if (!recoveryAllowed || initialAssessment.state === 'ready' || initialAssessment.state === 'ready_with_caveat') { - return { + return reconcileRetrievalPlan({ ...initial, recovery: { version: 1, @@ -207,7 +219,7 @@ export function recoverContextPackResult( attempts: [], improved: false, }, - } + }, options.question) } let current = initial @@ -294,7 +306,7 @@ export function recoverContextPackResult( } } - return { + return reconcileRetrievalPlan({ ...current, ...(initial.retrieval_plan ? { retrieval_plan: initial.retrieval_plan } : {}), recovery: { @@ -312,5 +324,5 @@ export function recoverContextPackResult( attempts, improved, }, - } + }, options.question) } diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index a2855610..80883d58 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -67,6 +67,7 @@ import { planConceptualFallback, queryEvidenceObligations, queryEvidenceTermsMatch, + reconcileRetrievalPlanQueryEvidence, underScopedDivergenceNodeIds, } from './retrieve/conceptual-fallback.js' import { recoverContextPackResult } from './context-pack-recovery.js' @@ -595,8 +596,14 @@ export function withRetrieveSnippetBudget( const shapedNodes = applyRetrieveSnippetBudgetToNodes(result.matched_nodes, options) const baseNodeTokenCount = tokenCountForMatchedNodes(result.matched_nodes) const shapedNodeTokenCount = tokenCountForMatchedNodes(shapedNodes.nodes) + const retrievalPlan = reconcileRetrievalPlanQueryEvidence( + result.retrieval_plan, + result.question, + shapedNodes.nodes, + ) return { ...result, + ...(retrievalPlan ? { retrieval_plan: retrievalPlan } : {}), token_count: Math.max(0, result.token_count - baseNodeTokenCount + shapedNodeTokenCount), matched_nodes: shapedNodes.nodes as RetrieveMatchedNode[], snippet_budget_tokens_used: shapedNodes.usedTokens, @@ -6084,6 +6091,7 @@ function retrieveContextWithConceptualFallback(graph: KnowledgeGraph, options: R }] : [] }), + initialQueryEvidence, ...(options.community !== undefined ? { community: options.community } : {}), ...(options.fileType !== undefined ? { fileType: options.fileType } : {}), }) @@ -6105,6 +6113,7 @@ function retrieveContextWithConceptualFallback(graph: KnowledgeGraph, options: R selectedSourceFiles(initial), selectedSourceFiles(recovered), new Set(selectedNodeIds(recovered)), + evaluateQueryEvidenceCoverage(options.question, recovered.matched_nodes), ) return { ...(finalized.useRecovered ? recovered : initial), @@ -6342,17 +6351,23 @@ export function compactRetrieveResult(result: RetrieveResult, options: RetrieveS (total, node) => total + estimateRetrieveEntryTokens(node.label, node.source_file, node.line_number, node.snippet ?? null), 0, ) + const matchedNodes = shapedNodes.nodes.map(({ evidence_class: _evidenceClass, ...node }) => node as CompactRetrieveMatchedNode) + const retrievalPlan = reconcileRetrievalPlanQueryEvidence( + result.retrieval_plan, + result.question, + matchedNodes, + ) return { question: result.question, token_count: Math.max(0, compactPack.token_count - compactPackNodeTokenCount + shapedNodeTokenCount), - matched_nodes: shapedNodes.nodes.map(({ evidence_class: _evidenceClass, ...node }) => node as CompactRetrieveMatchedNode), + matched_nodes: matchedNodes, relationships: compactPack.relationships, community_context: compactPack.community_context, graph_signals: compactPack.graph_signals ?? { god_nodes: [], bridge_nodes: [] }, ...(compactPack.shared_file_type ? { shared_file_type: compactPack.shared_file_type } : {}), retrieval_strategy: result.retrieval_strategy ?? 'default', - ...(result.retrieval_plan ? { retrieval_plan: result.retrieval_plan } : {}), + ...(retrievalPlan ? { retrieval_plan: retrievalPlan } : {}), ...(result.recovery ? { recovery: result.recovery } : {}), snippet_budget_tokens_used: shapedNodes.usedTokens, snippet_budget_tokens_remaining: shapedNodes.remainingTokens, @@ -6517,7 +6532,7 @@ function compactRetrievePayloadForStdioProfile( const retainsRelationshipEndpoints = (relationship: RetrieveRelationship): boolean => (typeof relationship.from_id !== 'string' || retainedNodeIds.has(relationship.from_id)) && (typeof relationship.to_id !== 'string' || retainedNodeIds.has(relationship.to_id)) - return { + const compacted = { ...payload, matched_nodes: matchedNodes, relationships: payload.relationships @@ -6528,6 +6543,23 @@ function compactRetrievePayloadForStdioProfile( ...(payload.expandable ? { expandable: compactExpandableRefsForStdio(payload.expandable, profile) } : {}), ...(payload.slice ? { slice: compactSliceForStdio(payload.slice, profile) } : {}), } + return reconcileStdioRetrievalPlan(compacted) +} + +function reconcileStdioRetrievalPlan(payload: StdioRetrieveResult): StdioRetrieveResult { + const retrievalPlan = reconcileRetrievalPlanQueryEvidence( + payload.retrieval_plan, + payload.question, + payload.matched_nodes, + ) + if (!retrievalPlan || retrievalPlan === payload.retrieval_plan) { + return payload + } + + return { + ...payload, + retrieval_plan: retrievalPlan, + } } export function compactRetrieveResultForStdio(result: RetrieveResult, options: RetrieveStdioOptions = {}): StdioRetrieveResult { @@ -6544,7 +6576,7 @@ export function compactRetrieveResultForStdio(result: RetrieveResult, options: R ...(result.expandable ? { expandable: result.expandable } : {}), ...(result.coverage ? { coverage: result.coverage } : {}), retrieval_strategy: result.retrieval_strategy ?? 'default', - ...(result.retrieval_plan ? { retrieval_plan: result.retrieval_plan } : {}), + ...(compactResult.retrieval_plan ? { retrieval_plan: compactResult.retrieval_plan } : {}), ...(result.recovery ? { recovery: result.recovery } : {}), snippet_budget_tokens_used: compactResult.snippet_budget_tokens_used, snippet_budget_tokens_remaining: compactResult.snippet_budget_tokens_remaining, @@ -6555,6 +6587,7 @@ export function compactRetrieveResultForStdio(result: RetrieveResult, options: R } const maxOutputTokens = options.maxOutputTokens ?? DEFAULT_RETRIEVE_STDIO_OUTPUT_TOKENS + payload = reconcileStdioRetrievalPlan(payload) if (estimateRetrievePayloadTokens(payload) <= maxOutputTokens) { return payload } diff --git a/src/runtime/retrieve/conceptual-fallback.ts b/src/runtime/retrieve/conceptual-fallback.ts index 872f1d10..d8e2220c 100644 --- a/src/runtime/retrieve/conceptual-fallback.ts +++ b/src/runtime/retrieve/conceptual-fallback.ts @@ -208,6 +208,8 @@ export interface ConceptualFallbackInput { question: string initialQuality: RetrievalQualitySnapshot selectedNodes: readonly ConceptualFallbackSelectedNode[] + /** Snippet-grounded coverage for the initial selected result, when available. */ + initialQueryEvidence?: QueryEvidenceCoverage community?: number fileType?: string } @@ -215,6 +217,8 @@ export interface ConceptualFallbackInput { export interface ConceptualFallbackProposal { plan: ContextPackRetrievalPlanDetail nodeBoosts: ReadonlyMap + /** Snippet-grounded coverage used for the public retrieval-plan contract. */ + initialQueryEvidence?: QueryEvidenceCoverage /** Internal prompt-obligation coverage used to decide whether recovery improved the result. */ obligationMatches?: ReadonlyMap> obligationCount?: number @@ -602,6 +606,38 @@ export function evaluateQueryEvidenceCoverage( } } +/** + * The retrieval plan is surfaced alongside the final selected snippets. Keep + * its public obligation summary tied to those snippets even when a later + * recovery or reranking stage replaces the conceptual-fallback result. + */ +export function reconcileRetrievalPlanQueryEvidence( + plan: ContextPackRetrievalPlanDetail | undefined, + question: string, + nodes: readonly QueryEvidenceNode[], +): ContextPackRetrievalPlanDetail | undefined { + if (!plan?.query_obligations) { + return plan + } + + const coverage = evaluateQueryEvidenceCoverage(question, nodes) + if ( + plan.query_obligations.total === coverage.total + && plan.query_obligations.finally_covered === coverage.covered + ) { + return plan + } + + return { + ...plan, + query_obligations: { + ...plan.query_obligations, + total: coverage.total, + finally_covered: coverage.covered, + }, + } +} + function stringValues(value: unknown, depth = 0): string[] { if (depth > 2) { return [] @@ -1591,9 +1627,10 @@ export function planConceptualFallback( anchor.id, new Set(anchor.obligationMatches.keys()), ])) - const initialObligationCoverage = new Set( + const initialAnchorObligationCoverage = new Set( [...selectedIds].flatMap((nodeId) => [...(obligationMatches.get(nodeId) ?? [])]), ).size + const initialObligationCoverage = input.initialQueryEvidence?.covered ?? initialAnchorObligationCoverage const obligationRecoveryNeeded = obligations.length >= 2 && initialObligationCoverage < obligations.length const reasons: RetrievalFallbackReason[] = [ @@ -1812,9 +1849,10 @@ export function planConceptualFallback( plan: { ...basePlan, status: 'kept_initial', attempts: [attempt] }, nodeBoosts: boundedBoosts, obligationMatches, + ...(input.initialQueryEvidence ? { initialQueryEvidence: input.initialQueryEvidence } : {}), obligationCount: obligations.length, preferredObligationAnchors: diversified.preferredByObligation, - initialObligationCoverage, + initialObligationCoverage: initialAnchorObligationCoverage, } } @@ -1834,6 +1872,7 @@ export function finalizeConceptualFallbackPlan( initialFiles: ReadonlySet, recoveredFiles: ReadonlySet, recoveredNodeIds: ReadonlySet = new Set(), + recoveredQueryEvidence?: QueryEvidenceCoverage, ): { plan: ContextPackRetrievalPlanDetail; useRecovered: boolean } { const attempt = proposal.plan.attempts[0] if (!attempt || proposal.nodeBoosts.size === 0) { @@ -1854,22 +1893,38 @@ export function finalizeConceptualFallbackPlan( const recoveredObligationCoverage = new Set( [...recoveredNodeIds].flatMap((nodeId) => [...(proposal.obligationMatches?.get(nodeId) ?? [])]), ).size + const initialSnippetObligationCoverage = proposal.plan.query_obligations?.initially_covered + ?? proposal.initialObligationCoverage + ?? 0 + const recoveredSnippetObligationCoverage = recoveredQueryEvidence?.covered ?? recoveredObligationCoverage + const initialSnippetObligations = proposal.initialQueryEvidence?.covered_obligations ?? [] + const recoveredSnippetObligations = new Set(recoveredQueryEvidence?.covered_obligations ?? []) + const snippetEvidenceNonRegressing = recoveredQueryEvidence === undefined + || proposal.plan.query_obligations === undefined + || initialSnippetObligations.every((obligation) => recoveredSnippetObligations.has(obligation)) const obligationCoverageImproved = recoveredObligationCoverage > (proposal.initialObligationCoverage ?? 0) + const snippetObligationCoverageImproved = recoveredSnippetObligationCoverage > initialSnippetObligationCoverage const recoveryGoalMet = recoveredEmptyResult || (proposal.plan.reasons.includes('missing_required_evidence') && requiredEvidenceImproved) || (proposal.plan.reasons.includes('missing_semantic_evidence') && semanticEvidenceImproved) || (proposal.plan.reasons.includes('low_workflow_coherence') && coherenceImproved) || (proposal.plan.reasons.includes('weak_anchors') && weakAnchorImproved) || obligationCoverageImproved + || snippetObligationCoverageImproved const obligationAdjustedQuality = qualityValue(recoveredQuality) // Cross-service and cross-language stages are often disconnected in a // static graph. Covering a previously missing prompt obligation must be // allowed to outweigh the resulting drop in local cluster coherence. - + Math.max(0, recoveredObligationCoverage - (proposal.initialObligationCoverage ?? 0)) * 1.5 + + Math.max( + 0, + recoveredObligationCoverage - (proposal.initialObligationCoverage ?? 0), + recoveredSnippetObligationCoverage - initialSnippetObligationCoverage, + ) * 1.5 const obligationAwareNonRegression = obligationAdjustedQuality >= qualityValue(proposal.plan.initial) - 0.05 const useRecovered = resultChanged && recoveryGoalMet && (nonRegressingQuality || obligationAwareNonRegression) + && snippetEvidenceNonRegressing const finalAttempt: RetrievalFallbackAttempt = { ...attempt, @@ -1890,7 +1945,7 @@ export function finalizeConceptualFallbackPlan( query_obligations: { ...proposal.plan.query_obligations, finally_covered: useRecovered - ? recoveredObligationCoverage + ? recoveredSnippetObligationCoverage : proposal.plan.query_obligations.initially_covered, }, } diff --git a/src/runtime/stdio/tools.ts b/src/runtime/stdio/tools.ts index 3eff7b6a..eabe66b7 100644 --- a/src/runtime/stdio/tools.ts +++ b/src/runtime/stdio/tools.ts @@ -4,6 +4,7 @@ import { buildMadarPromptPack } from '../../infrastructure/compare.js' import { buildAnswerReadyPackSchema, buildExplainPackPayloadCore } from '../../infrastructure/context-pack-command.js' import type { TaskContextPlan } from '../../contracts/task-context-plan.js' import type { CompareRefsInput } from '../../infrastructure/time-travel.js' +import type { ContextPackRetrievalPlan, ContextPackRetrievalPlanDetail } from '../../contracts/retrieval-plan.js' import type { ContextPackClaim, ContextPackCoverage, @@ -44,6 +45,7 @@ import { type RetrieveResult, type RetrieveSnippetOptions, } from '../retrieve.js' +import { reconcileRetrievalPlanQueryEvidence } from '../retrieve/conceptual-fallback.js' import { buildRetrievalEvidencePlan } from '../retrieve/pipeline.js' import { computeContextPackDiagnostics } from '../context-pack-diagnostics.js' import { collectPackNodeIds, computeDeltaContextPack } from '../context-pack-delta.js' @@ -159,6 +161,22 @@ interface CachedExplainContextPackPayload extends Record { expandable?: ContextPackExpandableRef[] } +function reconcileSurfaceRetrievalPlan( + plan: ContextPackRetrievalPlan | undefined, + question: string, + nodes: readonly T[], +): ContextPackRetrievalPlan | undefined { + if (!plan || !('initial' in plan) || !('final' in plan)) { + return plan + } + + return reconcileRetrievalPlanQueryEvidence( + plan as ContextPackRetrievalPlanDetail, + question, + nodes, + ) +} + function isStoredContextPackHandle(value: unknown): value is StoredContextPackHandle { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -1916,6 +1934,11 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa return rest }) const resolvedDeltaNodes = applyResolutionToNodes(deltaNodesStripped, deltaResult.delta_pack.relationships) + const deltaRetrievalPlan = reconcileSurfaceRetrievalPlan( + deltaResult.delta_pack.retrieval_plan, + prompt, + resolvedDeltaNodes.nodes, + ) const deltaEvidence = buildMadarResponseEvidence({ answerContract: deltaResult.delta_pack.answer_contract, coverage: deltaResult.delta_pack.coverage, @@ -1942,8 +1965,8 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa relationships: deltaResult.delta_pack.relationships, community_context: deltaResult.delta_pack.community_context, graph_signals: deltaResult.delta_pack.graph_signals ?? { god_nodes: [], bridge_nodes: [] }, - ...(deltaResult.delta_pack.retrieval_plan - ? { retrieval_plan: deltaResult.delta_pack.retrieval_plan } + ...(deltaRetrievalPlan + ? { retrieval_plan: deltaRetrievalPlan } : {}), }, diagnostics: computeContextPackDiagnostics(deltaResult.delta_pack, { skipBudgetUnderutilization: true }), @@ -1977,9 +2000,15 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa return helpers.ok(id, helpers.textToolResult(JSON.stringify(deltaPayload))) } const resolvedNodes = applyResolutionToNodes(compactPack.matched_nodes, compactPack.relationships) + const retrievalPlan = reconcileSurfaceRetrievalPlan( + (explainPayload?.pack ?? compactPack).retrieval_plan, + prompt, + resolvedNodes.nodes, + ) const serializedPack = { ...(explainPayload?.pack ?? compactPack), matched_nodes: resolvedNodes.nodes, + ...(retrievalPlan ? { retrieval_plan: retrievalPlan } : {}), } const evidence = evidenceForRetrievePayload({ question: prompt, diff --git a/tests/unit/context-pack-command.test.ts b/tests/unit/context-pack-command.test.ts index 66399477..e36bb20c 100644 --- a/tests/unit/context-pack-command.test.ts +++ b/tests/unit/context-pack-command.test.ts @@ -10,6 +10,7 @@ import { buildAnswerReadyPackSchema, runContextPackCommand, type ContextPackComm import { build } from '../../src/pipeline/build.js' import { assessMadarResponseEvidence } from '../../src/runtime/mcp-response-evidence.js' import { compactRetrieveResult, retrieveContext, type RetrieveResult } from '../../src/runtime/retrieve.js' +import { evaluateQueryEvidenceCoverage } from '../../src/runtime/retrieve/conceptual-fallback.js' import { buildRetrievalEvidencePlanFromResult } from '../../src/runtime/retrieve/pipeline.js' import { estimateQueryTokens } from '../../src/runtime/serve.js' import { buildCrossLayerMonitorFlowFixture } from '../fixtures/cross-layer-monitor-flow.js' @@ -1117,6 +1118,10 @@ describe('context-pack-command', () => { answerability?: { state?: string; broad_search_fallback?: string } agent_directive?: string } + pack?: { + matched_nodes?: Array<{ label: string; source_file: string; snippet?: string | null }> + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + } } expect(optimisticRetrieval.recovery?.final_state).toBe('ready') @@ -1134,6 +1139,48 @@ describe('context-pack-command', () => { ])) }) + it('reconciles a retained retrieval-plan receipt to the serialized snippets', () => { + const prompt = 'Explain the exact end-to-end path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Compare every distinct overall-status computation. Read-only: do not modify files.' + const retrieval = retrieveContext(buildCrossLayerMonitorFlowFixture(), { + question: prompt, + budget: 1_800, + taskKind: 'explain', + retrievalStrategy: 'slice-v1', + }) + const compact = compactRetrieveResult(retrieval) + const omittedNodeIds = new Set( + compact.matched_nodes + .filter((node) => /apps\/workflows\/src\/checker\/(?:index|alerting|utils)\.ts$/.test(node.source_file)) + .flatMap((node) => node.node_id ? [node.node_id] : []), + ) + const { score: _score, ...evidence } = assessMadarResponseEvidence({ + evidencePlan: buildRetrievalEvidencePlanFromResult(retrieval), + question: prompt, + recovery: retrieval.recovery, + }) + const payload = buildAnswerReadyPackSchema({ + schema_version: 1, + task: 'explain', + prompt, + budget: 5_000, + evidence, + pack: { + ...compact, + matched_nodes: compact.matched_nodes.filter((node) => !node.node_id || !omittedNodeIds.has(node.node_id)), + }, + }, 5_000, retrieval.selection_diagnostics) + const pack = payload.pack as { + matched_nodes: Array<{ label: string; source_file: string; snippet?: string | null }> + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + } + + const serializedCoverage = evaluateQueryEvidenceCoverage(prompt, pack.matched_nodes) + expect(pack.retrieval_plan?.query_obligations).toMatchObject({ + total: serializedCoverage.total, + finally_covered: serializedCoverage.covered, + }) + }) + it('keeps a late unique evidence owner when the eight-node response cap would otherwise drop it', () => { const prompt = 'Explain the exact end-to-end path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Compare every distinct overall-status computation.' const retrieval = retrieveContext(buildCrossLayerMonitorFlowFixture(), { @@ -1177,8 +1224,12 @@ describe('context-pack-command', () => { }, }, 5_000, retrieval.selection_diagnostics) const selectedNodes = (payload.pack as { - matched_nodes: Array<{ node_id?: string; snippet?: string }> + matched_nodes: Array<{ node_id?: string; label: string; source_file: string; snippet?: string }> + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } }).matched_nodes + const retrievalPlan = (payload.pack as { + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + }).retrieval_plan const serializedEvidence = payload.evidence as { answerability?: { state?: string } agent_directive?: string @@ -1191,6 +1242,11 @@ describe('context-pack-command', () => { answerability: { state: expect.stringMatching(/^ready(?:_with_caveat)?$/) }, agent_directive: 'answer_from_pack', }) + const serializedCoverage = evaluateQueryEvidenceCoverage(prompt, selectedNodes) + expect(retrievalPlan?.query_obligations).toMatchObject({ + total: serializedCoverage.total, + finally_covered: serializedCoverage.covered, + }) }) it('keeps every cited supporting node when one falls beyond the answer-ready node cap', () => { @@ -1482,9 +1538,39 @@ describe('context-pack-command', () => { taskIntent: 'explain', retrievalStrategy: 'slice-v1', }) + const retrievalWithStalePlan = { + ...retrieval, + retrieval_plan: { + version: 1 as const, + status: 'kept_initial' as const, + reasons: ['missing_query_obligations' as const], + initial: { + selected_nodes: retrieval.matched_nodes.length, + selected_files: retrieval.matched_nodes.length, + direct_matches: retrieval.matched_nodes.length, + explicit_anchors: 0, + workflow_coherence: 1, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: retrieval.token_count, + }, + final: { + selected_nodes: retrieval.matched_nodes.length, + selected_files: retrieval.matched_nodes.length, + direct_matches: retrieval.matched_nodes.length, + explicit_anchors: 0, + workflow_coherence: 1, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: retrieval.token_count, + }, + attempts: [], + query_obligations: { total: 99, initially_covered: 99, finally_covered: 99 }, + }, + } const dependencies: ContextPackCommandDependencies = { loadGraph: vi.fn().mockReturnValue(graph), - retrieveContext: vi.fn().mockReturnValue(retrieval), + retrieveContext: vi.fn().mockReturnValue(retrievalWithStalePlan), compactRetrieveResult, analyzePrImpact: vi.fn(), compactPrImpactResult: vi.fn(), @@ -1502,11 +1588,20 @@ describe('context-pack-command', () => { verbose: true, } as never, dependencies) const payload = JSON.parse(output) as { - pack?: { slice?: { selected_paths?: unknown[]; selected_path_count?: number } } + pack?: { + slice?: { selected_paths?: unknown[]; selected_path_count?: number } + matched_nodes?: Array<{ label: string; source_file: string; snippet?: string | null }> + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + } } expect(payload.pack?.slice?.selected_paths?.length).toBeGreaterThan(0) expect(payload.pack?.slice?.selected_path_count).toBeUndefined() + const coverage = evaluateQueryEvidenceCoverage(retrieval.question, payload.pack?.matched_nodes ?? []) + expect(payload.pack?.retrieval_plan?.query_obligations).toEqual(expect.objectContaining({ + total: coverage.total, + finally_covered: coverage.covered, + })) }) it('deprioritizes helper-like matched nodes when runtime-generation explain falls back without an execution spine', async () => { diff --git a/tests/unit/context-pack-recovery.test.ts b/tests/unit/context-pack-recovery.test.ts index 4dafb2af..9d0e73f6 100644 --- a/tests/unit/context-pack-recovery.test.ts +++ b/tests/unit/context-pack-recovery.test.ts @@ -5,6 +5,32 @@ import { KnowledgeGraph } from '../../src/contracts/graph.js' import { recoverContextPackResult } from '../../src/runtime/context-pack-recovery.js' import type { RetrieveResult } from '../../src/runtime/retrieve.js' +function conceptualPlan(finallyCovered: number): NonNullable { + const quality = { + selected_nodes: 1, + selected_files: 1, + direct_matches: 1, + explicit_anchors: 0, + workflow_coherence: 0.5, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: 100, + } + return { + version: 1, + status: 'recovered', + reasons: ['missing_query_obligations'], + initial: quality, + final: quality, + attempts: [], + query_obligations: { + total: 1, + initially_covered: 0, + finally_covered: finallyCovered, + }, + } +} + function coverage(supportingCovered: boolean): ContextPackCoverage { return { required_evidence: ['primary', 'supporting'], @@ -145,6 +171,27 @@ describe('bounded cumulative context-pack recovery', () => { }) }) + it('reconciles conceptual query coverage after a later accepted recovery pass', () => { + const initial = { + ...result({ supportingCovered: false, expandable: [target('expand-supporting', 'supporting')] }), + retrieval_plan: conceptualPlan(0), + } + const recovered = result({ supportingCovered: true, includeSupportingNode: true }) + + const output = recoverContextPackResult( + recoveryGraph(), + initial, + { question: initial.question, budget: 500 }, + () => recovered, + ) + + expect(output.retrieval_plan?.query_obligations).toEqual({ + total: 1, + initially_covered: 0, + finally_covered: 1, + }) + }) + it('keeps the cumulative prior result when two bounded attempts remain partial', () => { const targets = [target('expand-supporting', 'supporting'), target('expand-alternate', 'alternate')] const initial = result({ supportingCovered: false, expandable: targets }) diff --git a/tests/unit/doctor.test.ts b/tests/unit/doctor.test.ts index 35bc88a9..4b1950a9 100644 --- a/tests/unit/doctor.test.ts +++ b/tests/unit/doctor.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { describe, expect, test } from 'vitest' -import { agentsInstall, isMadarCodexMcpConfig, resolveCodexMcpConfigPath } from '../../src/infrastructure/install.js' +import { agentsInstall, claudeInstall, isMadarCodexMcpConfig, resolveCodexMcpConfigPath } from '../../src/infrastructure/install.js' import { runDoctorCommand, runStatusCommand } from '../../src/infrastructure/doctor.js' import { generateGraph } from '../../src/infrastructure/generate.js' import { createWatcherState, writeWatcherState } from '../../src/infrastructure/watcher-state.js' @@ -221,14 +221,8 @@ describe('doctor command', () => { withSandbox((sandboxDir) => { const graphPath = resolve(sandboxDir, 'out', 'graph.json') writeText(graphPath, '{"nodes":[],"edges":[]}\n') - writeText(resolve(sandboxDir, 'CLAUDE.md'), '## madar\n') writeText(resolve(sandboxDir, 'GEMINI.md'), '## madar\n') writeText(resolve(sandboxDir, '.cursor', 'rules', 'madar.mdc'), 'rule') - writeJson(resolve(sandboxDir, '.claude', 'settings.json'), { - hooks: { - PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'out' }] }], - }, - }) writeJson(resolve(sandboxDir, '.gemini', 'settings.json'), { hooks: { BeforeTool: [{ matcher: 'read_file', hooks: [{ type: 'command', command: 'out' }] }], @@ -241,9 +235,9 @@ describe('doctor command', () => { }, }, }) - writeMcpServer(resolve(sandboxDir, '.mcp.json'), 'mcpServers') writeMcpServer(resolve(sandboxDir, '.cursor', 'mcp.json'), 'mcpServers') writeMcpServer(resolve(sandboxDir, '.vscode', 'mcp.json'), 'servers') + claudeInstall(sandboxDir) const doctor = runDoctorCommand({ projectDir: sandboxDir, @@ -266,6 +260,118 @@ describe('doctor command', () => { }) }) + test('recognizes the current Claude UserPromptSubmit hook as configured', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') + claudeInstall(sandboxDir) + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('claude: configured') + expect(doctor).toContain('rules=yes, hook=yes, mcp=ok') + }) + }) + + test('recognizes an exact legacy Claude prompt hook script during upgrade', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') + claudeInstall(sandboxDir) + const scriptPath = resolve(sandboxDir, '.claude', 'madar-user-prompt-submit.cjs') + writeText( + scriptPath, + readFileSync(scriptPath, 'utf8').replace('// madar managed Claude UserPromptSubmit hook\n', ''), + ) + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('claude: configured') + expect(doctor).toContain('rules=yes, hook=yes, mcp=ok') + }) + }) + + test('reports Claude as partial when its managed prompt hook script is missing', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') + claudeInstall(sandboxDir) + rmSync(resolve(sandboxDir, '.claude', 'madar-user-prompt-submit.cjs'), { force: true }) + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('claude: partial') + expect(doctor).toContain('rules=yes, hook=no, mcp=ok') + }) + }) + + test('reports Claude as partial when its managed prompt hook script is corrupted', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') + claudeInstall(sandboxDir) + writeText(resolve(sandboxDir, '.claude', 'madar-user-prompt-submit.cjs'), 'console.log("corrupted")\n') + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('claude: partial') + expect(doctor).toContain('rules=yes, hook=no, mcp=ok') + }) + }) + + test('reports Claude as partial when its managed hook identity has the wrong command', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') + claudeInstall(sandboxDir) + writeJson(resolve(sandboxDir, '.claude', 'settings.json'), { + hooks: { + UserPromptSubmit: [{ + name: 'madar', + source: 'madar', + hooks: [{ type: 'command', command: 'node .claude/not-madar.cjs' }], + }], + }, + }) + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('claude: partial') + expect(doctor).toContain('rules=yes, hook=no, mcp=ok') + }) + }) + + test('does not treat an arbitrary PreToolUse out reference as a Madar Claude hook', () => { + withSandbox((sandboxDir) => { + writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') + writeText(resolve(sandboxDir, 'CLAUDE.md'), '## madar\n') + writeJson(resolve(sandboxDir, '.claude', 'settings.json'), { + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'echo out' }] }], + }, + }) + writeMcpServer(resolve(sandboxDir, '.mcp.json'), 'mcpServers') + + const doctor = runDoctorCommand({ + projectDir: sandboxDir, + now: Date.now(), + }) + + expect(doctor).toContain('claude: partial') + expect(doctor).toContain('rules=yes, hook=no, mcp=ok') + }) + }) + test('flags stale mcp path and recommends reinstall', () => { withSandbox((sandboxDir) => { const graphPath = resolve(sandboxDir, 'out', 'graph.json') diff --git a/tests/unit/install.test.ts b/tests/unit/install.test.ts index 9b5ea5bf..671316dd 100644 --- a/tests/unit/install.test.ts +++ b/tests/unit/install.test.ts @@ -582,6 +582,7 @@ describe('install helpers', () => { mcpServers?: Record } expect(uninstalledSettings.mcpServers?.madar).toBeUndefined() + expect(uninstalledSettings).toEqual({}) }) }) }) @@ -711,6 +712,48 @@ describe('install helpers', () => { const uninstallMessage = claudeUninstall(projectDir) expect(uninstallMessage).toMatch(/madar section removed|CLAUDE\.md was empty after removal/) expect(existsSync(join(projectDir, 'CLAUDE.md'))).toBe(false) + expect(JSON.parse(readFileSync(join(projectDir, '.claude', 'settings.json'), 'utf8'))).toEqual({}) + expect(existsSync(join(projectDir, '.claude', 'madar-user-prompt-submit.cjs'))).toBe(false) + expect(JSON.parse(readFileSync(join(projectDir, '.mcp.json'), 'utf8'))).toEqual({}) + }) + }) + + it('preserves pre-existing empty Claude config files on uninstall', () => { + withTempDir((projectDir) => { + const settingsPath = join(projectDir, '.claude', 'settings.json') + const mcpPath = join(projectDir, '.mcp.json') + mkdirSync(dirname(settingsPath), { recursive: true }) + writeFileSync(settingsPath, '{}\n', 'utf8') + writeFileSync(mcpPath, '{}\n', 'utf8') + + claudeInstall(projectDir) + claudeUninstall(projectDir) + + expect(JSON.parse(readFileSync(settingsPath, 'utf8'))).toEqual({}) + expect(JSON.parse(readFileSync(mcpPath, 'utf8'))).toEqual({}) + }) + }) + + it('preserves unrelated Claude settings while removing Madar hook entries', () => { + withTempDir((projectDir) => { + const settingsPath = join(projectDir, '.claude', 'settings.json') + mkdirSync(dirname(settingsPath), { recursive: true }) + writeFileSync(settingsPath, JSON.stringify({ + permissions: { allow: ['Read'] }, + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo keep-prompt-hook' }] }], + PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'echo keep-read-hook' }] }], + }, + }, null, 2), 'utf8') + + claudeInstall(projectDir) + claudeUninstall(projectDir) + + const settings = readFileSync(settingsPath, 'utf8') + expect(settings).toContain('keep-prompt-hook') + expect(settings).toContain('keep-read-hook') + expect(settings).toContain('"permissions"') + expect(settings).not.toContain('madar-user-prompt-submit.cjs') }) }) @@ -769,6 +812,35 @@ describe('install helpers', () => { }) }) + it('does not remove a Claude prompt script replaced by the user after installation', () => { + withTempDir((projectDir) => { + claudeInstall(projectDir) + + const hookScriptPath = join(projectDir, '.claude', 'madar-user-prompt-submit.cjs') + const userScript = 'console.log("keep user-managed Claude hook")\n' + writeFileSync(hookScriptPath, userScript, 'utf8') + + claudeUninstall(projectDir) + + expect(readFileSync(hookScriptPath, 'utf8')).toBe(userScript) + expect(JSON.parse(readFileSync(join(projectDir, '.claude', 'settings.json'), 'utf8'))).toEqual({}) + }) + }) + + it('does not overwrite a user-managed Claude prompt script during installation', () => { + withTempDir((projectDir) => { + const hookScriptPath = join(projectDir, '.claude', 'madar-user-prompt-submit.cjs') + const userScript = 'console.log("keep user-managed Claude hook")\n' + mkdirSync(dirname(hookScriptPath), { recursive: true }) + writeFileSync(hookScriptPath, userScript, 'utf8') + + expect(() => claudeInstall(projectDir)).toThrow(/Refusing to overwrite user-managed Claude hook script/) + expect(readFileSync(hookScriptPath, 'utf8')).toBe(userScript) + expect(existsSync(join(projectDir, 'CLAUDE.md'))).toBe(false) + expect(existsSync(join(projectDir, '.claude', 'settings.json'))).toBe(false) + }) + }) + it('injects Claude prompt guidance only for local code tasks', () => { withTempDir((projectDir) => { mkdirSync(join(projectDir, 'out'), { recursive: true }) @@ -1419,6 +1491,30 @@ describe('install helpers', () => { }) }) + it('removes fresh Codex hook entries and script without deleting the config file', () => { + withTempDir((projectDir) => { + agentsInstall(projectDir, 'codex') + agentsUninstall(projectDir, 'codex') + + expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false) + expect(JSON.parse(readFileSync(join(projectDir, '.codex', 'hooks.json'), 'utf8'))).toEqual({}) + expect(existsSync(join(projectDir, '.codex', 'madar-user-prompt-submit.cjs'))).toBe(false) + }) + }) + + it('preserves a pre-existing empty Codex hooks config on uninstall', () => { + withTempDir((projectDir) => { + const hooksPath = join(projectDir, '.codex', 'hooks.json') + mkdirSync(dirname(hooksPath), { recursive: true }) + writeFileSync(hooksPath, '{}\n', 'utf8') + + agentsInstall(projectDir, 'codex') + agentsUninstall(projectDir, 'codex') + + expect(JSON.parse(readFileSync(hooksPath, 'utf8'))).toEqual({}) + }) + }) + it('does not rewrite an already-current Codex prompt hook', () => { withTempDir((projectDir) => { agentsInstall(projectDir, 'codex') @@ -2028,7 +2124,7 @@ describe('install helpers', () => { expect(uninstallMessage).toContain('madar section removed') expect(readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')).toContain('Keep calm.') expect(readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')).not.toContain('## madar') - expect(readFileSync(join(projectDir, '.codex', 'hooks.json'), 'utf8')).not.toContain('madar') + expect(JSON.parse(readFileSync(join(projectDir, '.codex', 'hooks.json'), 'utf8'))).toEqual({}) }) }) }) diff --git a/tests/unit/retrieve-conceptual-fallback.test.ts b/tests/unit/retrieve-conceptual-fallback.test.ts index 9c8abebe..d43aac3d 100644 --- a/tests/unit/retrieve-conceptual-fallback.test.ts +++ b/tests/unit/retrieve-conceptual-fallback.test.ts @@ -9,12 +9,15 @@ import { compactRetrieveResultForStdio, contextPackFromRetrieveResult, retrieveContext, + withRetrieveSnippetBudget, } from '../../src/runtime/retrieve.js' import { evaluateQueryEvidenceCoverage, + finalizeConceptualFallbackPlan, planConceptualFallback, queryEvidenceObligations, underScopedDivergenceNodeIds, + type ConceptualFallbackProposal, } from '../../src/runtime/retrieve/conceptual-fallback.js' function addNode( @@ -153,6 +156,90 @@ describe('conceptual-query fallback planner', () => { }) }) + it('reports snippet-grounded obligation coverage rather than vocabulary-anchor coverage', () => { + const question = 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Identify inconsistent status-computation paths.' + const proposal = planConceptualFallback(new KnowledgeGraph({ directed: true }), { + question, + initialQuality: lowQuality(), + selectedNodes: [], + initialQueryEvidence: { + total: 5, + covered: 5, + covered_obligations: [ + 'query:obligation:1', + 'query:obligation:2', + 'query:obligation:3', + 'query:obligation:4', + 'query:obligation:5', + ], + missing_obligations: [], + }, + }) + + expect(proposal.plan.reasons).not.toContain('missing_query_obligations') + expect(proposal.plan.query_obligations).toEqual({ + total: 5, + initially_covered: 5, + finally_covered: 5, + }) + }) + + it('does not replace one covered obligation with another at the same count', () => { + const quality = lowQuality() + const proposal: ConceptualFallbackProposal = { + plan: { + version: 1, + status: 'kept_initial', + reasons: ['missing_query_obligations'], + initial: quality, + final: quality, + attempts: [{ + fallback: 'repository_vocabulary_v1', + status: 'kept_initial', + reasons: ['missing_query_obligations'], + vocabulary_sources: [], + expansion_terms: [], + promoted_candidates: 1, + promoted_communities: [], + changed_result: false, + added_selected_files: 0, + removed_selected_files: 0, + }], + query_obligations: { + total: 2, + initially_covered: 2, + finally_covered: 2, + }, + }, + nodeBoosts: new Map([['recovered', 1]]), + initialQueryEvidence: { + total: 2, + covered: 2, + covered_obligations: ['query:obligation:1', 'query:obligation:2'], + missing_obligations: [], + }, + obligationMatches: new Map([['recovered', new Set([0, 1])]]), + initialObligationCoverage: 1, + } + + const finalized = finalizeConceptualFallbackPlan( + proposal, + quality, + new Set(['/initial.ts']), + new Set(['/recovered.ts']), + new Set(['recovered']), + { + total: 2, + covered: 2, + covered_obligations: ['query:obligation:1', 'query:obligation:3'], + missing_obligations: ['query:obligation:2'], + }, + ) + + expect(finalized.useRecovered).toBe(false) + expect(finalized.plan.query_obligations?.finally_covered).toBe(2) + }) + it('excludes generic computations from a repository-scoped divergence comparison', () => { const graph = new KnowledgeGraph({ directed: true }) addNode(graph, 'page-status', 'computeOverallStatus()', '/apps/server/status-page/index.ts') @@ -443,14 +530,58 @@ describe('conceptual-query fallback planner', () => { expect(retrieval.retrieval_plan?.initial.workflow_coherence).toBe(1) }) - it('surfaces the retrieval plan through full, compact, and stdio context representations', () => { + it('keeps retrieval-plan query coverage aligned with every snippet-shaped representation', () => { const result = retrieveContext(conceptualWorkflowGraph(), { question: 'How is topology kept current when modifications happen?', budget: 3000, }) - - expect(contextPackFromRetrieveResult(result).retrieval_plan).toEqual(result.retrieval_plan) - expect(compactRetrieveResult(result).retrieval_plan).toEqual(result.retrieval_plan) - expect(compactRetrieveResultForStdio(result).retrieval_plan).toEqual(result.retrieval_plan) + const stalePlanResult = { + ...result, + retrieval_plan: { + version: 1 as const, + status: 'kept_initial' as const, + reasons: ['missing_query_obligations' as const], + initial: { + selected_nodes: result.matched_nodes.length, + selected_files: result.matched_nodes.length, + direct_matches: result.matched_nodes.length, + explicit_anchors: 0, + workflow_coherence: 1, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: result.token_count, + }, + final: { + selected_nodes: result.matched_nodes.length, + selected_files: result.matched_nodes.length, + direct_matches: result.matched_nodes.length, + explicit_anchors: 0, + workflow_coherence: 1, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: result.token_count, + }, + attempts: [], + query_obligations: { + total: 99, + initially_covered: 99, + finally_covered: 99, + }, + }, + } + const shapedRepresentations = [ + withRetrieveSnippetBudget(stalePlanResult, { topNWithSnippet: 1, snippetBudget: 12 }), + compactRetrieveResult(stalePlanResult, { topNWithSnippet: 1, snippetBudget: 12 }), + compactRetrieveResultForStdio(stalePlanResult, { topNWithSnippet: 1, snippetBudget: 12, maxOutputTokens: 1 }), + ] + + expect(contextPackFromRetrieveResult(stalePlanResult).retrieval_plan).toEqual(stalePlanResult.retrieval_plan) + for (const representation of shapedRepresentations) { + const coverage = evaluateQueryEvidenceCoverage(result.question, representation.matched_nodes) + expect(representation.retrieval_plan?.query_obligations).toEqual(expect.objectContaining({ + total: coverage.total, + finally_covered: coverage.covered, + })) + } }) }) diff --git a/tests/unit/retrieve-cross-layer-flow.test.ts b/tests/unit/retrieve-cross-layer-flow.test.ts index 49b4be38..5c1afe72 100644 --- a/tests/unit/retrieve-cross-layer-flow.test.ts +++ b/tests/unit/retrieve-cross-layer-flow.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import { readQueryEvidenceSnippet, retrieveContext } from '../../src/runtime/retrieve.js' import { assessMadarResponseEvidence } from '../../src/runtime/mcp-response-evidence.js' import { buildRetrievalEvidencePlanFromResult } from '../../src/runtime/retrieve/pipeline.js' +import { evaluateQueryEvidenceCoverage } from '../../src/runtime/retrieve/conceptual-fallback.js' import { handleStdioRequest } from '../../src/runtime/stdio-server.js' import { buildCrossLayerMonitorFlowFixture, @@ -49,6 +50,7 @@ describe('cross-layer flow retrieval', () => { question: QUESTION, recovery: result.recovery, }) + const snippetCoverage = evaluateQueryEvidenceCoverage(QUESTION, result.matched_nodes) expect( CROSS_LAYER_MONITOR_FLOW_FILES.every((file) => selectedFiles.has(file)), @@ -78,6 +80,10 @@ describe('cross-layer flow retrieval', () => { expect(result.retrieval_plan?.query_obligations?.initially_covered).toBeLessThan( result.retrieval_plan?.query_obligations?.finally_covered ?? 0, ) + expect(result.retrieval_plan?.query_obligations).toMatchObject({ + total: snippetCoverage.total, + finally_covered: snippetCoverage.covered, + }) expect(evidence.answerability.state).toMatch(/^ready(?:_with_caveat)?$/) expect(evidence.answerability.broad_search_fallback).toBe('not_needed') expect(evidence.agent_directive).toBe('answer_from_pack') diff --git a/tests/unit/stdio-slice-surface.test.ts b/tests/unit/stdio-slice-surface.test.ts index e56b8a26..ee15448e 100644 --- a/tests/unit/stdio-slice-surface.test.ts +++ b/tests/unit/stdio-slice-surface.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { handleStdioRequest } from '../../src/runtime/stdio-server.js' import * as retrieveRuntime from '../../src/runtime/retrieve.js' +import { evaluateQueryEvidenceCoverage } from '../../src/runtime/retrieve/conceptual-fallback.js' import { estimateQueryTokens } from '../../src/runtime/serve.js' const tempRoots: string[] = [] @@ -185,6 +186,172 @@ describe('stdio slice-v1 surface', () => { expect([...retrieveSnippetLines].some((line) => contextPackSnippetLines.has(line))).toBe(true) }) + it('reconciles fallback coverage receipts for verbose retrieve, verbose context_pack, and delta packs', async () => { + const graphPath = createGraphPath() + const question = 'Trace how a failed monitor check becomes an incident and triggers notifications.' + const retrieval = { + question, + token_count: 300, + matched_nodes: [ + { + node_id: 'monitor-check', + label: 'checkMonitor()', + source_file: 'apps/checker/check-monitor.ts', + line_number: 10, + file_type: 'code', + snippet: 'if (monitorCheck.failed) await createIncident(monitor)', + match_score: 1, + relevance_band: 'direct' as const, + community: 0, + community_label: 'monitoring', + }, + { + node_id: 'incident', + label: 'createIncident()', + source_file: 'apps/workflows/create-incident.ts', + line_number: 20, + file_type: 'code', + snippet: 'await notificationWorkflow.triggerNotifications(incident)', + match_score: 0.9, + relevance_band: 'direct' as const, + community: 1, + community_label: 'incident workflow', + }, + { + node_id: 'notification', + label: 'triggerNotifications()', + source_file: 'apps/workflows/notifications.ts', + line_number: 30, + file_type: 'code', + snippet: 'await sendNotification({ type: "alert" })', + match_score: 0.8, + relevance_band: 'direct' as const, + community: 2, + community_label: 'notifications', + }, + ], + relationships: [ + { from_id: 'monitor-check', from: 'checkMonitor()', to_id: 'incident', to: 'createIncident()', relation: 'calls' }, + { from_id: 'incident', from: 'createIncident()', to_id: 'notification', to: 'triggerNotifications()', relation: 'calls' }, + ], + community_context: [ + { id: 0, label: 'monitoring', node_count: 1 }, + { id: 1, label: 'incident workflow', node_count: 1 }, + { id: 2, label: 'notifications', node_count: 1 }, + ], + graph_signals: { god_nodes: [], bridge_nodes: [] }, + retrieval_strategy: 'default' as const, + retrieval_plan: { + version: 1 as const, + status: 'kept_initial' as const, + reasons: ['missing_query_obligations' as const], + initial: { + selected_nodes: 3, + selected_files: 3, + direct_matches: 3, + explicit_anchors: 3, + workflow_coherence: 1, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: 300, + }, + final: { + selected_nodes: 3, + selected_files: 3, + direct_matches: 3, + explicit_anchors: 3, + workflow_coherence: 1, + missing_required_evidence: 0, + missing_semantic_evidence: 0, + token_count: 300, + }, + attempts: [], + query_obligations: { + total: 99, + initially_covered: 99, + finally_covered: 99, + }, + }, + } + const retrieveSpy = vi.spyOn(retrieveRuntime, 'retrieveContext').mockImplementation(() => retrieval as never) + const sessionState = { + logLevel: 'info' as const, + subscribedResourceUris: new Set(), + resourceVersions: new Map(), + resourceListSignature: null, + contextPromptSessions: new Map(), + contextPackHandles: new Map(), + contextPackCache: new Map(), + contextPackNodeIds: new Map(), + } + const parsePayload = (response: unknown): { + matched_nodes?: Array<{ label: string; source_file: string; snippet?: string | null }> + pack?: { + matched_nodes?: Array<{ label: string; source_file: string; snippet?: string | null }> + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + } + retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + } => JSON.parse((((response as { result?: { content?: Array<{ text: string }> } }).result?.content) ?? [])[0]?.text ?? '') + const expectPlanMatchesNodes = ( + payload: ReturnType, + nodes: Array<{ label: string; source_file: string; snippet?: string | null }> | undefined, + plan: { query_obligations?: { total?: number; finally_covered?: number } } | undefined, + ): void => { + const coverage = evaluateQueryEvidenceCoverage(question, nodes ?? []) + expect(plan?.query_obligations).toEqual(expect.objectContaining({ + total: coverage.total, + finally_covered: coverage.covered, + })) + } + + try { + const verboseRetrieveResponse = await Promise.resolve(handleStdioRequest(graphPath, { + id: 101, + method: 'tools/call', + params: { + name: 'retrieve', + arguments: { question, budget: 1000, verbose: true, top_n_with_snippet: 1, snippet_budget: 8 }, + }, + })) + const verboseContextPackResponse = await Promise.resolve(handleStdioRequest(graphPath, { + id: 102, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { prompt: question, budget: 1000, task: 'explain', verbose: true }, + }, + }, sessionState)) + const firstDeltaResponse = await Promise.resolve(handleStdioRequest(graphPath, { + id: 103, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { prompt: question, budget: 1000, task: 'explain', delta_session_id: 'receipt-delta' }, + }, + }, sessionState)) + const secondDeltaResponse = await Promise.resolve(handleStdioRequest(graphPath, { + id: 104, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { prompt: question, budget: 1000, task: 'explain', delta_session_id: 'receipt-delta' }, + }, + }, sessionState)) + + const verboseRetrievePayload = parsePayload(verboseRetrieveResponse) + const verboseContextPackPayload = parsePayload(verboseContextPackResponse) + const firstDeltaPayload = parsePayload(firstDeltaResponse) + const secondDeltaPayload = parsePayload(secondDeltaResponse) + + expectPlanMatchesNodes(verboseRetrievePayload, verboseRetrievePayload.matched_nodes, verboseRetrievePayload.retrieval_plan) + expectPlanMatchesNodes(verboseContextPackPayload, verboseContextPackPayload.pack?.matched_nodes, verboseContextPackPayload.pack?.retrieval_plan) + expectPlanMatchesNodes(firstDeltaPayload, firstDeltaPayload.pack?.matched_nodes, firstDeltaPayload.pack?.retrieval_plan) + expectPlanMatchesNodes(secondDeltaPayload, secondDeltaPayload.pack?.matched_nodes, secondDeltaPayload.pack?.retrieval_plan) + } finally { + retrieveSpy.mockRestore() + } + }) + it('honors explain context_pack budgets below 3000', async () => { const graphPath = createGraphPath() From dc68d68c569f1cfc96290cead6e910013c23d1f4 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 14:03:34 +0400 Subject: [PATCH 2/2] fix: harden receipt and hook reconciliation --- src/infrastructure/context-pack-command.ts | 8 ++++- src/infrastructure/install.ts | 29 ++++++++++++++----- src/runtime/retrieve/conceptual-fallback.ts | 6 ++++ tests/unit/context-pack-command.test.ts | 3 +- tests/unit/install.test.ts | 13 +++++++++ .../unit/retrieve-conceptual-fallback.test.ts | 1 + 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/infrastructure/context-pack-command.ts b/src/infrastructure/context-pack-command.ts index 554f0683..6a06a984 100644 --- a/src/infrastructure/context-pack-command.ts +++ b/src/infrastructure/context-pack-command.ts @@ -1864,14 +1864,20 @@ function reconcileSerializedRetrievalPlanQueryObligations( return false } + const initiallyCovered = typeof obligations.initially_covered === 'number' + ? Math.min(obligations.initially_covered, queryCoverage.total) + : 0 + if ( obligations.total === queryCoverage.total + && obligations.initially_covered === initiallyCovered && obligations.finally_covered === queryCoverage.covered ) { return false } obligations.total = queryCoverage.total + obligations.initially_covered = initiallyCovered obligations.finally_covered = queryCoverage.covered return true } @@ -1890,7 +1896,7 @@ function reconcileSerializedQueryEvidence( const baselineMissing = new Set(baselineQueryCoverage?.missing_obligations ?? []) const lostDuringSerialization = queryCoverage.missing_obligations.some((obligation) => !baselineMissing.has(obligation)) if (!lostDuringSerialization) { - return false + return retrievalPlanReconciled } const missingObligations = [...new Set(queryCoverage.missing_obligations)] diff --git a/src/infrastructure/install.ts b/src/infrastructure/install.ts index 310522f2..b572e806 100644 --- a/src/infrastructure/install.ts +++ b/src/infrastructure/install.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto' -import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { getBuiltInSkillContent } from './install-skill-templates.js' @@ -791,20 +791,33 @@ function claudePromptHookScript(profile?: InstallProfile): string { } export function hasManagedClaudePromptHookScript(scriptPath: string): boolean { - if (!existsSync(scriptPath)) { + try { + if (!lstatSync(scriptPath).isFile()) { + return false + } + + const content = readFileSync(scriptPath, 'utf8') + return content === claudePromptHookScript() + || content === claudePromptHookScript('strict') + || content === legacyClaudePromptHookScript() + || content === legacyClaudePromptHookScript('strict') + } catch { return false } +} - const content = readFileSync(scriptPath, 'utf8') - return content === claudePromptHookScript() - || content === claudePromptHookScript('strict') - || content === legacyClaudePromptHookScript() - || content === legacyClaudePromptHookScript('strict') +function hasClaudePromptHookScriptPath(scriptPath: string): boolean { + try { + lstatSync(scriptPath) + return true + } catch { + return false + } } function assertClaudePromptHookScriptIsSafe(projectDir: string): void { const hookScriptPath = join(projectDir, CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH) - if (existsSync(hookScriptPath) && !hasManagedClaudePromptHookScript(hookScriptPath)) { + if (hasClaudePromptHookScriptPath(hookScriptPath) && !hasManagedClaudePromptHookScript(hookScriptPath)) { throw new Error(`Refusing to overwrite user-managed Claude hook script at ${hookScriptPath}`) } } diff --git a/src/runtime/retrieve/conceptual-fallback.ts b/src/runtime/retrieve/conceptual-fallback.ts index d8e2220c..2cbff083 100644 --- a/src/runtime/retrieve/conceptual-fallback.ts +++ b/src/runtime/retrieve/conceptual-fallback.ts @@ -621,8 +621,13 @@ export function reconcileRetrievalPlanQueryEvidence( } const coverage = evaluateQueryEvidenceCoverage(question, nodes) + const initiallyCovered = Math.min( + plan.query_obligations.initially_covered, + coverage.total, + ) if ( plan.query_obligations.total === coverage.total + && plan.query_obligations.initially_covered === initiallyCovered && plan.query_obligations.finally_covered === coverage.covered ) { return plan @@ -633,6 +638,7 @@ export function reconcileRetrievalPlanQueryEvidence( query_obligations: { ...plan.query_obligations, total: coverage.total, + initially_covered: initiallyCovered, finally_covered: coverage.covered, }, } diff --git a/tests/unit/context-pack-command.test.ts b/tests/unit/context-pack-command.test.ts index e36bb20c..67912974 100644 --- a/tests/unit/context-pack-command.test.ts +++ b/tests/unit/context-pack-command.test.ts @@ -1591,7 +1591,7 @@ describe('context-pack-command', () => { pack?: { slice?: { selected_paths?: unknown[]; selected_path_count?: number } matched_nodes?: Array<{ label: string; source_file: string; snippet?: string | null }> - retrieval_plan?: { query_obligations?: { total?: number; finally_covered?: number } } + retrieval_plan?: { query_obligations?: { total?: number; initially_covered?: number; finally_covered?: number } } } } @@ -1600,6 +1600,7 @@ describe('context-pack-command', () => { const coverage = evaluateQueryEvidenceCoverage(retrieval.question, payload.pack?.matched_nodes ?? []) expect(payload.pack?.retrieval_plan?.query_obligations).toEqual(expect.objectContaining({ total: coverage.total, + initially_covered: Math.min(99, coverage.total), finally_covered: coverage.covered, })) }) diff --git a/tests/unit/install.test.ts b/tests/unit/install.test.ts index 671316dd..037f2fd8 100644 --- a/tests/unit/install.test.ts +++ b/tests/unit/install.test.ts @@ -16,6 +16,7 @@ import { defaultInstallPlatform, geminiInstall, geminiUninstall, + hasManagedClaudePromptHookScript, installCopilotMcp, installSkill, isAgentPlatform, @@ -841,6 +842,18 @@ describe('install helpers', () => { }) }) + it('treats a non-file Claude prompt hook path as user-managed without crashing', () => { + withTempDir((projectDir) => { + const hookScriptPath = join(projectDir, '.claude', 'madar-user-prompt-submit.cjs') + mkdirSync(hookScriptPath, { recursive: true }) + + expect(hasManagedClaudePromptHookScript(hookScriptPath)).toBe(false) + expect(() => claudeInstall(projectDir)).toThrow(/Refusing to overwrite user-managed Claude hook script/) + expect(() => claudeUninstall(projectDir)).not.toThrow() + expect(existsSync(hookScriptPath)).toBe(true) + }) + }) + it('injects Claude prompt guidance only for local code tasks', () => { withTempDir((projectDir) => { mkdirSync(join(projectDir, 'out'), { recursive: true }) diff --git a/tests/unit/retrieve-conceptual-fallback.test.ts b/tests/unit/retrieve-conceptual-fallback.test.ts index d43aac3d..74f72170 100644 --- a/tests/unit/retrieve-conceptual-fallback.test.ts +++ b/tests/unit/retrieve-conceptual-fallback.test.ts @@ -580,6 +580,7 @@ describe('conceptual-query fallback planner', () => { const coverage = evaluateQueryEvidenceCoverage(result.question, representation.matched_nodes) expect(representation.retrieval_plan?.query_obligations).toEqual(expect.objectContaining({ total: coverage.total, + initially_covered: Math.min(99, coverage.total), finally_covered: coverage.covered, })) }