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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
17 changes: 15 additions & 2 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +1816 to 1819
// 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))
Expand Down
94 changes: 94 additions & 0 deletions cmd/hooks_payload_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 6 additions & 2 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion plugins/codemap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`

Expand Down
Loading