Skip to content

Commit ced70ce

Browse files
AVSRPA1KRclaude
andcommitted
feat(act): defer-* plan kinds for native deferral config
Wire the mcp-deferral-gaps findings to the act machinery with three new plan kinds, all through the existing ConfigDocs/runAction path (journaled, backed up, stale-guarded, dry-run previewable, undo restores byte-identical files): - defer-enable: removes a stale ENABLE_TOOL_SEARCH=false from the settings env of the scope where the finding recorded it. Refusal paths render as manual notes instead of plans: shell-profile lines (codeburn only appends marker blocks to shell files, never edits user lines), unknown proxies (setting the override blind makes requests fail outright on proxies that don't forward tool_reference blocks — the note says to verify first), Vertex, and old versions. A proxy-verified cause (set by the part-3 verifier) produces a real ENABLE_TOOL_SEARCH=true plan in user settings. - defer-alwaysload: strips alwaysLoad: true from the named servers in the exact config files the finding recorded. Gated on an injectable installed-version probe (default: claude --version); below v2.1.121, unparseable, or probe failure all refuse with a note naming the required version. Preview notes the removed up-to-5s startup block. - defer-threshold: rewrites the auto override to the recommended auto:N, or deletes it when the finding says the default already defers (removeOverride). Findings now carry FindingApply payloads (path, scope, cause, servers, recommended N); detector text is unchanged, so plain optimize renders byte-identically to before. Every plan links its findingId into the ActionRecord and states that changes take effect on the next session (the config is read at Claude Code start). Discrepancy vs the design notes: no existing version-check helper was found in guard/ or act/ to reuse, so the detector's parseVersion/ versionPredates are exported and shared instead of adding a parallel comparator. Refs #614 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7333604 commit ced70ce

5 files changed

Lines changed: 991 additions & 14 deletions

File tree

src/act/plans.ts

Lines changed: 278 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { existsSync, readFileSync } from 'fs'
2+
import { execFileSync } from 'child_process'
23
import { isAbsolute, join } from 'path'
34
import { homedir } from 'os'
45
import type { ActionKind, ActionPlan, PlannedChange } from './types.js'
56
import { sha256 } from './backup.js'
7+
import {
8+
ALWAYSLOAD_MIN_VERSION,
9+
ALWAYSLOAD_STARTUP_CAP_SECONDS,
10+
ENABLE_TOOL_SEARCH_VAR,
11+
parseVersion,
12+
versionPredates,
13+
} from '../optimize.js'
614
import type { WasteFinding } from '../optimize.js'
715

816
// Turns an optimize finding into a concrete, journaled file-mutation plan.
@@ -14,6 +22,9 @@ export type PlanContext = {
1422
homeDir?: string
1523
cwd?: string
1624
shell?: string
25+
// Installed Claude Code version (null when undeterminable). Injectable so
26+
// tests never shell out; production defaults to probing `claude --version`.
27+
claudeVersion?: () => string | null
1728
}
1829

1930
export type BuiltPlan = {
@@ -34,11 +45,32 @@ type ResolvedPaths = {
3445
projectSettings: string
3546
projectSettingsLocal: string
3647
userClaudeJson: string
48+
userSettings: string
3749
skillsDir: string
3850
agentsDir: string
3951
commandsDir: string
4052
projectClaudeMd: string
4153
shellRc: string
54+
// Not a path, but resolved from the same context: the injectable installed-
55+
// version probe the defer-alwaysload version gate consults.
56+
claudeVersion: () => string | null
57+
}
58+
59+
// `claude --version` prints e.g. "2.1.130 (Claude Code)"; any failure (binary
60+
// missing, timeout, non-zero exit) yields null and version-gated plans
61+
// degrade to a manual note instead of guessing.
62+
const CLAUDE_VERSION_PROBE_TIMEOUT_MS = 3000
63+
64+
function probeClaudeVersion(): string | null {
65+
try {
66+
return execFileSync('claude', ['--version'], {
67+
encoding: 'utf-8',
68+
timeout: CLAUDE_VERSION_PROBE_TIMEOUT_MS,
69+
stdio: ['ignore', 'pipe', 'ignore'],
70+
}).trim()
71+
} catch {
72+
return null
73+
}
4274
}
4375

4476
function resolvePaths(ctx: PlanContext): ResolvedPaths {
@@ -52,11 +84,13 @@ function resolvePaths(ctx: PlanContext): ResolvedPaths {
5284
projectSettings: join(cwd, '.claude', 'settings.json'),
5385
projectSettingsLocal: join(cwd, '.claude', 'settings.local.json'),
5486
userClaudeJson: join(homeDir, '.claude.json'),
87+
userSettings: join(homeDir, '.claude', 'settings.json'),
5588
skillsDir: join(homeDir, '.claude', 'skills'),
5689
agentsDir: join(homeDir, '.claude', 'agents'),
5790
commandsDir: join(homeDir, '.claude', 'commands'),
5891
projectClaudeMd: join(cwd, 'CLAUDE.md'),
5992
shellRc: join(homeDir, /zsh/.test(shell) ? '.zshrc' : '.bashrc'),
93+
claudeVersion: ctx.claudeVersion ?? probeClaudeVersion,
6094
}
6195
}
6296

@@ -74,6 +108,9 @@ function buildPlan(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
74108
case 'mcp-low-coverage': return buildMcpRemove(finding, r)
75109
case 'unused-mcp': return buildMcpRemove(finding, r)
76110
case 'mcp-project-scope': return buildMcpProjectScope(finding, r)
111+
case 'mcp-deferral-off': return buildDeferEnable(finding, r)
112+
case 'mcp-alwaysload-hygiene': return buildDeferAlwaysLoad(finding, r)
113+
case 'mcp-defer-threshold': return buildDeferThreshold(finding, r)
77114
case 'unused-skills': return buildArchive(finding, r, 'skill')
78115
case 'unused-agents': return buildArchive(finding, r, 'agent')
79116
case 'unused-commands': return buildArchive(finding, r, 'command')
@@ -230,15 +267,20 @@ function projectRemovalNote(server: string, entries: string[], homeDir: string):
230267
return `removes ${server} from ${entries.length} project ${noun}: ${entries.map(e => shortPath(e, homeDir)).join(', ')}`
231268
}
232269

270+
// Accumulates per-file preview annotations; repeats on a path join with ";".
271+
function pathNoteAdder(pathNotes: Record<string, string>): (path: string, note: string) => void {
272+
return (path, note) => {
273+
pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note
274+
}
275+
}
276+
233277
function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
234278
const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : []
235279
const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson]
236280
const docs = new ConfigDocs(r.homeDir)
237281
const skips: string[] = []
238282
const pathNotes: Record<string, string> = {}
239-
const addPathNote = (path: string, note: string): void => {
240-
pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note
241-
}
283+
const addPathNote = pathNoteAdder(pathNotes)
242284

243285
for (const server of servers) {
244286
let removed = false
@@ -268,9 +310,7 @@ function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPla
268310
const docs = new ConfigDocs(r.homeDir)
269311
const skips: string[] = []
270312
const pathNotes: Record<string, string> = {}
271-
const addPathNote = (path: string, note: string): void => {
272-
pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note
273-
}
313+
const addPathNote = pathNoteAdder(pathNotes)
274314

275315
for (const { server, keepProjects, removeProjects } of entries) {
276316
const keepers = keepProjects.filter(p => isAbsolute(p))
@@ -335,6 +375,238 @@ function mcpPlan(kind: ActionKind, findingId: string, description: string, chang
335375
return { kind, findingId, description, changes }
336376
}
337377

378+
// ---------------------------------------------------------------------------
379+
// MCP deferral plans — defer-enable / defer-alwaysload / defer-threshold (#614)
380+
// ---------------------------------------------------------------------------
381+
382+
// ENABLE_TOOL_SEARCH and mcpServers config are read once at Claude Code
383+
// process start, so an applied plan changes nothing for sessions already
384+
// running. Stated on every deferral plan.
385+
const NEXT_SESSION_NOTE = 'takes effect on the next session (this config is read at Claude Code start)'
386+
387+
// findDeferralEnvSetting (src/optimize.ts) reports shell-profile hits with
388+
// exactly this scope string; the plan layer keys its refusal on it.
389+
const SHELL_PROFILE_SCOPE = 'shell profile'
390+
391+
const SHELL_TOOL_SEARCH_LINE = new RegExp(`^\\s*(?:export\\s+)?${ENABLE_TOOL_SEARCH_VAR}\\s*=.*$`, 'm')
392+
393+
function envContainer(state: DocState): Record<string, unknown> | null {
394+
const env = state.doc.env
395+
return env && typeof env === 'object' ? env as Record<string, unknown> : null
396+
}
397+
398+
// An emptied env object stays in place, matching deleteServer's convention
399+
// for emptied mcpServers containers.
400+
function deleteEnvKey(state: DocState, key: string): boolean {
401+
const env = envContainer(state)
402+
if (!env || !(key in env)) return false
403+
delete env[key]
404+
state.dirty = true
405+
return true
406+
}
407+
408+
function setEnvKey(state: DocState, key: string, value: string): void {
409+
const env = envContainer(state) ?? (state.doc.env = {}) as Record<string, unknown>
410+
env[key] = value
411+
state.dirty = true
412+
}
413+
414+
// Shell rc files only ever receive marker-block APPENDS (markerChange);
415+
// deleting or rewriting arbitrary user lines is out. Deferral overrides
416+
// found in a profile get precise by-hand instructions naming the exact file
417+
// and line instead of a plan. `replacement` switches the instruction from
418+
// "delete the line" to "change it to <replacement>".
419+
function shellOverrideManualNotes(path: string, homeDir: string, replacement?: string): string[] {
420+
const shown = shortPath(path, homeDir)
421+
let content: string | null = null
422+
try {
423+
content = readFileSync(path, 'utf-8')
424+
} catch {
425+
content = null
426+
}
427+
const line = content?.match(SHELL_TOOL_SEARCH_LINE)?.[0]?.trim()
428+
if (!line) {
429+
return [`manual: ${ENABLE_TOOL_SEARCH_VAR} was reported in ${shown} but no such line is there now; nothing to change`]
430+
}
431+
// Keep the original line's `export ` prefix in the suggested replacement —
432+
// a user following the note verbatim would otherwise lose it.
433+
const replacementLine = replacement !== undefined && line.startsWith('export ') && !replacement.startsWith('export ')
434+
? `export ${replacement}`
435+
: replacement
436+
const action = replacementLine === undefined
437+
? `delete the line \`${line}\` from ${shown}`
438+
: `in ${shown}, change the line \`${line}\` to \`${replacementLine}\``
439+
return [`manual: ${action} yourself — codeburn only appends marker blocks to shell files and never edits user lines`]
440+
}
441+
442+
// mcp-deferral-off -> defer-enable. Only two causes are auto-appliable:
443+
// removing a stale ENABLE_TOOL_SEARCH=false from a settings file, and (for
444+
// the future part-3 verifier) forcing =true once a proxy is verified. The
445+
// rest refuse with instructions: an unknown proxy because an explicit
446+
// override makes requests FAIL outright on proxies that don't forward
447+
// tool_reference blocks (live-docs fact), Vertex because default-off there
448+
// is a platform property, old-version because the fix is `claude update`.
449+
function buildDeferEnable(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
450+
const payload = finding.apply?.kind === 'defer-enable' ? finding.apply : null
451+
if (!payload) return { plan: null, notes: [] }
452+
453+
switch (payload.cause) {
454+
case 'env-false': {
455+
if (!payload.settingPath) return { plan: null, notes: [] }
456+
if (payload.settingScope === SHELL_PROFILE_SCOPE) {
457+
return { plan: null, notes: shellOverrideManualNotes(payload.settingPath, r.homeDir) }
458+
}
459+
const docs = new ConfigDocs(r.homeDir)
460+
const state = docs.load(payload.settingPath)
461+
if (!state) return { plan: null, notes: docs.errorNotes() }
462+
if (!deleteEnvKey(state, ENABLE_TOOL_SEARCH_VAR)) {
463+
return { plan: null, notes: [`skipped: ${ENABLE_TOOL_SEARCH_VAR} is no longer set in ${shortPath(payload.settingPath, r.homeDir)}`] }
464+
}
465+
return {
466+
plan: mcpPlan(
467+
'defer-enable',
468+
finding.id,
469+
`Remove the ${ENABLE_TOOL_SEARCH_VAR}=${payload.value ?? 'false'} override from ${shortPath(payload.settingPath, r.homeDir)}`,
470+
docs.changes(),
471+
),
472+
notes: [`restores default-on MCP tool deferral; ${NEXT_SESSION_NOTE}`],
473+
}
474+
}
475+
case 'proxy-unknown': {
476+
const where = payload.settingPath ? ` configured in ${payload.settingScope ?? 'settings'} (${shortPath(payload.settingPath, r.homeDir)})` : ''
477+
return {
478+
plan: null,
479+
notes: [
480+
`not auto-applied: setting ${ENABLE_TOOL_SEARCH_VAR}=true would force deferral back on, but requests fail outright on proxies that don't forward tool_reference blocks. ` +
481+
`Verify that the proxy${where} forwards them before setting the override.`,
482+
],
483+
}
484+
}
485+
case 'proxy-verified': {
486+
// Part-3 verifier confirmed the proxy forwards tool_reference blocks,
487+
// so the explicit opt-in is safe. User settings env is the target: it
488+
// covers every project without touching shell files.
489+
const docs = new ConfigDocs(r.homeDir)
490+
const state = docs.load(r.userSettings)
491+
if (!state) return { plan: null, notes: docs.errorNotes() }
492+
setEnvKey(state, ENABLE_TOOL_SEARCH_VAR, 'true')
493+
return {
494+
plan: mcpPlan(
495+
'defer-enable',
496+
finding.id,
497+
`Set ${ENABLE_TOOL_SEARCH_VAR}=true in ${shortPath(r.userSettings, r.homeDir)} (proxy verified to forward tool_reference blocks)`,
498+
docs.changes(),
499+
),
500+
notes: [`enables MCP tool deferral through the verified proxy; ${NEXT_SESSION_NOTE}`],
501+
}
502+
}
503+
case 'vertex':
504+
return {
505+
plan: null,
506+
notes: [
507+
`manual: tool search is disabled by default on Vertex AI — a platform property, not a config error. ` +
508+
`Opt in yourself with \`export ${ENABLE_TOOL_SEARCH_VAR}=true\` if your Vertex setup supports it.`,
509+
],
510+
}
511+
case 'old-version':
512+
return {
513+
plan: null,
514+
notes: ['manual: every observed Claude Code version predates default-on tool search; run `claude update`'],
515+
}
516+
}
517+
}
518+
519+
// mcp-alwaysload-hygiene -> defer-alwaysload: drop `"alwaysLoad": true` from
520+
// the flagged servers in the exact config files the finding recorded.
521+
function buildDeferAlwaysLoad(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
522+
const entries = finding.apply?.kind === 'defer-alwaysload' ? finding.apply.servers : []
523+
if (entries.length === 0) return { plan: null, notes: [] }
524+
525+
// Version gate: server-level alwaysLoad shipped in v2.1.121. Below that
526+
// (or undeterminable) the key is inert — tools defer normally and there is
527+
// no startup block — so "removing the cost" would be a false claim.
528+
const installed = r.claudeVersion()
529+
const parsed = installed === null ? null : parseVersion(installed)
530+
if (parsed === null) {
531+
return { plan: null, notes: [`skipped: could not determine the installed Claude Code version; removing alwaysLoad is only meaningful on v${ALWAYSLOAD_MIN_VERSION}+`] }
532+
}
533+
if (versionPredates(installed!, ALWAYSLOAD_MIN_VERSION)) {
534+
return { plan: null, notes: [`skipped: installed Claude Code v${parsed.join('.')} predates v${ALWAYSLOAD_MIN_VERSION}, where server-level alwaysLoad shipped; the pin is inert there`] }
535+
}
536+
537+
const docs = new ConfigDocs(r.homeDir)
538+
const skips: string[] = []
539+
const pathNotes: Record<string, string> = {}
540+
const addPathNote = pathNoteAdder(pathNotes)
541+
542+
for (const { server, paths } of entries) {
543+
let removed = false
544+
for (const path of paths) {
545+
const state = docs.load(path)
546+
if (!state) continue
547+
const container = state.doc.mcpServers
548+
if (!container || typeof container !== 'object') continue
549+
const key = findServerKey(container as Record<string, unknown>, server)
550+
if (!key) continue
551+
const entry = (container as Record<string, unknown>)[key]
552+
if (!entry || typeof entry !== 'object' || (entry as Record<string, unknown>)['alwaysLoad'] !== true) continue
553+
delete (entry as Record<string, unknown>)['alwaysLoad']
554+
state.dirty = true
555+
removed = true
556+
// alwaysLoad also blocks session startup on the server's connection
557+
// (capped at 5s), so unpinning removes that startup cost too.
558+
addPathNote(path, `unpins ${server}: its schema defers on demand and session startup no longer blocks up to ${ALWAYSLOAD_STARTUP_CAP_SECONDS}s on its connection`)
559+
}
560+
if (!removed) skips.push(`skipped ${server}: no "alwaysLoad": true entry found in its config files`)
561+
}
562+
563+
const changes = docs.changes()
564+
const notes = [...docs.errorNotes(), ...skips]
565+
if (changes.length === 0) return { plan: null, notes }
566+
return {
567+
plan: mcpPlan('defer-alwaysload', finding.id, `Unpin ${entries.length === 1 ? 'an alwaysLoad MCP server' : 'alwaysLoad MCP servers'}`, changes),
568+
notes: [...notes, NEXT_SESSION_NOTE],
569+
...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}),
570+
}
571+
}
572+
573+
// mcp-defer-threshold -> defer-threshold: retune the ENABLE_TOOL_SEARCH auto
574+
// override in place. The detector found the key in config, so this is always
575+
// a value rewrite (or removal), never an env-object creation.
576+
function buildDeferThreshold(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
577+
const payload = finding.apply?.kind === 'defer-threshold' ? finding.apply : null
578+
if (!payload) return { plan: null, notes: [] }
579+
if (payload.settingScope === SHELL_PROFILE_SCOPE) {
580+
const replacement = payload.removeOverride ? undefined : `${ENABLE_TOOL_SEARCH_VAR}=auto:${payload.recommendedPercent}`
581+
return { plan: null, notes: shellOverrideManualNotes(payload.settingPath, r.homeDir, replacement) }
582+
}
583+
584+
const docs = new ConfigDocs(r.homeDir)
585+
const state = docs.load(payload.settingPath)
586+
if (!state) return { plan: null, notes: docs.errorNotes() }
587+
const env = envContainer(state)
588+
if (!env || !(ENABLE_TOOL_SEARCH_VAR in env)) {
589+
return { plan: null, notes: [`skipped: ${ENABLE_TOOL_SEARCH_VAR} is no longer set in ${shortPath(payload.settingPath, r.homeDir)}`] }
590+
}
591+
if (payload.removeOverride) {
592+
// Defs already exceed the default auto threshold: the override is pure
593+
// downside, so deleting it restores default auto behavior, which defers.
594+
delete env[ENABLE_TOOL_SEARCH_VAR]
595+
} else {
596+
env[ENABLE_TOOL_SEARCH_VAR] = `auto:${payload.recommendedPercent}`
597+
}
598+
state.dirty = true
599+
600+
const shown = shortPath(payload.settingPath, r.homeDir)
601+
const description = payload.removeOverride
602+
? `Remove the ${ENABLE_TOOL_SEARCH_VAR}=${payload.value} override from ${shown} (the default auto threshold already defers this volume)`
603+
: `Tighten ${ENABLE_TOOL_SEARCH_VAR} to auto:${payload.recommendedPercent} in ${shown}`
604+
return {
605+
plan: mcpPlan('defer-threshold', finding.id, description, docs.changes()),
606+
notes: [NEXT_SESSION_NOTE],
607+
}
608+
}
609+
338610
// ---------------------------------------------------------------------------
339611
// Archive unused skills / agents / commands
340612
// ---------------------------------------------------------------------------

src/act/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type ActionKind =
22
| 'mcp-remove' | 'mcp-project-scope'
3+
| 'defer-enable' | 'defer-alwaysload' | 'defer-threshold'
34
| 'archive-skill' | 'archive-agent' | 'archive-command'
45
| 'claude-md-rule' | 'shell-config'
56
| 'guard-install' | 'guard-uninstall'

0 commit comments

Comments
 (0)