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/`