From 3e5d5d2b06357f500f81ad4193290fb5106c9e53 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Sat, 28 Mar 2026 23:33:19 -0400 Subject: [PATCH] Add transition-based codemap hook guidance --- CLAUDE.md | 10 +++ README.md | 1 + cmd/hooks.go | 172 +++++++++++++++++++++++++++++++++++++++-- cmd/hooks_more_test.go | 56 +++++++++++--- docs/HOOKS.md | 21 ++++- 5 files changed, 239 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a6d0cd6..7134d9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,16 @@ codemap --diff --ref # Changes vs specific branch **BEFORE starting any task**, run `codemap .` first. +Treat codemap as part of the execution loop, not as optional reference material: + +- Before the first real code exploration in a task: `codemap .` +- Before editing any file: `codemap --importers ` +- Before refactors, moves, or dependency-heavy changes: `codemap --deps` +- Before summarizing, reviewing, or committing: `codemap --diff` +- If a codemap hook prints `Next codemap:` or `Run now:`, do that before continuing + +Use `rg` for exact string lookup after codemap has established structure or blast radius. + **ALWAYS run `codemap --deps` when:** - User asks how something works - Refactoring or moving code diff --git a/README.md b/README.md index 85befbd..b94f825 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,7 @@ Skills are also available via MCP: `list_skills` (metadata) and `get_skill` (ful The prompt-submit hook performs **intent classification** on every prompt — detecting whether you're refactoring, fixing a bug, exploring, testing, or building a feature. It then: - Surfaces **risk analysis** based on hub file involvement +- Emits **exact next-step codemap commands** at transition points like “before editing” and “before refactoring” - Shows your **working set** (files edited this session) - Emits **structured JSON markers** (``) for tool consumption - Matches **relevant skills** and tells you which to pull (`codemap skill show `) diff --git a/cmd/hooks.go b/cmd/hooks.go index c834cd2..93ec733 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -651,7 +651,7 @@ func hookPreEdit(root string) error { return nil // silently skip if no file path } - return checkFileImporters(root, filePath) + return checkFileImportersWithPhase(root, filePath, "before") } // hookPostEdit shows impact after editing (reads JSON from stdin) @@ -661,7 +661,7 @@ func hookPostEdit(root string) error { return nil } - return checkFileImporters(root, filePath) + return checkFileImportersWithPhase(root, filePath, "after") } // hookPromptSubmit analyzes user prompt with code intelligence and shows context @@ -724,6 +724,8 @@ func hookPromptSubmit(root string) error { } } + showNextCodemapSteps(intent, info) + showRouteSuggestions(prompt, projCfg, topK) // Match and inject relevant skills @@ -811,6 +813,112 @@ func showMatchedSkills(root string, intent TaskIntent) { fmt.Printf("Skills matched: %s — run `codemap skill show ` for guidance\n", strings.Join(names, ", ")) } +type codemapNextStep struct { + Command string + Reason string +} + +func showNextCodemapSteps(intent TaskIntent, info *hubInfo) { + steps := planCodemapNextSteps(intent, info) + if len(steps) == 0 { + return + } + + fmt.Println() + fmt.Println("Next codemap:") + for _, step := range steps { + fmt.Printf(" • %s — %s\n", step.Command, step.Reason) + } +} + +func planCodemapNextSteps(intent TaskIntent, info *hubInfo) []codemapNextStep { + var steps []codemapNextStep + seen := make(map[string]bool) + add := func(command, reason string) { + if command == "" || seen[command] { + return + } + seen[command] = true + steps = append(steps, codemapNextStep{Command: command, Reason: reason}) + } + + primaryFile := pickPrimaryCodemapFile(intent.Files, info) + if primaryFile != "" { + importerCount := 0 + if info != nil { + importerCount = len(info.Importers[primaryFile]) + } + + reason := "check callers before editing" + switch { + case importerCount >= 3: + reason = fmt.Sprintf("check blast radius before editing this hub (%d importers)", importerCount) + case importerCount > 0: + reason = fmt.Sprintf("check callers before editing (%d importers)", importerCount) + } + add("codemap --importers "+shellQuoteIfNeeded(primaryFile), reason) + if importerCount >= 3 { + add("codemap --deps", "trace dependency flow around this hub before changing it") + } + } + + switch intent.Category { + case "explore": + if len(intent.Files) == 0 { + add("codemap .", "refresh project structure before diving in") + } else { + add("codemap --deps", "trace how the mentioned code connects") + } + case "refactor": + add("codemap --deps", "verify dependency flow before refactoring") + case "feature": + if len(intent.Files) == 0 { + add("codemap .", "refresh project structure before choosing edit points") + } + if len(intent.Files) > 0 || intent.Scope != "single-file" { + add("codemap --deps", "feature work tends to cross existing dependencies") + } + case "bugfix": + if len(intent.Files) == 0 { + add("codemap --diff", "check recent branch changes before debugging") + } else if primaryFile != "" && info != nil && len(info.Importers[primaryFile]) >= 3 { + add("codemap --deps", "hub fixes can ripple through dependents") + } + } + + if len(steps) > 2 { + steps = steps[:2] + } + return steps +} + +func pickPrimaryCodemapFile(files []string, info *hubInfo) string { + if len(files) == 0 { + return "" + } + + best := files[0] + bestImporters := -1 + for _, file := range files { + importerCount := 0 + if info != nil { + importerCount = len(info.Importers[file]) + } + if importerCount > bestImporters { + best = file + bestImporters = importerCount + } + } + return best +} + +func shellQuoteIfNeeded(value string) string { + if strings.ContainsAny(value, " \t") { + return strconv.Quote(value) + } + return value +} + func injectConfigSetupSkill(root string, idx *skills.SkillIndex, matches []skills.MatchResult) []skills.MatchResult { assessment := config.AssessSetup(root) if !assessment.NeedsAttention() { @@ -1373,6 +1481,10 @@ func extractFilePathFromStdin() (string, error) { // checkFileImporters checks if a file is a hub and shows its importers func checkFileImporters(root, filePath string) error { + return checkFileImportersWithPhase(root, filePath, "") +} + +func checkFileImportersWithPhase(root, filePath, phase string) error { info := getHubInfoNoFallback(root) if info == nil { return nil // silently skip if deps unavailable @@ -1388,8 +1500,18 @@ func checkFileImporters(root, filePath string) error { importers := info.Importers[filePath] if len(importers) >= 3 { fmt.Println() - fmt.Printf("⚠️ HUB FILE: %s\n", filePath) - fmt.Printf(" Imported by %d files - changes have wide impact!\n", len(importers)) + switch phase { + case "before": + fmt.Printf("🛑 Before editing: %s is a hub with %d importers.\n", filePath, len(importers)) + case "after": + fmt.Printf("⚠️ After editing: %s still fans out to %d importers.\n", filePath, len(importers)) + default: + fmt.Printf("⚠️ HUB FILE: %s\n", filePath) + fmt.Printf(" Imported by %d files - changes have wide impact!\n", len(importers)) + } + if phase != "" { + fmt.Printf(" Changes here have wide impact.\n") + } fmt.Println() fmt.Println(" Dependents:") for i, imp := range importers { @@ -1402,7 +1524,13 @@ func checkFileImporters(root, filePath string) error { fmt.Println() } else if len(importers) > 0 { fmt.Println() - fmt.Printf("📍 File: %s\n", filePath) + if phase == "after" { + fmt.Printf("📍 After editing: %s\n", filePath) + } else if phase == "before" { + fmt.Printf("📍 Before editing: %s\n", filePath) + } else { + fmt.Printf("📍 File: %s\n", filePath) + } fmt.Printf(" Imported by %d file(s): %s\n", len(importers), strings.Join(importers, ", ")) fmt.Println() } @@ -1420,9 +1548,43 @@ func checkFileImporters(root, filePath string) error { fmt.Println() } + showFileCodemapActions(filePath, len(importers), len(hubImports) > 0, phase) + return nil } +func showFileCodemapActions(filePath string, importerCount int, importsHub bool, phase string) { + steps := []codemapNextStep{ + { + Command: "codemap --importers " + shellQuoteIfNeeded(filePath), + Reason: "review blast radius for this file", + }, + } + if importerCount >= 3 || importsHub { + steps = append(steps, codemapNextStep{ + Command: "codemap --deps", + Reason: "verify dependency flow around this change", + }) + } + + if len(steps) == 0 { + return + } + + switch phase { + case "before": + fmt.Println(" Run now:") + case "after": + fmt.Println(" Re-check with:") + default: + fmt.Println(" Next codemap:") + } + for _, step := range steps { + fmt.Printf(" • %s — %s\n", step.Command, step.Reason) + } + fmt.Println() +} + // isHub checks if a file is a hub (has 3+ importers) func (h *hubInfo) isHub(path string) bool { return len(h.Importers[path]) >= 3 diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 938cda2..d63fe8e 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -323,20 +323,43 @@ func TestExtractFilePathAndEditHooks(t *testing.T) { } }) - checkOutput := func(fn func(string) error) { - withStdinInput(t, mustJSONInput(t, map[string]string{"file_path": target}), func() { - var hookErr error - out := captureOutput(func() { hookErr = fn(root) }) - if hookErr != nil { - t.Fatalf("hook returned error: %v", hookErr) + withStdinInput(t, mustJSONInput(t, map[string]string{"file_path": target}), func() { + var hookErr error + out := captureOutput(func() { hookErr = hookPreEdit(root) }) + if hookErr != nil { + t.Fatalf("hookPreEdit() error: %v", hookErr) + } + checks := []string{ + "Before editing: pkg/types.go is a hub with 3 importers.", + "Run now:", + "codemap --importers pkg/types.go", + "codemap --deps", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in output, got:\n%s", check, out) } - if !strings.Contains(out, "HUB FILE: pkg/types.go") { - t.Fatalf("expected hub warning, got:\n%s", out) + } + }) + + withStdinInput(t, mustJSONInput(t, map[string]string{"file_path": target}), func() { + var hookErr error + out := captureOutput(func() { hookErr = hookPostEdit(root) }) + if hookErr != nil { + t.Fatalf("hookPostEdit() error: %v", hookErr) + } + checks := []string{ + "After editing: pkg/types.go still fans out to 3 importers.", + "Re-check with:", + "codemap --importers pkg/types.go", + "codemap --deps", + } + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected %q in output, got:\n%s", check, out) } - }) - } - checkOutput(hookPreEdit) - checkOutput(hookPostEdit) + } + }) } func TestCheckFileImportersAndRouteSuggestions(t *testing.T) { @@ -372,6 +395,12 @@ func TestCheckFileImportersAndRouteSuggestions(t *testing.T) { if !strings.Contains(out, "Imports 1 hub(s): shared/hub.go") { t.Fatalf("expected hub import summary, got:\n%s", out) } + if !strings.Contains(out, "Next codemap:") || !strings.Contains(out, "codemap --importers pkg/types.go") { + t.Fatalf("expected actionable codemap guidance, got:\n%s", out) + } + if !strings.Contains(out, "codemap --deps") { + t.Fatalf("expected dependency guidance, got:\n%s", out) + } cfg := config.ProjectConfig{ Routing: config.RoutingConfig{ @@ -431,6 +460,9 @@ func TestHookPromptSubmitShowsContextAndProgress(t *testing.T) { checks := []string{ "Context for mentioned files", "pkg/types.go is a HUB", + "Next codemap:", + "codemap --importers pkg/types.go", + "codemap --deps", "Suggested context routes", "watching", "Session so far: 2 files edited, 1 hub edits", diff --git a/docs/HOOKS.md b/docs/HOOKS.md index c3b176c..b39d991 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -218,21 +218,29 @@ myproject ### Before/After Editing a File ``` -📍 File: cmd/hooks.go +📍 Before editing: cmd/hooks.go Imported by 1 file(s): main.go Imports 16 hub(s): scanner/types.go, scanner/walker.go, watch/daemon.go... + + Run now: + • codemap --importers cmd/hooks.go — review blast radius for this file + • codemap --deps — verify dependency flow around this change ``` Or if it's a hub: ``` -⚠️ HUB FILE: scanner/types.go - Imported by 10 files - changes have wide impact! +🛑 Before editing: scanner/types.go is a hub with 10 importers. + Changes here have wide impact. Dependents: • main.go • mcp/main.go • watch/watch.go ... and 7 more + + Run now: + • codemap --importers scanner/types.go — review blast radius for this file + • codemap --deps — verify dependency flow around this change ``` ### When You Mention a File (Prompt Submit) @@ -250,6 +258,10 @@ The prompt-submit hook now performs **intent classification** — it analyzes wh • [check-deps] scanner/types.go — verify dependents still compile after changes • [run-tests] . — run full test suite after refactoring +Next codemap: + • codemap --importers scanner/types.go — check blast radius before editing this hub (10 importers) + • codemap --deps — verify dependency flow before refactoring + 📚 Suggested context routes: @@ -355,7 +367,8 @@ codemap handoff --detail a.go . # lazy-load full detail for one changed file With these hooks, Claude: 1. **Knows** which files are hubs before touching them 2. **Sees** the blast radius after making changes -3. **Remembers** important files even after context compaction +3. **Gets exact codemap commands** at decision points instead of generic protocol reminders +4. **Remembers** important files even after context compaction ---