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
37 changes: 36 additions & 1 deletion .claude/skills/codemap/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: codemap
description: Analyze codebase structure, dependencies, and changes. Use when user asks about project structure, where code is located, how files connect, what changed, or before starting any coding task. Provides instant architectural context.
description: Analyze codebase structure, dependencies, changes, and cross-agent handoffs. Use when user asks about project structure, where code is located, how files connect, what changed, how to resume work, or before starting any coding task.
---

# Codemap
Expand All @@ -14,6 +14,13 @@ codemap . # Project structure and top files
codemap --deps # Dependency flow (imports/functions)
codemap --diff # Changes vs main branch
codemap --diff --ref <branch> # Changes vs specific branch
codemap handoff . # Build + save handoff artifact
codemap handoff --latest . # Read latest saved handoff
codemap handoff --json . # Machine-readable handoff payload
codemap handoff --since 2h . # Limit timeline lookback when building
codemap handoff --prefix . # Stable prefix snapshot only
codemap handoff --delta . # Recent delta snapshot only
codemap handoff --detail a.go . # Lazy-load full detail for one changed file
```

## When to Use
Expand All @@ -39,6 +46,12 @@ codemap --diff --ref <branch> # Changes vs specific branch
- Assessing what might break
- Use `--ref <branch>` when comparing against something other than main

### ALWAYS run `codemap handoff` when:
- Handing work from one agent to another (Claude, Codex, MCP client)
- Resuming work after a break and you want a compact recap
- User asks "what should the next agent know?"
- You want a durable summary in `.codemap/handoff.latest.json`

## Output Interpretation

### Tree View (`codemap .`)
Expand All @@ -58,6 +71,22 @@ codemap --diff --ref <branch> # Changes vs specific branch
- `(+N -M)` = lines added/removed
- Warning icons show files imported by others (impact analysis)

### Handoff (`codemap handoff`)
- layered output: `prefix` (stable hubs/context) + `delta` (recent changed-file stubs + timeline)
- changed file transport uses stubs (`path`, `hash`, `status`, `size`) for lower context cost
- `risk_files` highlights high-impact changed files when dependency context is available
- includes deterministic hashes (`prefix_hash`, `delta_hash`, `combined_hash`) and cache metrics
- `--latest` reads saved artifact without rebuilding

## Daemon and Hooks

- With daemon state: handoff includes richer timeline and better risk context.
- Without daemon state: handoff still works using git-based changed files.
- Hook behavior:
- `session-stop` writes `.codemap/handoff.latest.json`
- `session-start` may show recent handoff summary (24h freshness window)
- session-start structure output is capped/adaptive for large repos

## Examples

**User asks:** "Where is the authentication handled?"
Expand All @@ -71,3 +100,9 @@ codemap --diff --ref <branch> # Changes vs specific branch

**User asks:** "I want to refactor the utils module"
**Action:** Run `codemap --deps` first to see what depends on utils before making changes.

**User asks:** "I'm switching to another agent, what should I pass along?"
**Action:** Run `codemap handoff .` and share the summary (or `--json` for tools).

**User asks:** "I just came back, what was in progress?"
**Action:** Run `codemap handoff --latest .` and continue from that state.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ hub-check

# Runtime state
.codemap/
codemap-dev
dev_codemap
firebase-debug.log
firebalse-debug.log

# Local settings
.claude/settings.local.json
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ codemap --exclude .xcassets,Fonts,.png . # Hide assets
codemap --depth 2 . # Limit depth
codemap --diff # What changed vs main
codemap --deps . # Dependency flow
codemap handoff . # Save cross-agent handoff summary
codemap github.com/user/repo # Remote GitHub repo
```

Expand Down Expand Up @@ -133,9 +134,52 @@ Uses a shallow clone to a temp directory (fast, no history, auto-cleanup). If yo
**Hooks (Recommended)** — Automatic context at session start, before/after edits, and more.
→ See [docs/HOOKS.md](docs/HOOKS.md)

**MCP Server** — Deep integration with 7 tools for codebase analysis.
**MCP Server** — Deep integration with project analysis + handoff tools.
→ See [docs/MCP.md](docs/MCP.md)

## Multi-Agent Handoff

codemap now supports a shared handoff artifact so you can switch between agents (Claude, Codex, MCP clients) without re-briefing.

