// ---------- IntelliJ-first coding mode (policy surface) ---------- // // Pure policy for the IntelliJ-first coding mode: mode parsing, capability // indexing, project-root matching, patch/mutation planning and per-turn // guidance rendering. No bridge/IPC state lives here; acp-mcp-bridge.ts // consumes these pieces from its activation closures. import { existsSync, realpathSync } from 'node:fs' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import type { BridgeIdeCodingState, BridgeTool } from '../mcp/types.js' export const IDE_MODE_ENV = 'PI_ACP_IDE_MODE' /** * Direct Fabric/Schema mutation calls the fabric_exec gate blocks. Whitespace * around the dot is tolerated so `pi .write(` cannot slip through; aliasing * (`const w = pi.write`) and indirect dispatch remain out of scope for a text * scan and are covered by the post-turn provenance audit instead. */ export const FABRIC_MUTATION_CALL_RE = /\b(?:schema\s*\.\s*commit|pi\s*\.\s*(?:write|edit))\s*\(/ /** * Strip JS comments while respecting string, template, and escape literals so * the fabric mutation gate does not false-block calls that are merely * mentioned in comments. Regex literals are deliberately not tracked: their * content is kept verbatim, which can only under-trigger (a missed block on a * line after a regex containing `//`), never corrupt surrounding code. */ export function stripJsComments(code: string): string { let out = '' let i = 0 const n = code.length while (i < n) { const ch = code[i] const next = i + 1 < n ? code[i + 1] : '' if (ch === '/' && next === '/') { while (i < n && code[i] !== '\n') i += 1 continue } if (ch === '/' && next === '*') { i += 2 while (i < n && !(code[i] === '*' && i + 1 < n && code[i + 1] === '/')) i += 1 i = i < n ? i + 2 : n out += ' ' continue } if (ch === '"' || ch === "'" || ch === '`') { const quote = ch out += ch i += 1 while (i < n) { const c = code[i] if (c === '\\') { out += c + (i + 1 < n ? code[i + 1] : '') i += 2 continue } out += c i += 1 if (c === quote) break } continue } out += ch i += 1 } return out } export const NATIVE_FILE_TOOLS = new Set(['read', 'edit', 'write', 'grep', 'find', 'ls']) const REQUIRED_CAPABILITIES: Array<{ key: IdeCapabilityKey; remoteNames: string[] }> = [ { key: 'read', remoteNames: ['read_file'] }, { key: 'open', remoteNames: ['open_file_in_editor'] }, { key: 'patch', remoteNames: ['apply_patch'] }, { key: 'create', remoteNames: ['create_new_file'] }, { key: 'search', remoteNames: ['skill_search', 'search_text'] }, { key: 'inspect', remoteNames: ['lint_files', 'get_file_problems'] } ] export const OPTIONAL_CAPABILITIES: Array<{ key: IdeCapabilityKey; remoteNames: string[] }> = [ { key: 'rename', remoteNames: ['rename_refactoring'] }, { key: 'reformat', remoteNames: ['reformat_file'] } ] export const MUTATION_REMOTE_NAMES = new Set(['apply_patch', 'rename_refactoring', 'reformat_file', 'create_new_file']) export const PATH_KEYS = new Set([ 'filePath', 'file_path', 'pathInProject', 'files', 'paths', 'sourcePath', 'targetPath', 'oldPath', 'newPath', 'directoryPath', 'contextPath', 'path', 'file' ]) export const RESULT_PATH_KEYS = new Set([ 'filePath', 'file_path', 'files', 'paths', 'path', 'pathInProject', 'directoryPath', 'sourcePath', 'targetPath', 'oldPath', 'newPath', 'file' ]) export type IdeCodingMode = 'off' | 'prefer' | 'required' export type IdeCodingState = BridgeIdeCodingState export type IdeCapabilityKey = 'read' | 'open' | 'patch' | 'create' | 'search' | 'inspect' | 'rename' | 'reformat' export type IdeCapabilityMap = Map export type PatchTargetKind = 'add' | 'update' | 'delete' | 'move' export interface PatchTarget { kind: PatchTargetKind source?: string destination: string } export interface MutationPlan { preOpen: string[] mutationArgs: Record postOpen: string[] } export function parseIdeCodingMode(value: string | undefined): { mode: IdeCodingMode; diagnostic?: string } { // Default to prefer: this adapter's primary host is a JetBrains IDE, and the // mode degrades gracefully when the IDE (or its catalog) is unavailable. // Explicit 'off' stays the opt-out; invalid values still fail closed to // 'required'. if (value === undefined || value === '') return { mode: 'prefer' } if (value === 'off') return { mode: 'off' } if (value === 'prefer') return { mode: 'prefer' } if (value === 'required') return { mode: 'required' } return { mode: 'required', diagnostic: `invalid PI_ACP_IDE_MODE value '${value}'; failing closed as required` } } export function indexIdeCapabilities( tools: BridgeTool[], registeredNames: ReadonlySet ): { capabilities: IdeCapabilityMap; missing: IdeCapabilityKey[]; duplicates: string[] } { const capabilities: IdeCapabilityMap = new Map() const missing: IdeCapabilityKey[] = [] const duplicates: string[] = [] const allGroups = [...REQUIRED_CAPABILITIES, ...OPTIONAL_CAPABILITIES] for (const group of allGroups) { let chosen: string | undefined for (const remote of group.remoteNames) { const match = tools.find(tool => tool.remoteName === remote && registeredNames.has(tool.exposedName)) if (match) { chosen = match.exposedName break } } if (chosen === undefined) { if (REQUIRED_CAPABILITIES.some(item => item.key === group.key)) missing.push(group.key) continue } capabilities.set(group.key, chosen) } for (const group of allGroups) { for (const remote of group.remoteNames) { const matches = tools.filter(tool => tool.remoteName === remote && registeredNames.has(tool.exposedName)) if (matches.length > 1) duplicates.push(`${remote}: ${matches.map(match => match.exposedName).join(', ')}`) } } return { capabilities, missing, duplicates } } export function evaluateIdeAvailability( mode: IdeCodingMode, capabilities: IdeCapabilityMap ): { state: IdeCodingState; missing: IdeCapabilityKey[] } { const missing = REQUIRED_CAPABILITIES.filter(group => !capabilities.has(group.key)).map(group => group.key) if (missing.length === 0) return { state: 'active', missing } return mode === 'prefer' ? { state: 'native_fallback', missing } : { state: 'required_unavailable', missing } } /** * Whether the IDE-reported project root can serve as the mutation fence and * audit base for this session: it must resolve to the same directory as the * session cwd (case-insensitive on Windows; symlinked roots compared via * realpath). A mismatch means the fence and the git-based audit would * normalize the same file differently, so the mode must fail closed. */ export function ideRootMatchesSessionRoot(projectRoot: string | undefined, sessionCwd: string | undefined): boolean { if (!projectRoot || !sessionCwd) return false const samePath = (a: string, b: string): boolean => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b const left = resolve(projectRoot) const right = resolve(sessionCwd) if (samePath(left, right)) return true try { const realLeft = realpathSync(left) const realRight = realpathSync(right) return samePath(realLeft, realRight) } catch { return false } } export function isInside(root: string, candidate: string): boolean { if (candidate === root) return true const rel = relative(root, candidate) if (rel === '' || isAbsolute(rel)) return false return rel !== '..' && !rel.startsWith('..' + sep) } export function tryRealpath(path: string): string | undefined { try { return realpathSync(path) } catch { return undefined } } export function nearestExistingAncestor(candidate: string): string | undefined { let current = candidate while (true) { if (existsSync(current)) return current const parent = dirname(current) if (parent === current) return undefined current = parent } } export function normalizeProjectPath(projectRoot: string, input: string, mutation: boolean): { path: string } { if (input === '') throw new Error('IDE path is empty') const root = resolve(projectRoot) let raw = input if (raw.startsWith('@')) raw = raw.slice(1) if (raw.includes('\0')) throw new Error(`IDE path contains NUL: ${input}`) const candidate = isAbsolute(raw) ? raw : resolve(root, raw) if (!isInside(root, candidate)) throw new Error(`IDE path escapes project root: ${input}`) if (mutation) { const realRoot = tryRealpath(root) if (realRoot !== undefined) { const existingAncestor = nearestExistingAncestor(candidate) if (existingAncestor !== undefined) { const real = tryRealpath(existingAncestor) if (real !== undefined && !isInside(realRoot, real)) { throw new Error(`IDE mutation path escapes project root through symlink: ${input}`) } } } } const rel = relative(root, candidate) return { path: rel.split(sep).join('/') } } export function parsePatchTargets(patch: string): PatchTarget[] { const targets: PatchTarget[] = [] const seen = new Set() const add = (kind: PatchTargetKind, destination: string, source?: string) => { if (destination === '' || destination === '/dev/null') return const key = `${kind}:${source ?? ''}:${destination}` if (seen.has(key)) return seen.add(key) targets.push({ kind, destination, source }) } const stripPrefix = (value: string): string => { if (value.startsWith('a/')) return value.slice(2) if (value.startsWith('b/')) return value.slice(2) return value } const cleanHeaderPath = (value: string): string => { let p = value.trim() const tab = p.indexOf('\t') if (tab >= 0) p = p.slice(0, tab) if (p.startsWith('"') && p.endsWith('"')) { p = p.slice(1, -1) const chars = Array.from(p) let result = '' let run: number[] = [] const flush = () => { if (run.length > 0) { result += Buffer.from(run).toString('utf8') run = [] } } for (let i = 0; i < chars.length; i++) { const code = chars[i].codePointAt(0) as number if (code !== 92) { flush() result += chars[i] continue } const nextCode = i + 1 < chars.length ? (chars[i + 1].codePointAt(0) as number) : -1 if (nextCode === 116) { flush() result += String.fromCharCode(9) i++ continue } if (nextCode === 110) { flush() result += String.fromCharCode(10) i++ continue } if (nextCode === 114) { flush() result += String.fromCharCode(13) i++ continue } if (nextCode === 98) { flush() result += String.fromCharCode(8) i++ continue } if (nextCode === 102) { flush() result += String.fromCharCode(12) i++ continue } if (nextCode === 118) { flush() result += String.fromCharCode(11) i++ continue } if (nextCode === 97) { flush() result += String.fromCharCode(7) i++ continue } if (nextCode === 34) { flush() result += String.fromCharCode(34) i++ continue } if (nextCode === 92) { flush() result += String.fromCharCode(92) i++ continue } const rest = chars.slice(i + 1).join('') const octal = /^[0-7]{1,3}/.exec(rest) if (octal) { run.push(parseInt(octal[0], 8)) i += octal[0].length continue } flush() result += String.fromCharCode(92) } flush() p = result } return p } const lines = patch.split(/\r?\n/) if (lines[0]?.trim() === '*** Begin Patch') { let current: { kind: PatchTargetKind; destination: string; source?: string } | undefined for (const line of lines) { const trimmed = line.trim() if (!line.startsWith('*** ')) continue const update = /^\*\*\* Update File:\s*(.+)$/.exec(trimmed) const addFile = /^\*\*\* Add File:\s*(.+)$/.exec(trimmed) const del = /^\*\*\* Delete File:\s*(.+)$/.exec(trimmed) const move = /^\*\*\* Move to:\s*(.+)$/.exec(trimmed) if (update) { if (current) add(current.kind, current.destination, current.source) current = { kind: 'update', destination: update[1].trim() } } else if (addFile) { if (current) add(current.kind, current.destination, current.source) current = { kind: 'add', destination: addFile[1].trim() } } else if (del) { if (current) add(current.kind, current.destination, current.source) current = { kind: 'delete', destination: del[1].trim() } } else if (move) { if (!current) throw new Error('Codex patch Move to without a source section') current = { kind: 'move', destination: move[1].trim(), source: current.destination } } } if (current) add(current.kind, current.destination, current.source) return targets } let pendingOld: string | undefined let remainingOld = 0 let remainingNew = 0 for (const line of lines) { const trimmed = line.trim() const hunk = /^@@\s+-\d+(?:,(\d+))?\s+\+\d+(?:,(\d+))?/.exec(trimmed) if (hunk) { remainingOld = hunk[1] === undefined ? 1 : Number(hunk[1]) remainingNew = hunk[2] === undefined ? 1 : Number(hunk[2]) continue } if (remainingOld > 0 || remainingNew > 0) { if (line.startsWith(' ')) { if (remainingOld > 0) remainingOld-- if (remainingNew > 0) remainingNew-- continue } if (line.startsWith('-')) { if (remainingOld > 0) remainingOld-- continue } if (line.startsWith('+')) { if (remainingNew > 0) remainingNew-- continue } remainingOld = 0 remainingNew = 0 continue } const oldHeader = /^---\s+(.+)$/.exec(trimmed) const newHeader = /^\+\+\+\s+(.+)$/.exec(trimmed) if (oldHeader) { pendingOld = oldHeader[1].trim() continue } if (newHeader && pendingOld !== undefined) { const oldPath = stripPrefix(cleanHeaderPath(pendingOld)) const newPath = stripPrefix(cleanHeaderPath(newHeader[1].trim())) if (oldPath === '/dev/null' && newPath !== '/dev/null') add('add', newPath) else if (newPath === '/dev/null' && oldPath !== '/dev/null') add('delete', oldPath) else if (oldPath !== '/dev/null' && newPath !== '/dev/null') { if (oldPath === newPath) add('update', newPath) else add('move', newPath, oldPath) } pendingOld = undefined } } return targets } function patchTextFromArgs(tool: BridgeTool, args: Record): string | undefined { const schema = tool.inputSchema as { properties?: Record } const properties = schema.properties ?? {} for (const key of Object.keys(properties)) { if (!/patch|diff|input|text/i.test(key)) continue const value = args[key] if (typeof value === 'string') return value } return undefined } function firstPathArg(tool: BridgeTool, args: Record): string | undefined { const schema = tool.inputSchema as { properties?: Record } const properties = schema.properties ?? {} for (const key of Object.keys(properties)) { if (!PATH_KEYS.has(key)) continue const value = args[key] if (typeof value === 'string') return value if (Array.isArray(value)) { const first = value.find(item => typeof item === 'string') if (first !== undefined) return first as string } } return undefined } function pathArgValues(tool: BridgeTool, args: Record): string[] { const schema = tool.inputSchema as { properties?: Record } const properties = schema.properties ?? {} const values: string[] = [] for (const key of Object.keys(properties)) { if (!PATH_KEYS.has(key)) continue const value = args[key] if (typeof value === 'string') values.push(value) else if (Array.isArray(value)) for (const item of value) if (typeof item === 'string') values.push(item) } return values } export function dedupe(values: string[]): string[] { return [...new Set(values)] } export function buildMutationPlan(tool: BridgeTool, args: Record, projectRoot: string): MutationPlan { const preOpen: string[] = [] const postOpen: string[] = [] switch (tool.remoteName) { case 'apply_patch': { const patch = patchTextFromArgs(tool, args) if (patch === undefined) throw new Error('apply_patch requires patch text') const targets = parsePatchTargets(patch) if (targets.length === 0) throw new Error('apply_patch patch contains no affected files') for (const target of targets) { if (target.kind === 'move' && target.source) { preOpen.push(normalizeProjectPath(projectRoot, target.source, true).path) const dest = normalizeProjectPath(projectRoot, target.destination, true).path postOpen.push(dest) continue } if (target.kind === 'add') { postOpen.push(normalizeProjectPath(projectRoot, target.destination, true).path) continue } preOpen.push(normalizeProjectPath(projectRoot, target.destination, true).path) } break } case 'rename_refactoring': { const source = firstPathArg(tool, args) if (source === undefined) throw new Error('rename_refactoring requires a recognized path argument') preOpen.push(normalizeProjectPath(projectRoot, source, true).path) break } case 'reformat_file': { const values = pathArgValues(tool, args) if (values.length === 0) throw new Error('reformat_file requires recognized file arguments') for (const value of values) preOpen.push(normalizeProjectPath(projectRoot, value, true).path) break } case 'create_new_file': { const target = firstPathArg(tool, args) if (target === undefined) throw new Error('create_new_file requires a recognized path argument') postOpen.push(normalizeProjectPath(projectRoot, target, true).path) break } default: break } return { preOpen: dedupe(preOpen), mutationArgs: args, postOpen: dedupe(postOpen) } } export function renderIdeCodingGuidance( mode: IdeCodingMode, state: IdeCodingState, capabilities: IdeCapabilityMap, projectRoot: string | undefined ): string { if (mode === 'off' || state === 'disabled' || state === 'shutdown') return '' const name = (key: IdeCapabilityKey): string | undefined => capabilities.get(key) const parts: string[] = [] const header = `[pi-acp IntelliJ-first mode: ${state}]` switch (state) { case 'active': { parts.push('IntelliJ-first mode is active.') parts.push('Pi generates the code; IntelliJ applies and validates it.') parts.push('Native file tools (read, edit, write, grep, find, ls) are unavailable.') parts.push( 'Direct Fabric/Schema file mutations (schema.commit, pi.write, pi.edit) are blocked; route mutations through the IDE tools.' ) parts.push('Post-turn, files changed outside IntelliJ are reported as mutation-provenance violations.') const lines: string[] = [] const read = name('read') const open = name('open') const search = name('search') const patch = name('patch') const create = name('create') const inspect = name('inspect') const rename = name('rename') const reformat = name('reformat') if (read) lines.push(`read files with ${read}`) if (open) lines.push(`open files in the IDE with ${open}`) if (search) lines.push(`search the project with ${search}`) if (patch) lines.push(`apply patches with ${patch}`) if (create) lines.push(`create files with ${create}`) if (inspect) lines.push(`run IDE diagnostics with ${inspect}`) if (rename) lines.push(`use semantic rename via ${rename} instead of textual edits`) if (reformat) lines.push(`reformat via ${reformat}`) if (lines.length > 0) parts.push(`Use the registered IDE tools: ${lines.join('; ')}.`) parts.push('Mutations automatically open affected files in the IDE.') if (projectRoot) parts.push(`Paths are relative to the project root: ${projectRoot}`) parts.push('Use bash for Git, tests, builds, and diagnostics. Do not use bash to modify source files.') break } case 'awaiting_catalog': { if (mode === 'required') parts.push('IntelliJ-first mode is required and waiting for the IDE catalog. Native file tools stay disabled.') else parts.push( 'IntelliJ-first mode is preferred but the IDE catalog is not ready yet. Native file tools remain available temporarily.' ) break } case 'native_fallback': { parts.push( 'IDE IPC is unavailable or incomplete. IDE tools were removed from the active set; only native tools removed by this policy were restored.' ) parts.push('Do not call stale IDE tool names.') break } case 'required_unavailable': { parts.push( 'Required IntelliJ capabilities are unavailable. Native filesystem tools remain disabled. The adapter retries the IDE connection with backoff and the session recovers automatically once the bridge returns; until then, prefer waiting over starting unrelated work.' ) break } default: return '' } return `${header}\n${parts.join('\n')}` }