Skip to content
Open
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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ codemap --diff --ref <branch> # 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 <file>`
- 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** (`<!-- codemap:intent -->`) for tool consumption
- Matches **relevant skills** and tells you which to pull (`codemap skill show <name>`)
Expand Down
172 changes: 167 additions & 5 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -724,6 +724,8 @@ func hookPromptSubmit(root string) error {
}
}

showNextCodemapSteps(intent, info)

showRouteSuggestions(prompt, projCfg, topK)

// Match and inject relevant skills
Expand Down Expand Up @@ -811,6 +813,112 @@ func showMatchedSkills(root string, intent TaskIntent) {
fmt.Printf("Skills matched: %s — run `codemap skill show <name>` 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")
Comment on lines +878 to +879
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate codemap --deps suggestion when no files are mentioned

In planCodemapNextSteps, feature prompts with no detected files always add codemap --deps because the condition uses intent.Scope != "single-file"; computeScope returns "unknown" when no files are present, so this branch is always true. In practice, generic prompts like “add X feature” will now trigger a full dependency guidance step even before any target file/package is known, which adds noisy and often expensive advice on every such prompt.

Useful? React with 👍 / 👎.

}
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() {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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()
}
Expand All @@ -1420,9 +1548,43 @@ func checkFileImporters(root, filePath string) error {
fmt.Println()
}

showFileCodemapActions(filePath, len(importers), len(hubImports) > 0, phase)

Comment on lines 1548 to +1552
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showFileCodemapActions(...) is invoked unconditionally, so pre/post-edit hooks will now emit "Run now/Re-check with" guidance even when the file has 0 importers and does not import any hubs. That adds noise/token cost and doesn’t match the PR description’s framing of phase-aware guidance for risky files. Consider gating this call (or early-returning inside showFileCodemapActions) unless importerCount > 0 or importsHub (or some other explicit risk predicate).

Copilot uses AI. Check for mistakes.
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
}

Comment on lines +1570 to +1573
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showFileCodemapActions always seeds steps with one element, so if len(steps) == 0 { return } is unreachable. Remove the dead check, or only append the initial step when you actually want to emit guidance.

Suggested change
if len(steps) == 0 {
return
}

Copilot uses AI. Check for mistakes.
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
Expand Down
56 changes: 44 additions & 12 deletions cmd/hooks_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

<!-- codemap:routes [{"id":"scanning","score":3,"docs":["docs/MCP.md"]}] -->

📚 Suggested context routes:
Expand Down Expand Up @@ -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

---

Expand Down
Loading