diff --git a/.claude/settings.json b/.claude/settings.json index 29a2edf67..ddbdce134 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -17,7 +17,7 @@ "type": "command" } ], - "matcher": "Edit|Write|MultiEdit|Bash" + "matcher": "Edit|Write|MultiEdit|Bash|Skill" } ], "PostToolUseFailure": [ diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 46ef89fd4..7fd0d796a 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -6,6 +6,20 @@ "timeout": 30 } ], + "postToolUse": [ + { + "command": "chainloop trace hook cursor post-tool-use", + "matcher": "Shell", + "timeout": 30 + } + ], + "preToolUse": [ + { + "command": "chainloop trace hook cursor pre-tool-use", + "matcher": "Shell", + "timeout": 30 + } + ], "sessionEnd": [ { "command": "chainloop trace hook cursor session-end", diff --git a/.opencode/plugins/chainloop-trace.ts b/.opencode/plugins/chainloop-trace.ts index f23a9d010..71f2a458e 100644 --- a/.opencode/plugins/chainloop-trace.ts +++ b/.opencode/plugins/chainloop-trace.ts @@ -2,6 +2,22 @@ import { spawn } from "node:child_process" const fileWritingTools = ["edit","write","apply_patch","patch"] const commandTools = ["bash","shell"] +const skillTool = "skill" + +// skillDirFrom returns the folder of the skill that a skill tool call loaded: +// the dir field of the result metadata, or else the base directory line of +// the result text. Each OpenCode major gives the result in its own shape, so +// each candidate is tried in turn. +function skillDirFrom(...results: any[]): string { + for (const r of results) { + const dir = r?.metadata?.dir + if (typeof dir === "string" && dir) return dir + const text = typeof r === "string" ? r : typeof r?.output === "string" ? r.output : "" + const m = /^Base directory for this skill:\s*(.+)$/m.exec(text) + if (m) return m[1].trim() + } + return "" +} function filePathsFromArgs(args: any): string[] { if (args?.filePath) return [args.filePath] @@ -117,12 +133,19 @@ async function sessionEvent(directory: string, type: string, sessionID: string, } } -async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any) { +async function toolEvent(directory: string, hook: string, hookEventName: string, sessionID: string, tool: string, callID: string, args: any, skillDir = "") { const payload = { session_id: sessionID, hook_event_name: hookEventName, tool_name: tool } + if (tool === skillTool) { + // The skill is loaded after the call, and the result names its folder. + // The hook copies the folder for the evidence. + if (skillDir) await fire(directory, hook, { ...payload, skill_dir: skillDir }) + return + } if (commandTools.includes(tool)) { // The call ID pairs this hook with the other hook of the same call, so - // overlapping commands keep their own snapshots. - await fire(directory, hook, { ...payload, tool_use_id: callID }) + // overlapping commands keep their own snapshots. The command line names + // the checkouts that the command works in. + await fire(directory, hook, { ...payload, tool_use_id: callID, command: args?.command ?? "" }) return } if (!fileWritingTools.includes(tool)) return @@ -172,8 +195,8 @@ async function server({ directory, client }: any) { "tool.execute.before": async (input: any, output: any) => { await toolEvent(directory, "pre-tool-use", "tool.execute.before", input.sessionID, input.tool, input.callID, output.args) }, - "tool.execute.after": async (input: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args) + "tool.execute.after": async (input: any, output: any) => { + await toolEvent(directory, "post-tool-use", "tool.execute.after", input.sessionID, input.tool, input.callID, input.args, input.tool === skillTool ? skillDirFrom(output) : "") }, } } @@ -187,7 +210,7 @@ async function setup(ctx: any) { await toolEvent(directory, "pre-tool-use", "tool.execute.before", event.sessionID, event.tool, event.id, event.input) }) await ctx.tool.hook("execute.after", async (event: any) => { - await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input) + await toolEvent(directory, "post-tool-use", "tool.execute.after", event.sessionID, event.tool, event.id, event.input, event.tool === skillTool ? skillDirFrom(event.output, event.result, event) : "") }) // resume: false stores the message without asking the model for an answer.