diff --git a/internal/capture/claudecode.go b/internal/capture/claudecode.go index 02c68a7..50ca6e2 100644 --- a/internal/capture/claudecode.go +++ b/internal/capture/claudecode.go @@ -1,11 +1,16 @@ // Package capture turns agent hook payloads into normalized store.Events. // Adapters are tier 2 of the capture model (ADR D4): best-effort enhancement. -// Every parser here must be total — malformed input returns (nil, "", nil), +// Every parser here must be total — malformed input returns (nil, "", ""), // never an error that could break the agent's hook chain. +// +// Each adapter's contract (event names, field names, snake/camel variants, +// tool→op) lives in maps/.json and is read by the shared engine in +// mapping.go, so a vendor format change is a JSON edit, not a code change. +// Extraction that can't be a flat field map (e.g. Codex's apply_patch envelope) +// stays in code via structuralHandlers; see codex.go. package capture import ( - "encoding/json" "time" "github.com/provasign/shale/internal/store" @@ -14,81 +19,14 @@ import ( // AdapterVersion is stamped into shale files produced from these events. const AdapterVersion = "0.1.0" -// claudePayload covers the Claude Code hook stdin JSON surface we read. -// Recorded fixtures live in testdata/claude-code/; the hook API moves, so -// unknown fields are ignored and missing fields degrade gracefully. -type claudePayload struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - HookEventName string `json:"hook_event_name"` - Prompt string `json:"prompt"` - ToolName string `json:"tool_name"` - ToolInput json.RawMessage `json:"tool_input"` - ToolResponse json.RawMessage `json:"tool_response"` - Model string `json:"model"` -} - -type claudeToolInput struct { - FilePath string `json:"file_path"` - NotebookPath string `json:"notebook_path"` - Command string `json:"command"` -} - -type claudeToolResponse struct { - ExitCode *int `json:"exit_code"` -} - -// ParseClaudeCode normalizes one Claude Code hook payload. It returns the -// events to append, the session id, and the payload cwd (used to locate the -// repo root). A payload we cannot use yields zero events — never an error. +// ParseClaudeCode normalizes one Claude Code hook payload. Contract in +// maps/claude-code.json. A payload we cannot use yields zero events — never +// an error. Both Stop (per-turn) and SessionEnd (once at termination) map to +// session_end; finalize is idempotent, so duplicates are harmless. func ParseClaudeCode(raw []byte, now time.Time) (events []store.Event, sessionID, cwd string) { - var p claudePayload - if err := json.Unmarshal(raw, &p); err != nil { + m, ok := loadMapping("claude-code") + if !ok { return nil, "", "" } - sessionID, cwd = p.SessionID, p.CWD - at := now.UTC() - - switch p.HookEventName { - case "SessionStart": - events = append(events, store.Event{ - Kind: store.KindSessionMeta, At: at, - Tool: "claude-code", Model: p.Model, SessionID: p.SessionID, - }) - case "UserPromptSubmit": - if p.Prompt != "" { - // Prompts feed the transcript and prompt_count ONLY — never intent - // (spec rule 5). - events = append(events, store.Event{Kind: store.KindPrompt, At: at, Text: p.Prompt}) - } - case "PostToolUse": - var in claudeToolInput - _ = json.Unmarshal(p.ToolInput, &in) - switch p.ToolName { - case "Write": - if in.FilePath != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: in.FilePath, Op: "write"}) - } - case "Edit", "MultiEdit": - if in.FilePath != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: in.FilePath, Op: "edit"}) - } - case "NotebookEdit": - if in.NotebookPath != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: in.NotebookPath, Op: "edit"}) - } - case "Bash": - if in.Command != "" { - var resp claudeToolResponse - _ = json.Unmarshal(p.ToolResponse, &resp) - // Honest provenance: exit code only when the payload carries one. - events = append(events, store.Event{Kind: store.KindCommand, At: at, Cmd: in.Command, ExitCode: resp.ExitCode}) - } - } - case "Stop", "SessionEnd": - // Stop fires per turn, SessionEnd once at termination. Both mark - // last activity; finalize is idempotent, so duplicates are harmless. - events = append(events, store.Event{Kind: store.KindSessionEnd, At: at}) - } - return events, sessionID, cwd + return applyMapping(m, raw, now) } diff --git a/internal/capture/codex.go b/internal/capture/codex.go index 4d1c525..b27ac53 100644 --- a/internal/capture/codex.go +++ b/internal/capture/codex.go @@ -8,88 +8,38 @@ import ( "github.com/provasign/shale/internal/store" ) -// codexPayload covers the Codex CLI hook stdin JSON surface Shale reads. -// Fixtures under testdata/codex/ were shaped from Codex CLI 0.139.0 hook docs -// and local hook-reader behavior; unknown fields are ignored. -type codexPayload struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - HookEventName string `json:"hook_event_name"` - Prompt string `json:"prompt"` - ToolName string `json:"tool_name"` - ToolInput json.RawMessage `json:"tool_input"` - ToolResponse json.RawMessage `json:"tool_response"` - Model string `json:"model"` -} - -type codexToolInput struct { - FilePath string `json:"file_path"` - FilePathCamel string `json:"filePath"` - Path string `json:"path"` - NotebookPath string `json:"notebook_path"` - Command string `json:"command"` - Patch string `json:"patch"` - Input string `json:"input"` - Content string `json:"content"` - Cmd string `json:"cmd"` - Text string `json:"text"` -} - -type codexToolResponse struct { - ExitCode *int `json:"exit_code"` +// Codex's field/event contract lives in maps/codex.json. The one piece that +// can't be a flat field map is apply_patch: edits surface as a patch envelope +// (`*** Add File:` / `*** Update File:` / `*** Delete File:` lines), and the +// tool name varies ("apply_patch", "functions.apply_patch", …). That stays in +// code here, wired in as the map's tool_fallback structural handler. +func init() { + structuralHandlers["codex_apply_patch"] = func(toolName string, input json.RawMessage, at time.Time) []store.Event { + if !isApplyPatchTool(toolName) { + return nil + } + return codexPatchTouches(codexPatchText(input), at) + } } -// ParseCodex normalizes one Codex hook payload. It returns zero events for -// unknown or incomplete payloads, preserving hook fail-open behavior. +// ParseCodex normalizes one Codex hook payload. Contract in maps/codex.json; +// zero events for unknown or incomplete payloads, preserving fail-open. func ParseCodex(raw []byte, now time.Time) (events []store.Event, sessionID, cwd string) { - var p codexPayload - if err := json.Unmarshal(raw, &p); err != nil { + m, ok := loadMapping("codex") + if !ok { return nil, "", "" } - sessionID, cwd = p.SessionID, p.CWD - at := now.UTC() + return applyMapping(m, raw, now) +} - switch p.HookEventName { - case "SessionStart": - events = append(events, store.Event{ - Kind: store.KindSessionMeta, At: at, - Tool: "codex", Model: p.Model, SessionID: p.SessionID, - }) - case "UserPromptSubmit": - if p.Prompt != "" { - events = append(events, store.Event{Kind: store.KindPrompt, At: at, Text: p.Prompt}) - } - case "PostToolUse": - var in codexToolInput - _ = json.Unmarshal(p.ToolInput, &in) - switch p.ToolName { - case "Write": - if in.FilePath != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: in.FilePath, Op: "write"}) - } - case "Edit", "MultiEdit": - if in.FilePath != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: in.FilePath, Op: "edit"}) - } - case "NotebookEdit": - if in.NotebookPath != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: in.NotebookPath, Op: "edit"}) - } - case "Bash": - if in.Command != "" { - var resp codexToolResponse - _ = json.Unmarshal(p.ToolResponse, &resp) - events = append(events, store.Event{Kind: store.KindCommand, At: at, Cmd: in.Command, ExitCode: resp.ExitCode}) - } - default: - if isApplyPatchTool(p.ToolName) { - events = append(events, codexPatchTouches(codexPatchText(p.ToolInput), at)...) - } - } - case "Stop": - events = append(events, store.Event{Kind: store.KindSessionEnd, At: at}) - } - return events, sessionID, cwd +// codexPatchInput carries the fields a patch envelope may arrive under. +type codexPatchInput struct { + Patch string `json:"patch"` + Input string `json:"input"` + Content string `json:"content"` + Cmd string `json:"cmd"` + Command string `json:"command"` + Text string `json:"text"` } func isApplyPatchTool(name string) bool { @@ -104,7 +54,7 @@ func codexPatchText(raw json.RawMessage) string { if err := json.Unmarshal(raw, &text); err == nil { return text } - var in codexToolInput + var in codexPatchInput _ = json.Unmarshal(raw, &in) return firstNonEmpty(in.Patch, in.Input, in.Content, in.Cmd, in.Command, in.Text) } diff --git a/internal/capture/copilot.go b/internal/capture/copilot.go index 46550dc..25ffacd 100644 --- a/internal/capture/copilot.go +++ b/internal/capture/copilot.go @@ -1,80 +1,19 @@ package capture import ( - "encoding/json" "time" "github.com/provasign/shale/internal/store" ) -type copilotPayload struct { - SessionID string `json:"session_id"` - SessionIDCamel string `json:"sessionId"` - ThreadID string `json:"thread_id"` - ThreadIDCamel string `json:"threadId"` - ConversationID string `json:"conversation_id"` - ConversationIDCamel string `json:"conversationId"` - CWD string `json:"cwd"` - WorkspaceRoot string `json:"workspaceRoot"` - WorkspaceRootSnake string `json:"workspace_root"` - HookEventName string `json:"hook_event_name"` - HookEventNameCamel string `json:"hookEventName"` - Prompt string `json:"prompt"` - Text string `json:"text"` - ToolName string `json:"tool_name"` - ToolNameCamel string `json:"toolName"` - ToolInput json.RawMessage `json:"tool_input"` - ToolInputCamel json.RawMessage `json:"toolInput"` - ToolInfo json.RawMessage `json:"tool_info"` - ToolInfoCamel json.RawMessage `json:"toolInfo"` - ToolResponse json.RawMessage `json:"tool_response"` - ToolResponseCamel json.RawMessage `json:"toolResponse"` - Model string `json:"model"` -} - -// ParseCopilot normalizes one Copilot hook payload. The repo-level Copilot -// hook config uses camelCase event names; this parser also accepts snake_case -// variants because VS Code / CLI surfaces may drift independently. +// ParseCopilot normalizes one Copilot hook payload. Contract in +// maps/copilot.json — the repo-level Copilot hook config uses camelCase event +// names, and the map also lists snake_case variants because VS Code / CLI +// surfaces may drift independently. func ParseCopilot(raw []byte, now time.Time) (events []store.Event, sessionID, cwd string) { - var p copilotPayload - if err := json.Unmarshal(raw, &p); err != nil { + m, ok := loadMapping("copilot") + if !ok { return nil, "", "" } - sessionID = firstNonEmpty(p.SessionID, p.SessionIDCamel, p.ThreadID, p.ThreadIDCamel, p.ConversationID, p.ConversationIDCamel) - cwd = firstNonEmpty(p.CWD, p.WorkspaceRoot, p.WorkspaceRootSnake) - event := firstNonEmpty(p.HookEventName, p.HookEventNameCamel) - at := now.UTC() - - switch event { - case "sessionStart": - events = append(events, store.Event{ - Kind: store.KindSessionMeta, At: at, - Tool: "copilot", Model: p.Model, SessionID: sessionID, - }) - case "userPromptSubmitted": - if prompt := firstNonEmpty(p.Prompt, p.Text); prompt != "" { - events = append(events, store.Event{Kind: store.KindPrompt, At: at, Text: prompt}) - } - case "postToolUse": - tool := firstNonEmpty(p.ToolName, p.ToolNameCamel) - input := firstRaw(p.ToolInput, p.ToolInputCamel, p.ToolInfo, p.ToolInfoCamel) - switch tool { - case "Write": - if path := cursorPath(input); path != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: path, Op: "write"}) - } - case "Edit", "MultiEdit": - if path := cursorPath(input); path != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: path, Op: "edit"}) - } - case "Bash", "Terminal", "Shell": - if cmd := cursorCommand(input); cmd != "" { - resp := firstRaw(p.ToolResponse, p.ToolResponseCamel) - events = append(events, store.Event{Kind: store.KindCommand, At: at, Cmd: cmd, ExitCode: cursorExitCode(resp)}) - } - } - case "sessionEnd": - events = append(events, store.Event{Kind: store.KindSessionEnd, At: at}) - } - return events, sessionID, cwd + return applyMapping(m, raw, now) } diff --git a/internal/capture/cursor.go b/internal/capture/cursor.go index 65ffaf3..65a8d67 100644 --- a/internal/capture/cursor.go +++ b/internal/capture/cursor.go @@ -1,130 +1,19 @@ package capture import ( - "encoding/json" "time" "github.com/provasign/shale/internal/store" ) -type cursorPayload struct { - SessionID string `json:"session_id"` - SessionIDCamel string `json:"sessionId"` - CWD string `json:"cwd"` - WorkspaceRoot string `json:"workspaceRoot"` - HookEventName string `json:"hook_event_name"` - HookEventNameCamel string `json:"hookEventName"` - Prompt string `json:"prompt"` - Text string `json:"text"` - ToolName string `json:"tool_name"` - ToolNameCamel string `json:"toolName"` - ToolInput json.RawMessage `json:"tool_input"` - ToolInputCamel json.RawMessage `json:"toolInput"` - ToolInfo json.RawMessage `json:"tool_info"` - ToolInfoCamel json.RawMessage `json:"toolInfo"` - ToolResponse json.RawMessage `json:"tool_response"` - ToolResponseCamel json.RawMessage `json:"toolResponse"` - Model string `json:"model"` -} - -type cursorObject struct { - FilePath string `json:"file_path"` - FilePathCamel string `json:"filePath"` - Path string `json:"path"` - NotebookPath string `json:"notebook_path"` - Command string `json:"command"` - ExitCode *int `json:"exit_code"` - ExitCodeCamel *int `json:"exitCode"` -} - -// ParseCursor normalizes one Cursor hook payload. Cursor hook payloads have -// changed over time, so this parser accepts both snake_case and camelCase -// variants and drops anything it cannot prove. +// ParseCursor normalizes one Cursor hook payload. The whole contract — +// event names, field names, snake/camel variants — lives in maps/cursor.json, +// so adapting to a Cursor format change is a JSON edit, not a code change. +// See mapping.go for the engine. func ParseCursor(raw []byte, now time.Time) (events []store.Event, sessionID, cwd string) { - var p cursorPayload - if err := json.Unmarshal(raw, &p); err != nil { + m, ok := loadMapping("cursor") + if !ok { return nil, "", "" } - sessionID = firstNonEmpty(p.SessionID, p.SessionIDCamel) - cwd = firstNonEmpty(p.CWD, p.WorkspaceRoot) - event := firstNonEmpty(p.HookEventName, p.HookEventNameCamel) - at := now.UTC() - - switch event { - case "sessionStart": - events = append(events, store.Event{ - Kind: store.KindSessionMeta, At: at, - Tool: "cursor", Model: p.Model, SessionID: sessionID, - }) - case "beforeSubmitPrompt": - if prompt := firstNonEmpty(p.Prompt, p.Text); prompt != "" { - events = append(events, store.Event{Kind: store.KindPrompt, At: at, Text: prompt}) - } - case "afterFileEdit": - if path := cursorPath(firstRaw(p.ToolInput, p.ToolInputCamel, p.ToolInfo, p.ToolInfoCamel, raw)); path != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: path, Op: "edit"}) - } - case "postToolUse": - tool := firstNonEmpty(p.ToolName, p.ToolNameCamel) - input := firstRaw(p.ToolInput, p.ToolInputCamel, p.ToolInfo, p.ToolInfoCamel) - switch tool { - case "Write": - if path := cursorPath(input); path != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: path, Op: "write"}) - } - case "Edit", "MultiEdit": - if path := cursorPath(input); path != "" { - events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: path, Op: "edit"}) - } - case "Bash", "Terminal", "Shell": - if cmd := cursorCommand(input); cmd != "" { - resp := firstRaw(p.ToolResponse, p.ToolResponseCamel) - events = append(events, store.Event{Kind: store.KindCommand, At: at, Cmd: cmd, ExitCode: cursorExitCode(resp)}) - } - } - case "sessionEnd": - events = append(events, store.Event{Kind: store.KindSessionEnd, At: at}) - } - return events, sessionID, cwd -} - -func firstNonEmpty(vs ...string) string { - for _, v := range vs { - if v != "" { - return v - } - } - return "" -} - -func firstRaw(vs ...json.RawMessage) json.RawMessage { - for _, v := range vs { - if len(v) > 0 && string(v) != "null" { - return v - } - } - return nil -} - -func cursorObjectFrom(raw json.RawMessage) cursorObject { - var obj cursorObject - _ = json.Unmarshal(raw, &obj) - return obj -} - -func cursorPath(raw json.RawMessage) string { - obj := cursorObjectFrom(raw) - return firstNonEmpty(obj.FilePath, obj.FilePathCamel, obj.Path, obj.NotebookPath) -} - -func cursorCommand(raw json.RawMessage) string { - return cursorObjectFrom(raw).Command -} - -func cursorExitCode(raw json.RawMessage) *int { - obj := cursorObjectFrom(raw) - if obj.ExitCode != nil { - return obj.ExitCode - } - return obj.ExitCodeCamel + return applyMapping(m, raw, now) } diff --git a/internal/capture/helpers.go b/internal/capture/helpers.go new file mode 100644 index 0000000..af73565 --- /dev/null +++ b/internal/capture/helpers.go @@ -0,0 +1,25 @@ +package capture + +import "encoding/json" + +// firstNonEmpty returns the first non-empty string. Used by the codex +// apply_patch handler (codex.go). +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if v != "" { + return v + } + } + return "" +} + +// firstRaw returns the first present, non-null raw message. Used by the +// mapping engine (mapping.go). +func firstRaw(vs ...json.RawMessage) json.RawMessage { + for _, v := range vs { + if len(v) > 0 && string(v) != "null" { + return v + } + } + return nil +} diff --git a/internal/capture/mapping.go b/internal/capture/mapping.go new file mode 100644 index 0000000..bdb9a4d --- /dev/null +++ b/internal/capture/mapping.go @@ -0,0 +1,237 @@ +package capture + +import ( + "embed" + "encoding/json" + "sync" + "time" + + "github.com/provasign/shale/internal/store" +) + +// A mapping describes how to read one agent's hook payload as data, so that a +// vendor renaming a field or an event is a JSON edit in maps/.json +// rather than a Go change. The mapping covers the common drift — renamed +// events, renamed fields, new tool names. Genuinely structural extraction +// (e.g. Codex's apply_patch envelope) stays in code and is referenced from a +// tool entry as {"action":"structural","handler":""}; see +// structuralHandlers. +// +// Maps are embedded so a fresh install works offline with no setup. A future +// `shale update-map` can overlay newer files on top of these defaults; the +// loader below is the single seam that would consult the overlay first. + +//go:embed maps/*.json +var mapFS embed.FS + +var ( + mapsMu sync.Mutex + loadedMaps = map[string]*Mapping{} +) + +// fieldMap lists, for each semantic slot, the payload keys to try in order. +// First non-empty wins, so snake_case/camelCase variants are just more entries. +type fieldMap struct { + SessionID []string `json:"session_id"` + CWD []string `json:"cwd"` + EventName []string `json:"event_name"` + Prompt []string `json:"prompt"` + Model []string `json:"model"` + ToolName []string `json:"tool_name"` + ToolInput []string `json:"tool_input"` + ToolResponse []string `json:"tool_response"` +} + +// inputMap lists the keys to look for inside a tool's input/response objects. +type inputMap struct { + FilePath []string `json:"file_path"` + Command []string `json:"command"` + ExitCode []string `json:"exit_code"` +} + +// actionSpec is what to do for one event name or one tool name. +type actionSpec struct { + Action string `json:"action"` // session_meta|prompt|file_touch|command|session_end|tool_use|structural + Op string `json:"op,omitempty"` // write|edit|delete, for file_touch + Handler string `json:"handler,omitempty"` // structuralHandlers key, for action=structural +} + +// Mapping is one agent's full data-driven contract. +type Mapping struct { + Tool string `json:"tool"` + Locate fieldMap `json:"locate"` + Input inputMap `json:"input"` + Events map[string]actionSpec `json:"events"` + Tools map[string]actionSpec `json:"tools"` + // ToolFallback applies when a tool_use event names a tool not in Tools. + // Used for structural matchers whose tool name varies (e.g. Codex's + // apply_patch surfaces as "apply_patch", "functions.apply_patch", …); the + // handler inspects the tool name and self-guards. + ToolFallback *actionSpec `json:"tool_fallback,omitempty"` +} + +// structuralHandlers holds the few extractors that can't be expressed as a +// flat field map. Adapters register theirs in an init(). The handler receives +// the tool name so it can self-guard (return nil for tools it doesn't own). +var structuralHandlers = map[string]func(toolName string, input json.RawMessage, at time.Time) []store.Event{} + +// loadMapping returns the embedded mapping for a tool, parsed once and cached. +func loadMapping(tool string) (*Mapping, bool) { + mapsMu.Lock() + defer mapsMu.Unlock() + if m, ok := loadedMaps[tool]; ok { + return m, true + } + raw, err := mapFS.ReadFile("maps/" + tool + ".json") + if err != nil { + return nil, false + } + var m Mapping + if json.Unmarshal(raw, &m) != nil { + return nil, false + } + loadedMaps[tool] = &m + return &m, true +} + +// applyMapping is the engine: parse a payload by the mapping and emit events. +// Total and fail-open — any shape it cannot read yields zero events. +func applyMapping(m *Mapping, raw []byte, now time.Time) (events []store.Event, sessionID, cwd string) { + var top map[string]json.RawMessage + if json.Unmarshal(raw, &top) != nil { + return nil, "", "" + } + sessionID = objString(top, m.Locate.SessionID) + cwd = objString(top, m.Locate.CWD) + event := objString(top, m.Locate.EventName) + spec, ok := m.Events[event] + if !ok { + return nil, sessionID, cwd + } + at := now.UTC() + + switch spec.Action { + case "session_meta": + events = append(events, store.Event{ + Kind: store.KindSessionMeta, At: at, + Tool: m.Tool, Model: objString(top, m.Locate.Model), SessionID: sessionID, + }) + case "prompt": + if text := objString(top, m.Locate.Prompt); text != "" { + events = append(events, store.Event{Kind: store.KindPrompt, At: at, Text: text}) + } + case "file_touch": + if path := resolvePath(top, m); path != "" { + events = append(events, store.Event{Kind: store.KindFileTouch, At: at, Path: path, Op: spec.Op}) + } + case "session_end": + events = append(events, store.Event{Kind: store.KindSessionEnd, At: at}) + case "tool_use": + events = append(events, applyTool(top, m, at)...) + } + return events, sessionID, cwd +} + +// applyTool handles an event whose meaning depends on which tool ran. +func applyTool(top map[string]json.RawMessage, m *Mapping, at time.Time) []store.Event { + name := objString(top, m.Locate.ToolName) + tspec, ok := m.Tools[name] + if !ok { + if m.ToolFallback == nil { + return nil + } + tspec = *m.ToolFallback + } + switch tspec.Action { + case "file_touch": + if path := resolvePath(top, m); path != "" { + return []store.Event{{Kind: store.KindFileTouch, At: at, Path: path, Op: tspec.Op}} + } + case "command": + if cmd := inputString(top, m, m.Input.Command); cmd != "" { + return []store.Event{{Kind: store.KindCommand, At: at, Cmd: cmd, ExitCode: inputExit(top, m)}} + } + case "structural": + if h := structuralHandlers[tspec.Handler]; h != nil { + return h(name, firstRaw(rawList(top, m.Locate.ToolInput)...), at) + } + } + return nil +} + +// resolvePath finds a file path in the tool input object, then the payload root. +func resolvePath(top map[string]json.RawMessage, m *Mapping) string { + for _, obj := range append(inputObjects(top, m), top) { + if p := objString(obj, m.Input.FilePath); p != "" { + return p + } + } + return "" +} + +// inputString finds a string field inside the tool input object. +func inputString(top map[string]json.RawMessage, m *Mapping, keys []string) string { + for _, obj := range inputObjects(top, m) { + if v := objString(obj, keys); v != "" { + return v + } + } + return "" +} + +// inputExit finds an exit code in the tool response object. +func inputExit(top map[string]json.RawMessage, m *Mapping) *int { + resp := firstRaw(rawList(top, m.Locate.ToolResponse)...) + if resp == nil { + return nil + } + obj := asObj(resp) + for _, k := range m.Input.ExitCode { + if r, ok := obj[k]; ok { + var n int + if json.Unmarshal(r, &n) == nil { + return &n + } + } + } + return nil +} + +// inputObjects returns the candidate tool-input objects named by the mapping. +func inputObjects(top map[string]json.RawMessage, m *Mapping) []map[string]json.RawMessage { + var objs []map[string]json.RawMessage + if ti := firstRaw(rawList(top, m.Locate.ToolInput)...); ti != nil { + objs = append(objs, asObj(ti)) + } + return objs +} + +// objString returns the first non-empty string value among keys. +func objString(obj map[string]json.RawMessage, keys []string) string { + for _, k := range keys { + if r, ok := obj[k]; ok { + var s string + if json.Unmarshal(r, &s) == nil && s != "" { + return s + } + } + } + return "" +} + +// rawList returns the raw values present for keys, in order. +func rawList(obj map[string]json.RawMessage, keys []string) []json.RawMessage { + var out []json.RawMessage + for _, k := range keys { + if r, ok := obj[k]; ok { + out = append(out, r) + } + } + return out +} + +func asObj(raw json.RawMessage) map[string]json.RawMessage { + var obj map[string]json.RawMessage + _ = json.Unmarshal(raw, &obj) + return obj +} diff --git a/internal/capture/mapping_test.go b/internal/capture/mapping_test.go new file mode 100644 index 0000000..5598765 --- /dev/null +++ b/internal/capture/mapping_test.go @@ -0,0 +1,33 @@ +package capture + +import "testing" + +// TestEmbeddedMapsValid guards against shipping a malformed or empty mapping: +// a bad map would make loadMapping fail and the adapter silently no-op, which +// is hard to notice. Every adapter the dispatch routes to must have a loadable +// map with at least a tool name, events, and tools. +func TestEmbeddedMapsValid(t *testing.T) { + for _, tool := range []string{"claude-code", "codex", "cursor", "copilot"} { + m, ok := loadMapping(tool) + if !ok { + t.Fatalf("map %q failed to load", tool) + } + if m.Tool != tool { + t.Errorf("map %q has tool field %q", tool, m.Tool) + } + if len(m.Events) == 0 { + t.Errorf("map %q has no events", tool) + } + if len(m.Tools) == 0 { + t.Errorf("map %q has no tools", tool) + } + } +} + +// TestUnknownMapMissing confirms loadMapping fails closed for an adapter with +// no embedded map (the dispatch already fails open on the same condition). +func TestUnknownMapMissing(t *testing.T) { + if _, ok := loadMapping("nonexistent"); ok { + t.Fatal("loadMapping returned ok for a map that does not exist") + } +} diff --git a/internal/capture/maps/claude-code.json b/internal/capture/maps/claude-code.json new file mode 100644 index 0000000..f1383bd --- /dev/null +++ b/internal/capture/maps/claude-code.json @@ -0,0 +1,32 @@ +{ + "tool": "claude-code", + "locate": { + "session_id": ["session_id"], + "cwd": ["cwd"], + "event_name": ["hook_event_name"], + "prompt": ["prompt"], + "model": ["model"], + "tool_name": ["tool_name"], + "tool_input": ["tool_input"], + "tool_response": ["tool_response"] + }, + "input": { + "file_path": ["file_path", "notebook_path"], + "command": ["command"], + "exit_code": ["exit_code"] + }, + "events": { + "SessionStart": { "action": "session_meta" }, + "UserPromptSubmit": { "action": "prompt" }, + "PostToolUse": { "action": "tool_use" }, + "Stop": { "action": "session_end" }, + "SessionEnd": { "action": "session_end" } + }, + "tools": { + "Write": { "action": "file_touch", "op": "write" }, + "Edit": { "action": "file_touch", "op": "edit" }, + "MultiEdit": { "action": "file_touch", "op": "edit" }, + "NotebookEdit": { "action": "file_touch", "op": "edit" }, + "Bash": { "action": "command" } + } +} diff --git a/internal/capture/maps/codex.json b/internal/capture/maps/codex.json new file mode 100644 index 0000000..578f9f0 --- /dev/null +++ b/internal/capture/maps/codex.json @@ -0,0 +1,32 @@ +{ + "tool": "codex", + "locate": { + "session_id": ["session_id"], + "cwd": ["cwd"], + "event_name": ["hook_event_name"], + "prompt": ["prompt"], + "model": ["model"], + "tool_name": ["tool_name"], + "tool_input": ["tool_input"], + "tool_response": ["tool_response"] + }, + "input": { + "file_path": ["file_path", "filePath", "path", "notebook_path"], + "command": ["command"], + "exit_code": ["exit_code"] + }, + "events": { + "SessionStart": { "action": "session_meta" }, + "UserPromptSubmit": { "action": "prompt" }, + "PostToolUse": { "action": "tool_use" }, + "Stop": { "action": "session_end" } + }, + "tools": { + "Write": { "action": "file_touch", "op": "write" }, + "Edit": { "action": "file_touch", "op": "edit" }, + "MultiEdit": { "action": "file_touch", "op": "edit" }, + "NotebookEdit": { "action": "file_touch", "op": "edit" }, + "Bash": { "action": "command" } + }, + "tool_fallback": { "action": "structural", "handler": "codex_apply_patch" } +} diff --git a/internal/capture/maps/copilot.json b/internal/capture/maps/copilot.json new file mode 100644 index 0000000..fe2c072 --- /dev/null +++ b/internal/capture/maps/copilot.json @@ -0,0 +1,32 @@ +{ + "tool": "copilot", + "locate": { + "session_id": ["session_id", "sessionId", "thread_id", "threadId", "conversation_id", "conversationId"], + "cwd": ["cwd", "workspaceRoot", "workspace_root"], + "event_name": ["hook_event_name", "hookEventName"], + "prompt": ["prompt", "text"], + "model": ["model"], + "tool_name": ["tool_name", "toolName"], + "tool_input": ["tool_input", "toolInput", "tool_info", "toolInfo"], + "tool_response": ["tool_response", "toolResponse"] + }, + "input": { + "file_path": ["file_path", "filePath", "path", "notebook_path"], + "command": ["command"], + "exit_code": ["exit_code", "exitCode"] + }, + "events": { + "sessionStart": { "action": "session_meta" }, + "userPromptSubmitted": { "action": "prompt" }, + "postToolUse": { "action": "tool_use" }, + "sessionEnd": { "action": "session_end" } + }, + "tools": { + "Write": { "action": "file_touch", "op": "write" }, + "Edit": { "action": "file_touch", "op": "edit" }, + "MultiEdit": { "action": "file_touch", "op": "edit" }, + "Bash": { "action": "command" }, + "Terminal": { "action": "command" }, + "Shell": { "action": "command" } + } +} diff --git a/internal/capture/maps/cursor.json b/internal/capture/maps/cursor.json new file mode 100644 index 0000000..518df9e --- /dev/null +++ b/internal/capture/maps/cursor.json @@ -0,0 +1,33 @@ +{ + "tool": "cursor", + "locate": { + "session_id": ["session_id", "sessionId"], + "cwd": ["cwd", "workspaceRoot"], + "event_name": ["hook_event_name", "hookEventName"], + "prompt": ["prompt", "text"], + "model": ["model"], + "tool_name": ["tool_name", "toolName"], + "tool_input": ["tool_input", "toolInput", "tool_info", "toolInfo"], + "tool_response": ["tool_response", "toolResponse"] + }, + "input": { + "file_path": ["file_path", "filePath", "path", "notebook_path"], + "command": ["command"], + "exit_code": ["exit_code", "exitCode"] + }, + "events": { + "sessionStart": { "action": "session_meta" }, + "beforeSubmitPrompt": { "action": "prompt" }, + "afterFileEdit": { "action": "file_touch", "op": "edit" }, + "postToolUse": { "action": "tool_use" }, + "sessionEnd": { "action": "session_end" } + }, + "tools": { + "Write": { "action": "file_touch", "op": "write" }, + "Edit": { "action": "file_touch", "op": "edit" }, + "MultiEdit": { "action": "file_touch", "op": "edit" }, + "Bash": { "action": "command" }, + "Terminal": { "action": "command" }, + "Shell": { "action": "command" } + } +}