From 5dfe498bc5f7845bcc563f86f01b715bddc4c889 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Thu, 5 Nov 2026 22:08:50 -0500 Subject: [PATCH 1/2] fix(hooks): fit session-start output under the 10,000-char hook cap Claude Code truncates hook stdout/additionalContext at 10,000 characters. On this repo (380 files) session-start printed 17,134 bytes, so the tree was cut mid-directory and the hubs, diff and handoff after it were lost. session-start now captures the header and trailer first, reserves their size (trailer capped at half the budget), and re-renders the tree at decreasing depth until the whole output fits, noting the depth it settled on. If depth 1 is still too large the tree is cut at a line boundary. The cap is limits.MaxHookOutputBytes, overridable with CODEMAP_HOOK_OUTPUT_CAP; the multi-repo path uses the same cap. captureStdout is extracted from emitCodexHookContext and reused. Closes #288 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FWQw8AjoCA1yjN3T59vk46 --- cmd/hooks.go | 214 +++++++++++++++++++++++++---------------- cmd/hooks_more_test.go | 52 ++++++++++ docs/HOOKS.md | 2 +- limits/limits.go | 5 + 4 files changed, 187 insertions(+), 86 deletions(-) diff --git a/cmd/hooks.go b/cmd/hooks.go index 0d9c181f..31768855 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -404,18 +404,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) @@ -428,19 +416,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, ",")) @@ -450,65 +488,65 @@ 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) + tree := renderTree(depth) + if len(tree) <= treeBudget { + return header + tree + trailer } - if recentHandoffMatchesBranch { - showRecentHandoffSummary(recentHandoff) + for depth > 1 { + depth-- + tree = renderTree(depth) + if len(tree)+len(notice(depth)) <= treeBudget { + return header + tree + notice(depth) + trailer + } } + return header + cutAtLine(tree, treeBudget-len(notice(depth)), "") + notice(depth) + trailer +} - 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. @@ -854,10 +892,30 @@ func emitCodexHookContext(event string, fn func() error, alwaysEmit bool) error if os.Getenv("CODEX") != "1" { return fn() } + 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 @@ -875,26 +933,12 @@ func emitCodexHookContext(event string, fn func() error, alwaysEmit bool) error result := <-readDone _ = reader.Close() if callErr != nil { - return callErr + return "", callErr } if closeErr != nil { - return closeErr + 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 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 @@ -2373,7 +2417,7 @@ func findChildRepos(root string) []string { } // hookSessionStartMultiRepo handles meta-repos containing multiple child repos. -// Output is capped to MaxContextOutputBytes to prevent context blowup. +// Output is capped to sessionStartOutputCap to fit 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)) @@ -2385,7 +2429,7 @@ func hookSessionStartMultiRepo(root string, childRepos []string) error { } // 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 diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 9c9cbe67..f55d4b50 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -1117,3 +1117,55 @@ 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("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 a74c3473..5e28d559 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. From c4a0a5823b51568bfadb7fab90a059c0a28f3010 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 6 Nov 2026 10:29:06 -0500 Subject: [PATCH 2/2] fix(hooks): make the session-start cap a hard ceiling fitSessionStart could exceed maxBytes when the remaining tree budget was smaller than the depth notice (any small CODEMAP_HOOK_OUTPUT_CAP), since the header and notice were appended uncapped. The notice is now dropped when it does not fit and the assembled output gets a final line cut at maxBytes. The multi-repo path only charged each child's tree against the total; the header, setup hints, separators and truncation suffix were printed outside the accounting. It now builds the whole output, sizes each repo's budget from what is left (reserving room for the "more repos" line) and applies a final cut at the cap. indexOf is no longer used. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FWQw8AjoCA1yjN3T59vk46 --- cmd/hooks.go | 78 ++++++++++++++++++++++++------------------ cmd/hooks_more_test.go | 43 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 33 deletions(-) diff --git a/cmd/hooks.go b/cmd/hooks.go index 31768855..36fe71b6 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -522,18 +522,27 @@ func fitSessionStart(header, trailer string, depth, structureBudget, maxBytes in 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) } + // 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 header + tree + trailer + return fit(tree, "") } for depth > 1 { depth-- tree = renderTree(depth) if len(tree)+len(notice(depth)) <= treeBudget { - return header + tree + notice(depth) + trailer + return fit(tree, notice(depth)) } } - return header + cutAtLine(tree, treeBudget-len(notice(depth)), "") + notice(depth) + trailer + n := notice(depth) + if len(n) > treeBudget { + n = "" + } + return fit(cutAtLine(tree, treeBudget-len(n), ""), n) } // cutAtLine returns s if it fits in maxBytes, otherwise its longest prefix @@ -2417,34 +2426,49 @@ func findChildRepos(root string) []string { } // hookSessionStartMultiRepo handles meta-repos containing multiple child repos. -// Output is capped to sessionStartOutputCap to fit the host hook limit. +// 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 := 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 { @@ -2461,34 +2485,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 f55d4b50..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"} { @@ -1161,6 +1195,15 @@ func TestFitSessionStartStaysUnderHookCap(t *testing.T) { }) } + 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))