```bash
codemap handoff . # Build + save layered handoff artifacts
codemap handoff --latest . # Read latest saved artifact
codemap handoff --json . # Machine-readable handoff payload
codemap handoff --since 2h . # Limit timeline lookback window
codemap handoff --prefix . # Stable prefix layer only
codemap handoff --delta . # Recent delta layer only
codemap handoff --detail a.go . # Lazy-load full detail for one changed file
codemap handoff --no-save . # Build/read without writing artifacts
```

What it captures (layered for cache reuse):
- `prefix` (stable): hub summaries + repo file-count context
- `delta` (dynamic): changed file stubs (`path`, `hash`, `status`, `size`), risk files, recent events, next steps
- deterministic hashes: `prefix_hash`, `delta_hash`, `combined_hash`
- cache metrics: reuse ratio + unchanged bytes vs previous handoff

Artifacts written:
- `.codemap/handoff.latest.json` (full artifact)
- `.codemap/handoff.prefix.json` (stable prefix snapshot)
- `.codemap/handoff.delta.json` (dynamic delta snapshot)
- `.codemap/handoff.metrics.log` (append-only metrics stream, one JSON line per save)

Save defaults:
- CLI saves by default; use `--no-save` to make generation read-only.
- MCP does not save by default; set `save=true` to persist artifacts.

Compatibility note:
- legacy top-level fields (`changed_files`, `risk_files`, etc.) are still included for compatibility and will be removed in a future schema version after migration.

Why this matters:
- default transport is compact stubs (low context cost)
- full per-file context is lazy-loaded only when needed (`--detail` / `file=...`)
- output is deterministic and budgeted to reduce context churn across agent turns

Hook integration:
- `session-stop` writes `.codemap/handoff.latest.json`
- `session-start` shows a compact recent handoff summary (24h freshness window)

