Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/infrastructure/context-pack-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1849,20 +1849,54 @@ 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<typeof queryEvidenceCoverageFromPayload>,
): boolean {
if (!queryCoverage) {
return false
}

const plan = asJsonRecord(asJsonRecord(payload.pack)?.retrieval_plan)
const obligations = asJsonRecord(plan?.query_obligations)
if (!obligations) {
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
}

function reconcileSerializedQueryEvidence(
payload: JsonRecord,
trimmedFields: string[],
baselineQueryCoverage: ReturnType<typeof queryEvidenceCoverageFromPayload>,
): 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))
if (!lostDuringSerialization) {
return false
return retrievalPlanReconciled
}

const missingObligations = [...new Set(queryCoverage.missing_obligations)]
Expand Down
32 changes: 31 additions & 1 deletion src/infrastructure/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'))
Expand Down
152 changes: 129 additions & 23 deletions src/infrastructure/install.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -746,28 +768,65 @@ function settingsHook(profile?: InstallProfile): Record<string, unknown> {
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 {
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
}
}

function hasClaudePromptHookScriptPath(scriptPath: string): boolean {
try {
lstatSync(scriptPath)
return true
} catch {
return false
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function assertClaudePromptHookScriptIsSafe(projectDir: string): void {
const hookScriptPath = join(projectDir, CLAUDE_PROMPT_HOOK_SCRIPT_RELATIVE_PATH)
if (hasClaudePromptHookScriptPath(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 {
Expand Down Expand Up @@ -976,6 +1035,22 @@ function writeJson(filePath: string, value: Record<string, unknown>): void {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}

function removeEmptyHookConfigEntries(config: Record<string, unknown>): 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)) {
Expand Down Expand Up @@ -1810,7 +1885,11 @@ function uninstallMcpServer(projectDir: string, target: McpConfigTarget): string
return undefined
}

delete (mcpConfig[serversKey] as Record<string, unknown>)[SKILL_SLUG]
const servers = mcpConfig[serversKey] as Record<string, unknown>
delete servers[SKILL_SLUG]
if (Object.keys(servers).length === 0) {
delete mcpConfig[serversKey]
}
writeJson(mcpJsonPath, mcpConfig)
return `${MCP_CONFIG_PATHS[target]} -> MCP server removed`
}
Expand All @@ -1825,23 +1904,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)) {
Expand All @@ -1859,8 +1949,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'
}
Expand Down Expand Up @@ -1903,7 +2002,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'
}
Expand Down Expand Up @@ -2534,6 +2638,7 @@ function uninstallCodexHook(projectDir: string): string | undefined {
hooks.PreToolUse = filteredPreToolUse
}
}
removeEmptyHookConfigEntries(hooksConfig)
writeJson(hooksPath, hooksConfig)

return removedModernHooks
Expand Down Expand Up @@ -2838,6 +2943,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),
Expand Down
Loading
Loading