diff --git a/cmd/hooks.go b/cmd/hooks.go index f1463d59..5ba6a346 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -421,18 +421,6 @@ func hookSessionStart(root string) error { ensureSessionDaemon(root, hookSessionIDFromStdin()) - fmt.Println("📍 Project Context:") - fmt.Println() - - // IMPORTANT: Hook output goes directly into Claude's "Messages" context, not system prompt. - // This means hook output competes with conversation history for the ~200k token limit. - // A 1.3MB output (like a full tree of a 10k file repo) = ~500k tokens = instant context overflow. - // - // We enforce two limits: - // 1. Adaptive depth: larger repos get shallower trees (depth 2-4 based on file count) - // 2. Hard cap: 60KB max output (~15k tokens, <10% of context window) - // - // Future: Consider structured output that Claude Code can format/truncate intelligently. fileCount := 0 fileCountKnown := false state := watch.ReadState(root) @@ -445,19 +433,69 @@ func hookSessionStart(root string) error { structureBudget := projCfg.SessionStartOutputBytes() maxHubs := projCfg.HubDisplayLimit() - showConfigSetupHint(root) - if line := integrationDriftLine(hostCodemapMCPEntries(root, detectAgentID())); line != "" { - fmt.Println(line) + header, err := captureStdout(func() error { + fmt.Println("📍 Project Context:") fmt.Println() + showConfigSetupHint(root) + if line := integrationDriftLine(hostCodemapMCPEntries(root, detectAgentID())); line != "" { + fmt.Println(line) + fmt.Println() + } + return nil + }) + if err != nil { + return err } - exe, err := os.Executable() - if err == nil { - depth := limits.AdaptiveDepth(fileCount) - if projCfg.Depth > 0 { - depth = projCfg.Depth + trailer, err := captureStdout(func() error { + // Show hub files (from daemon if running, otherwise fresh scan) + info := getHubInfo(root) + if info != nil && len(info.Hubs) > 0 { + fmt.Println("âš ī¸ High-impact files (hubs):") + for i, hub := range info.Hubs { + if i >= maxHubs { + fmt.Printf(" ... and %d more\n", len(info.Hubs)-maxHubs) + break + } + importers := len(info.Importers[hub]) + fmt.Printf(" âš ī¸ HUB FILE: %s (imported by %s)\n", hub, filesText(importers)) + } + } else if shouldSkipHubAnalysis(fileCount, fileCountKnown) { + fmt.Printf("â„šī¸ Hub analysis skipped for large repo (%d files)\n", fileCount) + } + + currentBranch, branchKnown := gitCurrentBranch(root) + recentHandoff := getRecentHandoff(root) + recentHandoffMatchesBranch := handoffMatchesBranch(recentHandoff, currentBranch, branchKnown) + hasRecentHandoffChanges := recentHandoffMatchesBranch && handoffHasChangedFiles(recentHandoff) + + // Show diff vs main only when we do not already have a recent structured handoff. + if !hasRecentHandoffChanges { + showDiffVsMain(root, fileCount, fileCountKnown, projCfg) + } + + // 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 + }) + if err != nil { + return err + } + depth := limits.AdaptiveDepth(fileCount) + if projCfg.Depth > 0 { + depth = projCfg.Depth + } + exe, exeErr := os.Executable() + renderTree := func(depth int) string { + if exeErr != nil { + return "" + } args := projectpath.PrependSetupRootArgs("--depth", strconv.Itoa(depth)) if len(projCfg.Only) > 0 { args = append(args, "--only", strings.Join(projCfg.Only, ",")) @@ -467,65 +505,74 @@ func hookSessionStart(root string) error { } args = append(args, root) cmd := exec.Command(exe, args...) - - // Capture output to enforce size limit var buf strings.Builder cmd.Stdout = &buf cmd.Stderr = os.Stderr cmd.Run() + return buf.String() + "\n" + } - output := buf.String() - if len(output) > structureBudget { - repoSummary := "repo size unknown" - if fileCountKnown { - repoSummary = fmt.Sprintf("repo has %d files", fileCount) - } - output = limits.TruncateAtLineBoundary( - output, - structureBudget, - "\n\n... (truncated - "+repoSummary+", use `codemap .` for full tree)\n", - ) - } + fmt.Print(fitSessionStart(header, trailer, depth, structureBudget, sessionStartOutputCap(), renderTree)) - fmt.Print(output) - fmt.Println() - } + return nil +} - // Show hub files (from daemon if running, otherwise fresh scan) - info := getHubInfo(root) - if info != nil && len(info.Hubs) > 0 { - fmt.Println("âš ī¸ High-impact files (hubs):") - for i, hub := range info.Hubs { - if i >= maxHubs { - fmt.Printf(" ... and %d more\n", len(info.Hubs)-maxHubs) - break - } - importers := len(info.Importers[hub]) - fmt.Printf(" âš ī¸ HUB FILE: %s (imported by %s)\n", hub, filesText(importers)) - } - } else if shouldSkipHubAnalysis(fileCount, fileCountKnown) { - fmt.Printf("â„šī¸ Hub analysis skipped for large repo (%d files)\n", fileCount) +// sessionStartOutputCap is the whole session-start budget; CODEMAP_HOOK_OUTPUT_CAP +// overrides it for hosts with a different limit. +func sessionStartOutputCap() int { + if n, err := strconv.Atoi(os.Getenv("CODEMAP_HOOK_OUTPUT_CAP")); err == nil && n > 0 { + return n } + return limits.MaxHookOutputBytes +} - currentBranch, branchKnown := gitCurrentBranch(root) - recentHandoff := getRecentHandoff(root) - recentHandoffMatchesBranch := handoffMatchesBranch(recentHandoff, currentBranch, branchKnown) - hasRecentHandoffChanges := recentHandoffMatchesBranch && handoffHasChangedFiles(recentHandoff) - - // Show diff vs main only when we do not already have a recent structured handoff. - if !hasRecentHandoffChanges { - showDiffVsMain(root, fileCount, fileCountKnown, projCfg) +// fitSessionStart assembles header, tree and trailer within maxBytes. The +// trailer (hubs, diff, handoff) is reserved first, capped at half the budget; +// the tree then drops depth until it fits, and is cut at a line as a last resort. +func fitSessionStart(header, trailer string, depth, structureBudget, maxBytes int, renderTree func(depth int) string) string { + trailer = cutAtLine(trailer, maxBytes/2, "... (truncated to fit the hook output cap)\n") + treeBudget := maxBytes - len(header) - len(trailer) + if structureBudget < treeBudget { + treeBudget = structureBudget + } + notice := func(depth int) string { + return fmt.Sprintf("(tree limited to depth %d to fit the %d-char hook cap; run `codemap .` for the full tree)\n\n", depth, maxBytes) } - // Show last session context only when recent handoff is unavailable/incomplete. - if len(lastSessionEvents) > 0 && !hasRecentHandoffChanges { - showLastSessionContext(root, lastSessionEvents) + // Header and notice are uncapped, so a tiny cap can still overflow; the + // final cut makes maxBytes a hard ceiling. + fit := func(tree, notice string) string { + return cutAtLine(header+tree+notice+trailer, maxBytes, "") + } + tree := renderTree(depth) + if len(tree) <= treeBudget { + return fit(tree, "") + } + for depth > 1 { + depth-- + tree = renderTree(depth) + if len(tree)+len(notice(depth)) <= treeBudget { + return fit(tree, notice(depth)) + } } - if recentHandoffMatchesBranch { - showRecentHandoffSummary(recentHandoff) + n := notice(depth) + if len(n) > treeBudget { + n = "" } + return fit(cutAtLine(tree, treeBudget-len(n), ""), n) +} - return nil +// cutAtLine returns s if it fits in maxBytes, otherwise its longest prefix +// of whole lines that leaves room for suffix, followed by suffix. +func cutAtLine(s string, maxBytes int, suffix string) string { + if len(s) <= maxBytes { + return s + } + limit := maxBytes - len(suffix) + if limit <= 0 { + return "" + } + return s[:strings.LastIndex(s[:limit], "\n")+1] + suffix } // showDiffVsMain shows files changed on this branch vs main. @@ -867,10 +914,30 @@ func hookPostEdit(root string) error { // carries no context and is valid under the common hook-output schema; // without it, an empty capture stays silent. func emitHookContext(event string, fn func() error, alwaysEmit bool) error { + output, err := captureStdout(fn) + if err != nil { + return err + } + if len(strings.TrimSpace(output)) == 0 { + if !alwaysEmit { + return nil + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"continue": true}) + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{ + "hookSpecificOutput": map[string]any{ + "hookEventName": event, + "additionalContext": output, + }, + }) +} + +// captureStdout runs fn with os.Stdout redirected and returns what it printed. +func captureStdout(fn func() error) (string, error) { original := os.Stdout reader, writer, err := os.Pipe() if err != nil { - return err + return "", err } type readResult struct { output []byte @@ -888,26 +955,12 @@ func emitHookContext(event string, fn func() error, alwaysEmit bool) error { result := <-readDone _ = reader.Close() if callErr != nil { - return callErr + return "", callErr } if closeErr != nil { - return closeErr - } - if result.err != nil { - return result.err - } - if len(strings.TrimSpace(string(result.output))) == 0 { - if !alwaysEmit { - return nil - } - return json.NewEncoder(original).Encode(map[string]any{"continue": true}) + return "", closeErr } - return json.NewEncoder(original).Encode(map[string]any{ - "hookSpecificOutput": map[string]any{ - "hookEventName": event, - "additionalContext": string(result.output), - }, - }) + return string(result.output), result.err } // hookPromptSubmit analyzes user prompt with code intelligence and shows context @@ -2386,34 +2439,49 @@ func findChildRepos(root string) []string { } // hookSessionStartMultiRepo handles meta-repos containing multiple child repos. -// Output is capped to MaxContextOutputBytes to prevent context blowup. +// Every emitted byte counts against sessionStartOutputCap, the host hook limit. func hookSessionStartMultiRepo(root string, childRepos []string) error { - fmt.Println("📍 Multi-Repo Project Context:") - fmt.Printf(" %d repositories in %s\n", len(childRepos), filepath.Base(root)) - fmt.Println() - exe, err := hookExecutablePath() if err != nil { return err } - // Budget: divide total budget across repos, with a per-repo cap - totalBudget := limits.MaxContextOutputBytes + totalBudget := sessionStartOutputCap() perRepoBudget := totalBudget / len(childRepos) if perRepoBudget < 2000 { perRepoBudget = 2000 // minimum useful output per repo } - totalUsed := 0 + const truncated = "\n ... (repo output truncated)\n" + more := func(n int) string { + return fmt.Sprintf(" ... and %d more repos (output budget reached)\n", n) + } - for _, repo := range childRepos { - if totalUsed >= totalBudget { - fmt.Printf(" ... and %d more repos (output budget reached)\n", len(childRepos)-indexOf(childRepos, repo)) - break - } + var out strings.Builder + fmt.Fprintf(&out, "📍 Multi-Repo Project Context:\n %d repositories in %s\n\n", len(childRepos), filepath.Base(root)) + for i, repo := range childRepos { repoPath := filepath.Join(root, repo) projCfg := config.Load(repoPath) - showConfigSetupHint(repoPath) + hint, err := captureStdout(func() error { + showConfigSetupHint(repoPath) + return nil + }) + if err != nil { + return err + } + + reserve := 0 + if rest := len(childRepos) - i - 1; rest > 0 { + reserve = len(more(rest)) + } + budget := totalBudget - out.Len() - len(hint) - 1 - reserve - len(truncated) + if budget > perRepoBudget { + budget = perRepoBudget + } + if budget <= 0 { + out.WriteString(more(len(childRepos) - i)) + break + } depth := 2 if projCfg.Depth > 0 { @@ -2430,34 +2498,22 @@ func hookSessionStartMultiRepo(root string, childRepos []string) error { args = append(args, repoPath) cmd := hookExecCommand(exe, args...) - // Capture and budget output instead of piping directly to stdout - buf := newCappedStringWriter(perRepoBudget + diffCaptureSlackBytes) + buf := newCappedStringWriter(budget + diffCaptureSlackBytes) cmd.Stdout = buf cmd.Stderr = os.Stderr cmd.Run() output := buf.String() - if len(output) > perRepoBudget || buf.Truncated() { - output = limits.TruncateAtLineBoundary(output, perRepoBudget, - "\n ... (repo output truncated)\n") + if len(output) > budget || buf.Truncated() { + output = limits.TruncateAtLineBoundary(output, budget, truncated) } - fmt.Print(output) - fmt.Println() - totalUsed += len(output) + out.WriteString(hint + output + "\n") } + fmt.Print(cutAtLine(out.String(), totalBudget, "")) return nil } -func indexOf(slice []string, val string) int { - for i, s := range slice { - if s == val { - return i - } - } - return len(slice) -} - // filesText and importersNoun keep counts grammatical: "1 file", "2 files". func filesText(n int) string { if n == 1 { diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 9c9cbe67..a6a0bae9 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -1000,6 +1001,39 @@ func TestDaemonCommandHelpersAndMultiRepoShellout(t *testing.T) { } }) + t.Run("multi repo start stays under the total cap", func(t *testing.T) { + root := t.TempDir() + repos := []string{"svc-a", "svc-b", "svc-c", "svc-d", "svc-e", "svc-f"} + for _, repo := range repos { + if err := os.MkdirAll(filepath.Join(root, repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + } + withHookRuntimeStubs( + t, + func() (string, error) { return "/tmp/codemap-hook", nil }, + func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "yes 'src/some/file.go' | head -c 20000") + }, + nil, + nil, + ) + for _, capBytes := range []int{50, 3000, limits.MaxHookOutputBytes} { + t.Setenv("CODEMAP_HOOK_OUTPUT_CAP", strconv.Itoa(capBytes)) + out := captureOutput(func() { + if err := hookSessionStartMultiRepo(root, repos); err != nil { + t.Fatalf("hookSessionStartMultiRepo() error: %v", err) + } + }) + if len(out) > capBytes { + t.Fatalf("cap %d: output is %d bytes", capBytes, len(out)) + } + if capBytes == limits.MaxHookOutputBytes && !strings.Contains(out, "more repos (output budget reached)") { + t.Fatalf("missing budget notice:\n%s", out) + } + } + }) + t.Run("multi repo start emits setup hints for child repos needing config", func(t *testing.T) { root := t.TempDir() for _, repo := range []string{"svc-a", "svc-b"} { @@ -1117,3 +1151,64 @@ func TestAutomaticLinkedWorktreeUsesLocalHookState(t *testing.T) { t.Fatalf("linked session lease entries = %d, err = %v", len(entries), err) } } + +func TestFitSessionStartStaysUnderHookCap(t *testing.T) { + const capBytes = limits.MaxHookOutputBytes + header := "📍 Project Context:\n\n" + trailer := "âš ī¸ High-impact files (hubs):\nNext codemap: codemap --deps\n" + // Each extra level of depth multiplies the tree by ten, like a wide repo. + synthetic := func(lineLen int) func(int) string { + return func(depth int) string { + n := 1 + for i := 0; i < depth; i++ { + n *= 10 + } + return strings.Repeat(strings.Repeat("x", lineLen-1)+"\n", n) + } + } + + t.Run("small tree is untouched", func(t *testing.T) { + got := fitSessionStart(header, trailer, 2, limits.MaxStructureOutputBytes, capBytes, synthetic(20)) + if want := header + synthetic(20)(2) + trailer; got != want { + t.Fatalf("small output changed:\n%s", got) + } + }) + + for name, lineLen := range map[string]int{"depth reduced": 50, "depth 1 still too big": 2000} { + t.Run(name, func(t *testing.T) { + got := fitSessionStart(header, trailer, 4, limits.MaxStructureOutputBytes, capBytes, synthetic(lineLen)) + if len(got) > capBytes { + t.Fatalf("output is %d bytes, cap %d", len(got), capBytes) + } + if !strings.HasPrefix(got, header) || !strings.HasSuffix(got, trailer) { + t.Fatalf("header or trailer lost:\n%s", got) + } + if !strings.Contains(got, "tree limited to depth") { + t.Fatalf("missing depth notice:\n%s", got) + } + tree := strings.TrimSuffix(strings.TrimPrefix(got, header), trailer) + for _, line := range strings.Split(tree, "\n") { + if strings.HasPrefix(line, "x") && len(line) != lineLen-1 { + t.Fatalf("tree cut mid-line: %d-byte line", len(line)) + } + } + }) + } + + t.Run("tiny cap is a hard ceiling", func(t *testing.T) { + for _, tiny := range []int{1, 40, 120} { + got := fitSessionStart(header, trailer, 4, limits.MaxStructureOutputBytes, tiny, synthetic(50)) + if len(got) > tiny { + t.Fatalf("cap %d: output is %d bytes", tiny, len(got)) + } + } + }) + + t.Run("oversized trailer is capped", func(t *testing.T) { + big := strings.Repeat("diff line\n", 3000) + got := fitSessionStart(header, big, 2, limits.MaxStructureOutputBytes, capBytes, synthetic(20)) + if len(got) > capBytes { + t.Fatalf("output is %d bytes, cap %d", len(got), capBytes) + } + }) +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 3812a280..4faf6ea9 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -204,7 +204,7 @@ All fields are optional. When set: - `exclude` — hides matching paths from the tree - `depth` — overrides the adaptive depth calculation - `mode` — optional hook orchestration hint (`auto`, `structured`, `ad-hoc`) -- `budgets` — optional hook budgets (`session_start_bytes`, `diff_bytes`, `max_hubs`) +- `budgets` — optional hook budgets (`session_start_bytes`, `diff_bytes`, `max_hubs`). Whatever these say, the whole session-start output is held to 10,000 bytes, Claude Code's hook output cap: the tree drops depth until it fits and says so. Set `CODEMAP_HOOK_OUTPUT_CAP` to change the cap for another host. - `routing` — prompt-submit routing hints (keyword `strategy`, `top_k`, subsystem definitions) - `subsystems[].instructions` — markdown instructions injected when this subsystem matches (e.g., domain-specific guidance for Claude) - `drift` — documentation drift detection diff --git a/limits/limits.go b/limits/limits.go index 086fd49c..288bf882 100644 --- a/limits/limits.go +++ b/limits/limits.go @@ -3,6 +3,11 @@ package limits // Context output budgets for hook and MCP text responses. const ( MaxContextOutputBytes = 60000 // ~15k tokens, <10% of a 200k context window + + // MaxHookOutputBytes is Claude Code's cap on hook stdout/additionalContext + // (10,000 characters); text past it is cut by the host. Bytes overcount + // characters, so a byte budget stays under the cap. + MaxHookOutputBytes = 10000 ) // Watch daemon retention limits to keep long-running sessions bounded.