Skip to content

Commit 4bef107

Browse files
JordanCoinclaude
andauthored
fix(hooks): Parse Claude Code's nested tool_input payload (#114)
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) <noreply@anthropic.com>
1 parent d1e8a13 commit 4bef107

5 files changed

Lines changed: 117 additions & 6 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Here's how to get involved.
1010

1111
## Adding a New Language
1212

13-
Want to add support for a language like Clojure, Elixir, Scala, etc.? Here's what's needed:
13+
Want to add support for a language like Clojure, Zig, Haskell, etc.? Here's what's needed:
1414

1515
### 1. Add grammar to `release.yml`
1616

cmd/hooks.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,12 +1813,25 @@ func parseHookFilePathsErr(input []byte) ([]string, error) {
18131813
return nil, err
18141814
}
18151815

1816+
// Legacy shape: some hosts put the path at the top level.
18161817
if filePath, ok := data["file_path"].(string); ok {
18171818
return []string{filePath}, nil
18181819
}
1819-
// Codex applies file edits through apply_patch. Its hook payload stores the
1820-
// patch text under tool_input.command rather than a Claude-style file_path.
18211820
if toolInput, ok := data["tool_input"].(map[string]interface{}); ok {
1821+
// Claude Code nests the target under tool_input: file_path for
1822+
// Edit/Write, notebook_path for NotebookEdit. Missing these silently
1823+
// disables agent-edit provenance and the post-edit blast-radius report,
1824+
// because a well-formed payload parses cleanly and then matches nothing
1825+
// (the regex fallback above only runs when Unmarshal fails).
1826+
for _, key := range []string{"file_path", "notebook_path"} {
1827+
if filePath, ok := toolInput[key].(string); ok {
1828+
if paths := appendUniquePath(nil, filePath); len(paths) > 0 {
1829+
return paths, nil
1830+
}
1831+
}
1832+
}
1833+
// Codex applies file edits through apply_patch, which stores the patch
1834+
// text under tool_input.command rather than a path.
18221835
if command, ok := toolInput["command"].(string); ok {
18231836
matches := regexp.MustCompile(`(?m)^\*\*\* (?:Update|Add|Delete) File: (.+)$`).FindAllStringSubmatch(command, -1)
18241837
paths := make([]string, 0, len(matches))

cmd/hooks_payload_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
"time"
9+
)
10+
11+
// TestParseHookFilePathsAcceptsHostPayloadShapes pins the payload shapes the
12+
// hooks actually receive. Claude Code nests the path under tool_input for
13+
// Edit/Write/NotebookEdit; only a legacy shape puts it at the top level. Missing
14+
// the nested form silently disables both agent-edit provenance and the
15+
// post-edit blast-radius report, because the parser returns no paths and the
16+
// hook returns early.
17+
func TestParseHookFilePathsAcceptsHostPayloadShapes(t *testing.T) {
18+
target := filepath.Join("cmd", "doctor.go")
19+
quoted := strings.ReplaceAll(target, `\`, `\\`)
20+
21+
for _, tt := range []struct {
22+
name string
23+
input string
24+
want []string
25+
}{
26+
{
27+
name: "legacy top-level file_path",
28+
input: `{"session_id":"s1","file_path":"` + quoted + `"}`,
29+
want: []string{target},
30+
},
31+
{
32+
name: "Claude Edit tool_input.file_path",
33+
input: `{"session_id":"s1","tool_name":"Edit","tool_input":{"file_path":"` + quoted + `"}}`,
34+
want: []string{target},
35+
},
36+
{
37+
name: "Claude Write tool_input.file_path",
38+
input: `{"session_id":"s1","tool_name":"Write","tool_input":{"file_path":"` + quoted + `","content":"x"}}`,
39+
want: []string{target},
40+
},
41+
{
42+
name: "Claude NotebookEdit tool_input.notebook_path",
43+
input: `{"session_id":"s1","tool_name":"NotebookEdit","tool_input":{"notebook_path":"` + quoted + `"}}`,
44+
want: []string{target},
45+
},
46+
{
47+
name: "Codex apply_patch tool_input.command",
48+
input: `{"session_id":"s1","tool_input":{"command":"*** Update File: ` + quoted + `\n@@\n-a\n+b\n"}}`,
49+
want: []string{target},
50+
},
51+
{
52+
name: "no path present",
53+
input: `{"session_id":"s1","tool_name":"Bash","tool_input":{"command":"go test ./..."}}`,
54+
want: nil,
55+
},
56+
} {
57+
t.Run(tt.name, func(t *testing.T) {
58+
got := parseHookFilePaths([]byte(tt.input))
59+
if len(got) != len(tt.want) {
60+
t.Fatalf("parseHookFilePaths() = %v, want %v", got, tt.want)
61+
}
62+
for i := range tt.want {
63+
if got[i] != tt.want[i] {
64+
t.Fatalf("parseHookFilePaths()[%d] = %q, want %q", i, got[i], tt.want[i])
65+
}
66+
}
67+
})
68+
}
69+
}
70+
71+
// TestHookPostEditRecordsAgentEditForNestedPayload covers the whole path rather
72+
// than just the parser: the parser was arguably fine, it simply never saw the
73+
// shape it needed, so the regression has to be pinned end to end.
74+
func TestHookPostEditRecordsAgentEditForNestedPayload(t *testing.T) {
75+
root := t.TempDir()
76+
target := filepath.Join(root, "main.go")
77+
if err := os.WriteFile(target, []byte("package main\n"), 0o644); err != nil {
78+
t.Fatal(err)
79+
}
80+
81+
quoted := strings.ReplaceAll(target, `\`, `\\`)
82+
payload := `{"session_id":"nested-session","tool_name":"Edit","tool_input":{"file_path":"` + quoted + `"}}`
83+
84+
withStdinInput(t, payload, func() {
85+
if err := hookPostEdit(root); err != nil {
86+
t.Fatalf("hookPostEdit() = %v", err)
87+
}
88+
})
89+
90+
edits := loadAgentEdits(root, time.Time{})
91+
if !edits.paths[normalizeAgentEditPath(root, target)] {
92+
t.Fatalf("expected agent edit recorded for %s, got %v", target, edits.paths)
93+
}
94+
}

docs/HOOKS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ This command:
4141

4242
Use `--agent claude` or `--agent codex` to configure only one integration.
4343
Managed commands use the verified absolute path of the running `codemap`; rerun
44-
setup if that path changes. `codemap doctor` validates without rewriting.
44+
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).
4545

4646
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`.
4747

@@ -303,6 +303,10 @@ Next codemap:
303303

304304
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.
305305

306+
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.
307+
308+
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`).
309+
306310
### At Session End
307311
```
308312
📊 Session Summary
@@ -339,7 +343,7 @@ If a recent handoff exists **for the current branch**, session start includes a
339343
|---------|--------------|---------------|
340344
| `codemap hook session-start` | `SessionStart` | Full tree, hubs, branch diff, last session context |
341345
| `codemap hook pre-edit` | `PreToolUse` (Edit\|Write) | Who imports file + what hubs it imports |
342-
| `codemap hook post-edit` | `PostToolUse` (Edit\|Write) | Impact of changes (same as pre-edit) |
346+
| `codemap hook post-edit` | `PostToolUse` (Edit\|Write) | Impact of changes (same as pre-edit), and records the edit's provenance |
343347
| `codemap hook prompt-submit` | `UserPromptSubmit` | Intent classification, hub context, risk analysis, working set, route suggestions, drift warnings |
344348
| `codemap hook pre-compact` | `PreCompact` | Saves hub state to .codemap/hubs.txt |
345349
| `codemap hook session-stop` | `SessionEnd` | Edit timeline + writes `.codemap/handoff.latest.json`, `.codemap/handoff.prefix.json`, `.codemap/handoff.delta.json` |

plugins/codemap/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This is a Codex plugin bundle for Codemap.
44

55
It bundles:
66

7-
- the Codemap skill under `./skills/`
7+
- the Codemap skills under `./skills/`
88
- an MCP configuration generated at install time
99
- packaged logo/icon assets under `./assets/`
1010

0 commit comments

Comments
 (0)