**CLAUDE.md** — Add to your project root to teach Claude when to run codemap:
```bash
cp /path/to/codemap/CLAUDE.md your-project/
Expand All @@ -147,6 +191,7 @@ cp /path/to/codemap/CLAUDE.md your-project/
- [x] Tree depth limiting (`--depth`)
- [x] File filtering (`--only`, `--exclude`)
- [x] Claude Code hooks & MCP server
- [x] Cross-agent handoff artifact (`.codemap/handoff.latest.json`)
- [x] Remote repo support (GitHub, GitLab)
- [ ] Enhanced analysis (entry points, key types)

Expand Down
169 changes: 151 additions & 18 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"

"codemap/handoff"
"codemap/limits"
"codemap/scanner"
"codemap/watch"
Expand Down Expand Up @@ -149,20 +151,16 @@ func hookSessionStart(root string) error {
cmd.Run()

output := buf.String()
const maxBytes = limits.MaxContextOutputBytes

if len(output) > maxBytes {
// Truncate and add warning
output = output[:maxBytes]
// Find last newline to avoid cutting mid-line
if idx := strings.LastIndex(output, "\n"); idx > maxBytes-1000 {
output = output[:idx]
}
if len(output) > limits.MaxStructureOutputBytes {
repoSummary := "repo size unknown"
if fileCountKnown {
repoSummary = fmt.Sprintf("repo has %d files", fileCount)
}
output += "\n\n... (truncated - " + repoSummary + ", use `codemap .` for full tree)\n"
output = limits.TruncateAtLineBoundary(
output,
limits.MaxStructureOutputBytes,
"\n\n... (truncated - "+repoSummary+", use `codemap .` for full tree)\n",
)
}

fmt.Print(output)
Expand All @@ -185,13 +183,23 @@ func hookSessionStart(root string) error {
fmt.Printf("ℹ️ Hub analysis skipped for large repo (%d files)\n", fileCount)
}

// Show diff vs main if on a feature branch
showDiffVsMain(root, fileCount, fileCountKnown)
currentBranch, branchKnown := gitCurrentBranch(root)
recentHandoff := getRecentHandoff(root)
recentHandoffMatchesBranch := handoffMatchesBranch(recentHandoff, currentBranch, branchKnown)
hasRecentHandoffChanges := recentHandoffMatchesBranch && handoffHasChangedFiles(recentHandoff)

// Show last session context if resuming work
if len(lastSessionEvents) > 0 {
// Show diff vs main only when we do not already have a recent structured handoff.
if !hasRecentHandoffChanges {
showDiffVsMain(root, fileCount, fileCountKnown)
}

// Show last session context only when recent handoff is unavailable/incomplete.
if len(lastSessionEvents) > 0 && !hasRecentHandoffChanges {
showLastSessionContext(root, lastSessionEvents)
}
if recentHandoffMatchesBranch {
showRecentHandoffSummary(recentHandoff)
}

return nil
}
Expand Down Expand Up @@ -313,17 +321,83 @@ func showLastSessionContext(root string, events []string) {

fmt.Println()
fmt.Println("🕐 Last session worked on:")
count := 0
for file, op := range files {
if count >= 5 {
orderedFiles := make([]string, 0, len(files))
for file := range files {
orderedFiles = append(orderedFiles, file)
}
sort.Strings(orderedFiles)

for i, file := range orderedFiles {
op := files[file]
if i >= 5 {
fmt.Printf(" ... and %d more files\n", len(files)-5)
break
}
fmt.Printf(" • %s (%s)\n", file, strings.ToLower(op))
count++
}
}

func getRecentHandoff(root string) *handoff.Artifact {
artifact, err := handoff.ReadLatest(root)
if err != nil || artifact == nil {
return nil
}
if time.Since(artifact.GeneratedAt) > 24*time.Hour {
return nil
}
return artifact
}

func handoffHasChangedFiles(artifact *handoff.Artifact) bool {
if artifact == nil {
return false
}
return len(artifact.Delta.Changed) > 0 || len(artifact.ChangedFiles) > 0
}

func handoffMatchesBranch(artifact *handoff.Artifact, currentBranch string, branchKnown bool) bool {
if artifact == nil || !branchKnown {
return false
}
return strings.TrimSpace(artifact.Branch) == strings.TrimSpace(currentBranch)
}

func gitCurrentBranch(root string) (string, bool) {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return "", false
}
branch := strings.TrimSpace(string(out))
if branch == "" {
return "", false
}
return branch, true
}

func showRecentHandoffSummary(artifact *handoff.Artifact) {
if artifact == nil {
return
}
summary := handoff.RenderCompact(artifact, 5)
if summary == "" {
return
}

if len(summary) > limits.MaxHandoffCompactBytes {
summary = limits.TruncateAtLineBoundary(
summary,
limits.MaxHandoffCompactBytes,
"\n ... (handoff summary truncated)\n",
)
}

fmt.Println()
fmt.Println("🤝 Recent handoff:")
fmt.Print(summary)
}

// startDaemon launches the watch daemon in background
func startDaemon(root string) {
exe, err := os.Executable()
Expand Down Expand Up @@ -587,10 +661,69 @@ func hookSessionStop(root string) error {
}
}

if err := writeSessionHandoff(root, state); err == nil {
fmt.Printf("🤝 Saved handoff to .codemap/handoff.latest.json\n")
}

fmt.Println()
return nil
}

func writeSessionHandoff(root string, state *watch.State) error {
baseRef := resolveHandoffBaseRef(root)
artifact, err := handoff.Build(root, handoff.BuildOptions{
State: state,
BaseRef: baseRef,
})
if err != nil {
return err
}
return handoff.WriteLatest(root, artifact)
}

func resolveHandoffBaseRef(root string) string {
if remoteDefault, ok := gitSymbolicRef(root, "refs/remotes/origin/HEAD"); ok && remoteDefault != "" {
if gitRefExists(root, remoteDefault) {
return remoteDefault
}
}

for _, ref := range []string{"main", "master", "trunk", "develop"} {
if gitRefExists(root, ref) {
return ref
}
}

for _, ref := range []string{"origin/main", "origin/master", "origin/trunk", "origin/develop"} {
if gitRefExists(root, ref) {
return ref
}
}

// Last-resort fallback that always exists in committed repos.
return "HEAD"
}

func gitRefExists(root, ref string) bool {
cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref)
cmd.Dir = root
return cmd.Run() == nil
}

func gitSymbolicRef(root, ref string) (string, bool) {
cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", ref)
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return "", false
}
value := strings.TrimSpace(string(out))
if value == "" {
return "", false
}
return value, true
}

// stopDaemon stops the watch daemon
func stopDaemon(root string) {
if !watch.IsRunning(root) {
Expand Down
Loading
Loading