Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 14 additions & 76 deletions internal/capture/claudecode.go
Original file line number Diff line number Diff line change
@@ -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/<tool>.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"
Expand All @@ -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)
}
104 changes: 27 additions & 77 deletions internal/capture/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
75 changes: 7 additions & 68 deletions internal/capture/copilot.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading