From f4bc42deebce3830fdf2bf3d003c840c35a0b58f Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Sat, 8 Aug 2026 12:17:17 -0400 Subject: [PATCH] fix(hooks): Parse Claude Code's nested tool_input payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #110. parseHookFilePathsErr handled a top-level file_path and Codex's tool_input.command, but not tool_input.file_path — the shape Claude Code actually sends for Edit and Write. The regex fallback that would have caught it only runs when json.Unmarshal fails, so a well-formed payload in the unhandled shape was the one case that silently matched nothing and returned no paths. Two features depended on that parse and have therefore been inert on Claude Code: - Agent-edit provenance never recorded, so .codemap/agent_edits.jsonl was never created and the working-set summary always fell back to disk churn — the exact conflation the feature was built to remove. - The post-edit blast-radius report never fired, which is the behavior CLAUDE.md instructs agents to rely on after editing. Verified against the release binary before and after with the payload Claude Code sends: previously no output and no record; now the hub report prints and the edit is recorded. Also accepts tool_input.notebook_path for NotebookEdit. Tests cover each payload shape end to end rather than only the parser, since the parser was arguably correct — it simply never saw the shape it needed. Docs updated now that the behavior works: HOOKS.md describes working-set provenance and the payload keys it depends on, notes doctor's scope fallback from #100, and marks post-edit as recording provenance. The manual hook JSON keeps the bare PATH form, which is correct for hand-managed settings and accepted by doctor since #100. Drive-by: CONTRIBUTING suggested Elixir and Scala as languages to add, both already supported; plugin README said "skill" singular but ships two. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 2 +- cmd/hooks.go | 17 ++++++- cmd/hooks_payload_test.go | 94 +++++++++++++++++++++++++++++++++++++++ docs/HOOKS.md | 8 +++- plugins/codemap/README.md | 2 +- 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 cmd/hooks_payload_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 048ab8d..c0fcf42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Thanks for your interest in contributing! Here's how to get involved. ## Adding a New Language -Want to add support for a language like Clojure, Elixir, Scala, etc.? Here's what's needed: +Want to add support for a language like Clojure, Zig, Haskell, etc.? Here's what's needed: ### 1. Add grammar to `release.yml` diff --git a/cmd/hooks.go b/cmd/hooks.go index 6fad776..a7c7f4f 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -1813,12 +1813,25 @@ func parseHookFilePathsErr(input []byte) ([]string, error) { return nil, err } + // Legacy shape: some hosts put the path at the top level. if filePath, ok := data["file_path"].(string); ok { return []string{filePath}, nil } - // Codex applies file edits through apply_patch. Its hook payload stores the - // patch text under tool_input.command rather than a Claude-style file_path. if toolInput, ok := data["tool_input"].(map[string]interface{}); ok { + // Claude Code nests the target under tool_input: file_path for + // Edit/Write, notebook_path for NotebookEdit. Missing these silently + // disables agent-edit provenance and the post-edit blast-radius report, + // because a well-formed payload parses cleanly and then matches nothing + // (the regex fallback above only runs when Unmarshal fails). + for _, key := range []string{"file_path", "notebook_path"} { + if filePath, ok := toolInput[key].(string); ok { + if paths := appendUniquePath(nil, filePath); len(paths) > 0 { + return paths, nil + } + } + } + // Codex applies file edits through apply_patch, which stores the patch + // text under tool_input.command rather than a path. if command, ok := toolInput["command"].(string); ok { matches := regexp.MustCompile(`(?m)^\*\*\* (?:Update|Add|Delete) File: (.+)$`).FindAllStringSubmatch(command, -1) paths := make([]string, 0, len(matches)) diff --git a/cmd/hooks_payload_test.go b/cmd/hooks_payload_test.go new file mode 100644 index 0000000..bf2d98a --- /dev/null +++ b/cmd/hooks_payload_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestParseHookFilePathsAcceptsHostPayloadShapes pins the payload shapes the +// hooks actually receive. Claude Code nests the path under tool_input for +// Edit/Write/NotebookEdit; only a legacy shape puts it at the top level. Missing +// the nested form silently disables both agent-edit provenance and the +// post-edit blast-radius report, because the parser returns no paths and the +// hook returns early. +func TestParseHookFilePathsAcceptsHostPayloadShapes(t *testing.T) { + target := filepath.Join("cmd", "doctor.go") + quoted := strings.ReplaceAll(target, `\`, `\\`) + + for _, tt := range []struct { + name string + input string + want []string + }{ + { + name: "legacy top-level file_path", + input: `{"session_id":"s1","file_path":"` + quoted + `"}`, + want: []string{target}, + }, + { + name: "Claude Edit tool_input.file_path", + input: `{"session_id":"s1","tool_name":"Edit","tool_input":{"file_path":"` + quoted + `"}}`, + want: []string{target}, + }, + { + name: "Claude Write tool_input.file_path", + input: `{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"` + quoted + `","content":"x"}}`, + want: []string{target}, + }, + { + name: "Claude NotebookEdit tool_input.notebook_path", + input: `{"session_id":"s1","tool_name":"NotebookEdit","tool_input":{"notebook_path":"` + quoted + `"}}`, + want: []string{target}, + }, + { + name: "Codex apply_patch tool_input.command", + input: `{"session_id":"s1","tool_input":{"command":"*** Update File: ` + quoted + `\n@@\n-a\n+b\n"}}`, + want: []string{target}, + }, + { + name: "no path present", + input: `{"session_id":"s1","tool_name":"Bash","tool_input":{"command":"go test ./..."}}`, + want: nil, + }, + } { + t.Run(tt.name, func(t *testing.T) { + got := parseHookFilePaths([]byte(tt.input)) + if len(got) != len(tt.want) { + t.Fatalf("parseHookFilePaths() = %v, want %v", got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("parseHookFilePaths()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +// TestHookPostEditRecordsAgentEditForNestedPayload covers the whole path rather +// than just the parser: the parser was arguably fine, it simply never saw the +// shape it needed, so the regression has to be pinned end to end. +func TestHookPostEditRecordsAgentEditForNestedPayload(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "main.go") + if err := os.WriteFile(target, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + quoted := strings.ReplaceAll(target, `\`, `\\`) + payload := `{"session_id":"nested-session","tool_name":"Edit","tool_input":{"file_path":"` + quoted + `"}}` + + withStdinInput(t, payload, func() { + if err := hookPostEdit(root); err != nil { + t.Fatalf("hookPostEdit() = %v", err) + } + }) + + edits := loadAgentEdits(root, time.Time{}) + if !edits.paths[normalizeAgentEditPath(root, target)] { + t.Fatalf("expected agent edit recorded for %s, got %v", target, edits.paths) + } +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 008b70c..fa1f316 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -41,7 +41,7 @@ This command: Use `--agent claude` or `--agent codex` to configure only one integration. Managed commands use the verified absolute path of the running `codemap`; rerun -setup if that path changes. `codemap doctor` validates without rewriting. +setup if that path changes. `codemap doctor` validates without rewriting; it checks project scope and falls back to user scope, reporting which one satisfied each check (`codemap doctor --global` checks user scope only). Important: run `codemap setup` from the git repo root. Hook commands run relative to the current working directory; starting Claude from a nested folder can prevent codemap from finding `.git` and `.codemap`. @@ -303,6 +303,10 @@ Next codemap: The **working set** tracks files you've edited during the current session. It shows edit count, net line delta, and hub status — giving Claude awareness of your active work context. +Edits are attributed by *provenance*, not by disk churn. The `PostToolUse` hook records the paths an agent wrote through its own tool calls into `.codemap/agent_edits.jsonl`, so a branch switch, a build, or a `git` command that touches hundreds of files does not masquerade as work the agent did. When no agent edits have been recorded, the summary says so and falls back to reporting disk activity rather than conflating the two. + +Provenance depends on the hook receiving a payload it understands: `tool_input.file_path` (Edit/Write), `tool_input.notebook_path` (NotebookEdit), or `tool_input.command` (Codex `apply_patch`). + ### At Session End ``` 📊 Session Summary @@ -339,7 +343,7 @@ If a recent handoff exists **for the current branch**, session start includes a |---------|--------------|---------------| | `codemap hook session-start` | `SessionStart` | Full tree, hubs, branch diff, last session context | | `codemap hook pre-edit` | `PreToolUse` (Edit\|Write) | Who imports file + what hubs it imports | -| `codemap hook post-edit` | `PostToolUse` (Edit\|Write) | Impact of changes (same as pre-edit) | +| `codemap hook post-edit` | `PostToolUse` (Edit\|Write) | Impact of changes (same as pre-edit), and records the edit's provenance | | `codemap hook prompt-submit` | `UserPromptSubmit` | Intent classification, hub context, risk analysis, working set, route suggestions, drift warnings | | `codemap hook pre-compact` | `PreCompact` | Saves hub state to .codemap/hubs.txt | | `codemap hook session-stop` | `SessionEnd` | Edit timeline + writes `.codemap/handoff.latest.json`, `.codemap/handoff.prefix.json`, `.codemap/handoff.delta.json` | diff --git a/plugins/codemap/README.md b/plugins/codemap/README.md index 9b75414..fc017f7 100644 --- a/plugins/codemap/README.md +++ b/plugins/codemap/README.md @@ -4,7 +4,7 @@ This is a Codex plugin bundle for Codemap. It bundles: -- the Codemap skill under `./skills/` +- the Codemap skills under `./skills/` - an MCP configuration generated at install time - packaged logo/icon assets under `./assets/`