import type { FileChange } from "./types.js" const DIRECT_MUTATION_TOOL_NAMES = ["write", "edit", "multiedit"] as const const PATCH_MUTATION_TOOL_NAMES = ["patch", "apply_patch"] as const const BASH_TOOL_NAME = "bash" as const export const MUTATION_TOOL_NAMES = new Set([...DIRECT_MUTATION_TOOL_NAMES, ...PATCH_MUTATION_TOOL_NAMES, BASH_TOOL_NAME]) export type MutationToolName = (typeof DIRECT_MUTATION_TOOL_NAMES)[number] | (typeof PATCH_MUTATION_TOOL_NAMES)[number] | typeof BASH_TOOL_NAME export type NormalizedMutationToolName = "write" | "edit" | "multiedit" | "apply_patch" | "bash" export function normalizeMutationToolName(toolName: string): NormalizedMutationToolName | undefined { if ((DIRECT_MUTATION_TOOL_NAMES as readonly string[]).includes(toolName)) { return toolName as NormalizedMutationToolName } if ((PATCH_MUTATION_TOOL_NAMES as readonly string[]).includes(toolName)) { return "apply_patch" } if (toolName === BASH_TOOL_NAME) { return "bash" } return undefined } export function getMutationToolHookNames(toolName: string): string[] { const normalized = normalizeMutationToolName(toolName) if (!normalized) { return [] } if (normalized === "apply_patch") { return ["patch", "apply_patch"] } if (normalized === "bash") { return ["bash"] } return [normalized] } export function getToolAffectedPaths(toolName: string, args: Record): string[] { return getChangedPaths(getToolFileChanges(toolName, args)) } export function getToolFileChanges(toolName: string, args: Record): FileChange[] { const normalized = normalizeMutationToolName(toolName) if (!normalized) { return [] } if (normalized === "apply_patch") { const patchText = pickString(args.patchText, args.patch, args.diff, args.input) return patchText ? parsePatchChanges(patchText) : [] } if (normalized === "bash") { const command = pickString(args.command, args.cmd) return command ? parseBashChanges(command) : [] } // Pi MultiEdit and OMP's multi-file edit results both expose `edits`. // Result-derived OMP entries carry authoritative op/source/move metadata, // while legacy Pi entries continue to fall back to a modify operation. if (normalized === "multiedit" || normalized === "edit") { const fromArray = extractEditsArrayChanges(args) if (fromArray.length > 0) { return fromArray } } const directChange = extractDirectMutationChange(args) if (directChange) { return [directChange] } // OMP 17's unified edit tool sends both hashline and apply-patch payloads // as `{ input: string }`. Successful ToolResult details normally provide // authoritative paths; parsing the payload keeps the mapping correct when // details are unavailable (for example in compatibility test harnesses). if (normalized === "edit") { const editInput = pickString(args.input) if (editInput) { return editInput.includes("*** Begin Patch") ? parsePatchChanges(editInput) : parseHashlineChanges(editInput) } } return [] } export function getChangedPaths(changes: readonly FileChange[]): string[] { const paths = new Set() for (const change of changes) { if (change.operation === "rename") { if (change.fromPath) { paths.add(change.fromPath) } if (change.toPath) { paths.add(change.toPath) } continue } if (change.path) { paths.add(change.path) } } return Array.from(paths) } function pickString(...values: unknown[]): string | undefined { const value = values.find((candidate) => typeof candidate === "string" && candidate.trim().length > 0) return typeof value === "string" ? value : undefined } function extractEditsArrayChanges(args: Record): FileChange[] { const editsRaw = args.edits if (!Array.isArray(editsRaw) || editsRaw.length === 0) { return [] } // Top-level path is the canonical target for Pi MultiEdit. Each edit may // override it; OMP result normalization always supplies per-entry paths. const topLevelPath = pickString(args.filePath, args.file_path, args.path, args.file) const changes: FileChange[] = [] for (const edit of editsRaw) { if (!edit || typeof edit !== "object") { continue } const change = extractDirectMutationChange(edit as Record, topLevelPath) if (change) { changes.push(change) } } return changes } function extractDirectMutationChange( args: Record, fallbackPath?: string, ): FileChange | undefined { const filePath = pickString(args.filePath, args.file_path, args.path, args.file) ?? fallbackPath if (!filePath) { return undefined } const sourcePath = pickString(args.sourcePath, args.fromPath) const destinationPath = pickString(args.move, args.rename, args.toPath) if (sourcePath && sourcePath !== filePath) { return { operation: "rename", fromPath: sourcePath, toPath: filePath } } if (destinationPath && destinationPath !== filePath) { return { operation: "rename", fromPath: filePath, toPath: destinationPath } } const operation = pickString(args.op, args.operation) if (operation === "create" || operation === "delete") { return { operation, path: filePath } } return { operation: "modify", path: filePath } } function parsePatchChanges(patchText: string): FileChange[] { const changes: FileChange[] = [] let pendingUpdatePath: string | undefined const flushPendingModify = (): void => { if (!pendingUpdatePath) { return } changes.push({ operation: "modify", path: pendingUpdatePath }) pendingUpdatePath = undefined } for (const line of patchText.split("\n")) { const addMatch = line.match(/^\*\*\* Add File: (.+)$/) if (addMatch?.[1]) { flushPendingModify() changes.push({ operation: "create", path: addMatch[1].trim() }) continue } const deleteMatch = line.match(/^\*\*\* Delete File: (.+)$/) if (deleteMatch?.[1]) { flushPendingModify() changes.push({ operation: "delete", path: deleteMatch[1].trim() }) continue } const updateMatch = line.match(/^\*\*\* Update File: (.+)$/) if (updateMatch?.[1]) { flushPendingModify() pendingUpdatePath = updateMatch[1].trim() continue } const renameMatch = line.match(/^\*\*\* Move to: (.+)$/) if (renameMatch?.[1] && pendingUpdatePath) { changes.push({ operation: "rename", fromPath: pendingUpdatePath, toPath: renameMatch[1].trim() }) pendingUpdatePath = undefined } } flushPendingModify() return changes } function parseHashlineChanges(patchText: string): FileChange[] { const changes: FileChange[] = [] let currentPath: string | undefined let currentChange: FileChange | undefined const flushCurrent = (): void => { if (currentChange) { changes.push(currentChange) } else if (currentPath) { changes.push({ operation: "modify", path: currentPath }) } currentPath = undefined currentChange = undefined } for (const line of patchText.split("\n")) { const headerMatch = line.match(/^\[([^#\]\r\n]+)#[0-9A-Fa-f]{4}\]$/) if (headerMatch?.[1]) { flushCurrent() currentPath = headerMatch[1] continue } if (!currentPath) { continue } if (line === "REM") { currentChange = { operation: "delete", path: currentPath } continue } const moveMatch = line.match(/^MV\s+(.+)$/) if (moveMatch?.[1]) { currentChange = { operation: "rename", fromPath: currentPath, toPath: moveMatch[1].trim() } } } flushCurrent() return changes } function parseBashChanges(command: string): FileChange[] { const changes: FileChange[] = [] for (const segment of splitBashCommands(command)) { const tokens = shellTokenize(segment) if (tokens.length === 0) { continue } const cmd = tokens[0] // P1-17: parenthesise the `&&` / `||` properly. The previous code read // `cmd === "rm" || cmd === "git" && tokens[1] === "rm"`, which JS // evaluates as `cmd === "rm" || (cmd === "git" && tokens[1] === "rm")` // — coincidentally correct for `rm`, but the same shape was applied to // `mv`/`cp`, where the right-side test must also disqualify a bare `mv` // when a `git`-prefix is expected. Make the grouping explicit. if (cmd === "rm" || (cmd === "git" && tokens[1] === "rm")) { const paths = extractPathArgs(tokens, cmd === "git" ? 2 : 1) for (const p of paths) { changes.push({ operation: "delete", path: p }) } continue } if (cmd === "mv" || (cmd === "git" && tokens[1] === "mv")) { const paths = extractPathArgs(tokens, cmd === "git" ? 2 : 1) if (paths.length >= 2) { const dest = paths[paths.length - 1] for (const src of paths.slice(0, -1)) { changes.push({ operation: "rename", fromPath: src, toPath: dest }) } } continue } if (cmd === "cp" || (cmd === "git" && tokens[1] === "cp")) { const paths = extractPathArgs(tokens, cmd === "git" ? 2 : 1) if (paths.length >= 2) { changes.push({ operation: "create", path: paths[paths.length - 1] }) } continue } if (cmd === "touch") { const paths = extractPathArgs(tokens, 1) for (const p of paths) { changes.push({ operation: "create", path: p }) } continue } if (cmd === "mkdir") { const paths = extractPathArgs(tokens, 1) for (const p of paths) { changes.push({ operation: "create", path: p }) } continue } } return changes } function splitBashCommands(command: string): string[] { return command.split(/\s*(?:&&|\|\||;)\s*/) } function shellTokenize(segment: string): string[] { const tokens: string[] = [] let current = "" let inSingle = false let inDouble = false let escape = false for (const ch of segment) { if (escape) { current += ch escape = false continue } if (ch === "\\" && !inSingle) { escape = true continue } if (ch === "'" && !inDouble) { inSingle = !inSingle continue } if (ch === '"' && !inSingle) { inDouble = !inDouble continue } if (/\s/.test(ch) && !inSingle && !inDouble) { if (current.length > 0) { tokens.push(current) current = "" } continue } current += ch } if (current.length > 0) { tokens.push(current) } return tokens } /** * Extract positional path arguments from a tokenised shell segment, skipping * flags. POSIX `--` is honoured: every token after `--` is treated as a * positional argument even if it begins with `-` (so `rm -- --bad-name` * extracts `--bad-name` as a path). */ function extractPathArgs(tokens: string[], startIndex: number): string[] { const paths: string[] = [] let positionalOnly = false for (let i = startIndex; i < tokens.length; i++) { const token = tokens[i] if (positionalOnly) { paths.push(token) continue } if (token === "--") { // P1-17: a bare `--` flips the parser into positional-only mode for // the remainder of the segment. The previous loop tried to do the // same via `startIndex = i + 1` + `continue`, but the outer `for` // header keeps using its captured `startIndex`, so the reassignment // had no effect and the next `-`-prefixed token was still treated as // a flag. positionalOnly = true continue } if (token.startsWith("-")) { continue } paths.push(token) } return paths }