diff --git a/README.md b/README.md index 494d0da..0e314e5 100644 --- a/README.md +++ b/README.md @@ -200,11 +200,35 @@ Example `.codemap/config.json`: { "only": ["rs", "sh", "sql", "toml", "yml"], "exclude": ["docs/reference", "docs/research"], - "depth": 4 + "depth": 4, + "mode": "auto", + "budgets": { + "session_start_bytes": 30000, + "diff_bytes": 15000, + "max_hubs": 8 + }, + "routing": { + "retrieval": { "strategy": "keyword", "top_k": 3 }, + "subsystems": [ + { + "id": "watching", + "paths": ["watch/**"], + "keywords": ["hook", "daemon", "events"], + "docs": ["docs/HOOKS.md"], + "agents": ["codemap-hook-triage"] + } + ] + }, + "drift": { + "enabled": true, + "recent_commits": 10, + "require_docs_for": ["watching"] + } } ``` All fields are optional. CLI flags always override config values. +Hook-specific policy fields are optional and bounded by safe defaults. ## Roadmap diff --git a/cmd/config.go b/cmd/config.go index 2304e60..b51efc6 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -150,7 +150,7 @@ func configInit(root string) { func configShow(root string) { cfg := config.Load(root) - if len(cfg.Only) == 0 && len(cfg.Exclude) == 0 && cfg.Depth == 0 { + if isConfigEmpty(cfg) { cfgPath := config.ConfigPath(root) if _, err := os.Stat(cfgPath); os.IsNotExist(err) { fmt.Println("No config file found.") @@ -172,4 +172,69 @@ func configShow(root string) { if cfg.Depth > 0 { fmt.Printf(" depth: %d\n", cfg.Depth) } + if strings.TrimSpace(cfg.Mode) != "" { + fmt.Printf(" mode: %s\n", cfg.ModeOrDefault()) + } + if cfg.Budgets.SessionStartBytes > 0 || cfg.Budgets.DiffBytes > 0 || cfg.Budgets.MaxHubs > 0 { + fmt.Println(" budgets:") + if cfg.Budgets.SessionStartBytes > 0 { + fmt.Printf(" session_start_bytes: %d\n", cfg.Budgets.SessionStartBytes) + } + if cfg.Budgets.DiffBytes > 0 { + fmt.Printf(" diff_bytes: %d\n", cfg.Budgets.DiffBytes) + } + if cfg.Budgets.MaxHubs > 0 { + fmt.Printf(" max_hubs: %d\n", cfg.Budgets.MaxHubs) + } + } + if strings.TrimSpace(cfg.Routing.Retrieval.Strategy) != "" || cfg.Routing.Retrieval.TopK > 0 || len(cfg.Routing.Subsystems) > 0 { + fmt.Println(" routing:") + if strings.TrimSpace(cfg.Routing.Retrieval.Strategy) != "" || cfg.Routing.Retrieval.TopK > 0 { + fmt.Printf(" retrieval: strategy=%s top_k=%d\n", cfg.RoutingStrategyOrDefault(), cfg.RoutingTopKOrDefault()) + } + if len(cfg.Routing.Subsystems) > 0 { + fmt.Printf(" subsystems: %d configured\n", len(cfg.Routing.Subsystems)) + const maxShown = 5 + for i, sub := range cfg.Routing.Subsystems { + if i >= maxShown { + fmt.Printf(" ... and %d more\n", len(cfg.Routing.Subsystems)-maxShown) + break + } + label := strings.TrimSpace(sub.ID) + if label == "" { + label = fmt.Sprintf("(unnamed-%d)", i+1) + } + fmt.Printf(" - %s (keywords=%d docs=%d agents=%d)\n", label, len(sub.Keywords), len(sub.Docs), len(sub.Agents)) + } + } + } + if cfg.Drift.Enabled || cfg.Drift.RecentCommits > 0 || len(cfg.Drift.RequireDocsFor) > 0 { + fmt.Println(" drift:") + fmt.Printf(" enabled: %t\n", cfg.Drift.Enabled) + if cfg.Drift.RecentCommits > 0 { + fmt.Printf(" recent_commits: %d\n", cfg.Drift.RecentCommits) + } + if len(cfg.Drift.RequireDocsFor) > 0 { + fmt.Printf(" require_docs_for: %s\n", strings.Join(cfg.Drift.RequireDocsFor, ", ")) + } + } +} + +func isConfigEmpty(cfg config.ProjectConfig) bool { + if len(cfg.Only) > 0 || len(cfg.Exclude) > 0 || cfg.Depth > 0 { + return false + } + if strings.TrimSpace(cfg.Mode) != "" { + return false + } + if cfg.Budgets.SessionStartBytes > 0 || cfg.Budgets.DiffBytes > 0 || cfg.Budgets.MaxHubs > 0 { + return false + } + if strings.TrimSpace(cfg.Routing.Retrieval.Strategy) != "" || cfg.Routing.Retrieval.TopK > 0 || len(cfg.Routing.Subsystems) > 0 { + return false + } + if cfg.Drift.Enabled || cfg.Drift.RecentCommits > 0 || len(cfg.Drift.RequireDocsFor) > 0 { + return false + } + return true } diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..0351ebb --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "codemap/config" +) + +func TestIsConfigEmpty(t *testing.T) { + t.Run("zero value is empty", func(t *testing.T) { + if !isConfigEmpty(config.ProjectConfig{}) { + t.Fatal("expected zero-value config to be empty") + } + }) + + t.Run("new policy fields make config non-empty", func(t *testing.T) { + cfg := config.ProjectConfig{ + Mode: "structured", + } + if isConfigEmpty(cfg) { + t.Fatal("expected config with mode set to be non-empty") + } + }) +} + +func TestConfigShow_PrintsPolicyFields(t *testing.T) { + root := t.TempDir() + codemapDir := filepath.Join(root, ".codemap") + if err := os.MkdirAll(codemapDir, 0755); err != nil { + t.Fatal(err) + } + + data := `{ + "only": ["go", "ts"], + "mode": "structured", + "budgets": { + "session_start_bytes": 26000, + "diff_bytes": 12000, + "max_hubs": 7 + }, + "routing": { + "retrieval": {"strategy": "keyword", "top_k": 4}, + "subsystems": [ + {"id": "watching", "keywords": ["hook"], "docs": ["docs/HOOKS.md"], "agents": ["codemap-hook-triage"]} + ] + }, + "drift": { + "enabled": true, + "recent_commits": 12, + "require_docs_for": ["watching"] + } + }` + if err := os.WriteFile(filepath.Join(codemapDir, "config.json"), []byte(data), 0644); err != nil { + t.Fatal(err) + } + + out := captureOutput(func() { configShow(root) }) + want := []string{ + "only:", + "mode:", + "budgets:", + "session_start_bytes", + "diff_bytes", + "max_hubs", + "routing:", + "retrieval: strategy=keyword top_k=4", + "subsystems: 1 configured", + "watching (keywords=1 docs=1 agents=1)", + "drift:", + "enabled: true", + "recent_commits: 12", + "require_docs_for: watching", + } + for _, token := range want { + if !strings.Contains(out, token) { + t.Fatalf("expected config show output to contain %q, got:\n%s", token, out) + } + } +} diff --git a/cmd/hooks.go b/cmd/hooks.go index b2c1062..bc415dc 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -37,6 +37,8 @@ const ( var isOwnedDaemonProcess = watch.IsOwnedDaemon +var promptFileExtensions = []string{"go", "tsx", "ts", "jsx", "js", "py", "rs", "rb", "java", "swift", "kt", "c", "cpp", "h"} + type HookTimeoutError struct { Hook string Timeout time.Duration @@ -288,10 +290,12 @@ func hookSessionStart(root string) error { fileCount = state.FileCount fileCountKnown = true } + projCfg := config.Load(root) + structureBudget := projCfg.SessionStartOutputBytes() + maxHubs := projCfg.HubDisplayLimit() exe, err := os.Executable() if err == nil { - projCfg := config.Load(root) depth := limits.AdaptiveDepth(fileCount) if projCfg.Depth > 0 { depth = projCfg.Depth @@ -314,14 +318,14 @@ func hookSessionStart(root string) error { cmd.Run() output := buf.String() - if len(output) > limits.MaxStructureOutputBytes { + if len(output) > structureBudget { repoSummary := "repo size unknown" if fileCountKnown { repoSummary = fmt.Sprintf("repo has %d files", fileCount) } output = limits.TruncateAtLineBoundary( output, - limits.MaxStructureOutputBytes, + structureBudget, "\n\n... (truncated - "+repoSummary+", use `codemap .` for full tree)\n", ) } @@ -335,8 +339,8 @@ func hookSessionStart(root string) error { if info != nil && len(info.Hubs) > 0 { fmt.Println("⚠️ High-impact files (hubs):") for i, hub := range info.Hubs { - if i >= 10 { - fmt.Printf(" ... and %d more\n", len(info.Hubs)-10) + if i >= maxHubs { + fmt.Printf(" ... and %d more\n", len(info.Hubs)-maxHubs) break } importers := len(info.Importers[hub]) @@ -353,7 +357,7 @@ func hookSessionStart(root string) error { // Show diff vs main only when we do not already have a recent structured handoff. if !hasRecentHandoffChanges { - showDiffVsMain(root, fileCount, fileCountKnown) + showDiffVsMain(root, fileCount, fileCountKnown, projCfg) } // Show last session context only when recent handoff is unavailable/incomplete. @@ -369,7 +373,7 @@ func hookSessionStart(root string) error { // showDiffVsMain shows files changed on this branch vs main. // For large/unknown repos, uses lightweight git output to avoid expensive scans. -func showDiffVsMain(root string, fileCount int, fileCountKnown bool) { +func showDiffVsMain(root string, fileCount int, fileCountKnown bool, projCfg config.ProjectConfig) { // Check if we're on a branch other than main branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") branchCmd.Dir = root @@ -399,7 +403,7 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool) { } // Run codemap --diff to show richer impact analysis on manageable repos. - projCfg := config.Load(root) + diffBudget := projCfg.DiffOutputBytes() args := []string{"--diff"} if len(projCfg.Only) > 0 { args = append(args, "--only", strings.Join(projCfg.Only, ",")) @@ -409,9 +413,9 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool) { } args = append(args, root) cmd := exec.Command(exe, args...) - captureLimit := limits.MaxDiffOutputBytes + diffCaptureSlackBytes - if captureLimit < limits.MaxDiffOutputBytes { - captureLimit = limits.MaxDiffOutputBytes + captureLimit := diffBudget + diffCaptureSlackBytes + if captureLimit < diffBudget { + captureLimit = diffBudget } buf := newCappedStringWriter(captureLimit) cmd.Stdout = buf @@ -419,10 +423,10 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool) { cmd.Run() output := buf.String() - if len(output) > limits.MaxDiffOutputBytes || buf.Truncated() { + if len(output) > diffBudget || buf.Truncated() { output = limits.TruncateAtLineBoundary( output, - limits.MaxDiffOutputBytes, + diffBudget, "\n\n... (diff output truncated, run `codemap --diff` for full output)\n", ) } @@ -670,18 +674,12 @@ func hookPromptSubmit(root string) error { return nil } + projCfg := config.Load(root) + topK := projCfg.RoutingTopKOrDefault() info := getHubInfoNoFallback(root) - // Look for file patterns in the prompt - var filesMentioned []string - - // Check for common source file extensions (tsx before ts so it matches first) - extensions := []string{"go", "tsx", "ts", "jsx", "js", "py", "rs", "rb", "java", "swift", "kt", "c", "cpp", "h"} - for _, ext := range extensions { - pattern := regexp.MustCompile(`[a-zA-Z0-9_/-]+\.` + ext) - matches := pattern.FindAllString(prompt, 3) - filesMentioned = append(filesMentioned, matches...) - } + // Look for file patterns in the prompt. + filesMentioned := extractMentionedFiles(prompt, topK) // Build output for mentioned files var output []string @@ -704,6 +702,7 @@ func hookPromptSubmit(root string) error { fmt.Println(line) } } + showRouteSuggestions(prompt, projCfg, topK) // Show mid-session awareness: what's been edited so far showSessionProgress(root) @@ -711,6 +710,106 @@ func hookPromptSubmit(root string) error { return nil } +func extractMentionedFiles(prompt string, limit int) []string { + if limit <= 0 { + return nil + } + + var files []string + seen := make(map[string]struct{}) + for _, ext := range promptFileExtensions { + pattern := regexp.MustCompile(`[a-zA-Z0-9_/-]+\.` + ext) + matches := pattern.FindAllString(prompt, -1) + for _, match := range matches { + if _, exists := seen[match]; exists { + continue + } + seen[match] = struct{}{} + files = append(files, match) + if len(files) >= limit { + return files + } + } + } + return files +} + +type subsystemRouteMatch struct { + ID string + Score int + Docs []string + Agents []string +} + +func matchSubsystemRoutes(prompt string, cfg config.ProjectConfig, topK int) []subsystemRouteMatch { + if topK <= 0 || cfg.RoutingStrategyOrDefault() != "keyword" { + return nil + } + + promptLower := strings.ToLower(prompt) + var matches []subsystemRouteMatch + for _, subsystem := range cfg.Routing.Subsystems { + score := 0 + for _, keyword := range subsystem.Keywords { + keyword = strings.TrimSpace(strings.ToLower(keyword)) + if keyword != "" && strings.Contains(promptLower, keyword) { + score++ + } + } + for _, pathHint := range subsystem.Paths { + pathHint = strings.TrimSpace(strings.ToLower(pathHint)) + if pathHint != "" && strings.Contains(promptLower, pathHint) { + score++ + } + } + if score <= 0 { + continue + } + + id := strings.TrimSpace(subsystem.ID) + if id == "" { + id = "(unnamed)" + } + matches = append(matches, subsystemRouteMatch{ + ID: id, + Score: score, + Docs: subsystem.Docs, + Agents: subsystem.Agents, + }) + } + + sort.Slice(matches, func(i, j int) bool { + if matches[i].Score == matches[j].Score { + return matches[i].ID < matches[j].ID + } + return matches[i].Score > matches[j].Score + }) + if len(matches) > topK { + matches = matches[:topK] + } + return matches +} + +func showRouteSuggestions(prompt string, cfg config.ProjectConfig, topK int) { + matches := matchSubsystemRoutes(prompt, cfg, topK) + if len(matches) == 0 { + return + } + + fmt.Println() + fmt.Println("📚 Suggested context routes:") + for _, match := range matches { + line := fmt.Sprintf(" • %s (score=%d)", match.ID, match.Score) + if len(match.Docs) > 0 { + line += fmt.Sprintf(" docs=%s", strings.Join(match.Docs, ", ")) + } + if len(match.Agents) > 0 { + line += fmt.Sprintf(" agents=%s", strings.Join(match.Agents, ", ")) + } + fmt.Println(line) + } +} + // showSessionProgress shows files edited so far in this session func showSessionProgress(root string) { state := watch.ReadState(root) diff --git a/cmd/hooks_test.go b/cmd/hooks_test.go index 8add0c3..0bc96f9 100644 --- a/cmd/hooks_test.go +++ b/cmd/hooks_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "codemap/config" "codemap/handoff" "codemap/limits" "codemap/watch" @@ -541,16 +542,9 @@ func TestPromptFileMentionDetection(t *testing.T) { }, } - extensions := []string{"go", "tsx", "ts", "jsx", "js", "py", "rs", "rb", "java", "swift", "kt", "c", "cpp", "h"} - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var filesMentioned []string - for _, ext := range extensions { - pattern := regexp.MustCompile(`[a-zA-Z0-9_/-]+\.` + ext) - matches := pattern.FindAllString(tt.prompt, 3) - filesMentioned = append(filesMentioned, matches...) - } + filesMentioned := extractMentionedFiles(tt.prompt, 10) if tt.wantNoFile { if len(filesMentioned) > 0 { @@ -575,6 +569,50 @@ func TestPromptFileMentionDetection(t *testing.T) { } } +func TestExtractMentionedFilesLimitAndDedup(t *testing.T) { + prompt := "check main.go then main.go and api/server.go and util/file.py" + files := extractMentionedFiles(prompt, 2) + if len(files) != 2 { + t.Fatalf("expected 2 files due to limit, got %d: %v", len(files), files) + } + if files[0] != "main.go" || files[1] != "api/server.go" { + t.Fatalf("unexpected ordered files: %v", files) + } +} + +func TestMatchSubsystemRoutes(t *testing.T) { + cfg := config.ProjectConfig{ + Routing: config.RoutingConfig{ + Retrieval: config.RetrievalConfig{Strategy: "keyword", TopK: 2}, + Subsystems: []config.Subsystem{ + { + ID: "watching", + Keywords: []string{"hook", "daemon", "events"}, + Docs: []string{"docs/HOOKS.md"}, + Agents: []string{"codemap-hook-triage"}, + }, + { + ID: "handoff", + Keywords: []string{"handoff", "delta"}, + Docs: []string{"README.md"}, + }, + }, + }, + } + + prompt := "the daemon hook events log is too noisy, handoff delta might also be stale" + matches := matchSubsystemRoutes(prompt, cfg, cfg.RoutingTopKOrDefault()) + if len(matches) != 2 { + t.Fatalf("expected top_k=2 matches, got %d: %+v", len(matches), matches) + } + if matches[0].ID != "watching" { + t.Fatalf("expected highest score route to be watching, got %+v", matches[0]) + } + if len(matches[0].Docs) != 1 || matches[0].Docs[0] != "docs/HOOKS.md" { + t.Fatalf("unexpected watching docs: %+v", matches[0].Docs) + } +} + // TestHubInfoWithMultipleHubs tests scenarios with multiple hub files func TestHubInfoWithMultipleHubs(t *testing.T) { info := &hubInfo{ diff --git a/config/config.go b/config/config.go index 295a774..f18fc66 100644 --- a/config/config.go +++ b/config/config.go @@ -5,14 +5,129 @@ import ( "fmt" "os" "path/filepath" + "strings" + + "codemap/limits" ) // ProjectConfig holds per-project defaults from .codemap/config.json. // All fields are optional; zero values mean "no preference". type ProjectConfig struct { - Only []string `json:"only,omitempty"` - Exclude []string `json:"exclude,omitempty"` - Depth int `json:"depth,omitempty"` + Only []string `json:"only,omitempty"` + Exclude []string `json:"exclude,omitempty"` + Depth int `json:"depth,omitempty"` + Mode string `json:"mode,omitempty"` + Budgets HookBudgets `json:"budgets,omitempty"` + Routing RoutingConfig `json:"routing,omitempty"` + Drift DriftConfig `json:"drift,omitempty"` +} + +// HookBudgets configures per-hook output constraints. +// Values are clamped by safe defaults to avoid context blowups. +type HookBudgets struct { + SessionStartBytes int `json:"session_start_bytes,omitempty"` + DiffBytes int `json:"diff_bytes,omitempty"` + MaxHubs int `json:"max_hubs,omitempty"` +} + +// RoutingConfig controls prompt-submit retrieval hints. +type RoutingConfig struct { + Retrieval RetrievalConfig `json:"retrieval,omitempty"` + Subsystems []Subsystem `json:"subsystems,omitempty"` +} + +// RetrievalConfig sets prompt-submit retrieval behavior. +type RetrievalConfig struct { + Strategy string `json:"strategy,omitempty"` + TopK int `json:"top_k,omitempty"` +} + +// Subsystem is a lightweight task-routing entry used by prompt-submit hooks. +type Subsystem struct { + ID string `json:"id,omitempty"` + Paths []string `json:"paths,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Docs []string `json:"docs,omitempty"` + Agents []string `json:"agents,omitempty"` +} + +// DriftConfig stores optional doc drift policy metadata. +// The current hooks only read this for display/forward compatibility. +type DriftConfig struct { + Enabled bool `json:"enabled,omitempty"` + RecentCommits int `json:"recent_commits,omitempty"` + RequireDocsFor []string `json:"require_docs_for,omitempty"` +} + +const ( + defaultMode = "auto" + defaultRoutingStrategy = "keyword" + defaultRoutingTopK = 3 + defaultMaxHubs = 10 + maxMaxHubs = 100 +) + +func clampBudget(v, def, max int) int { + if v <= 0 { + return def + } + if max > 0 && v > max { + return max + } + return v +} + +func clampRange(v, def, min, max int) int { + if v <= 0 { + return def + } + if v < min { + return min + } + if max > 0 && v > max { + return max + } + return v +} + +// ModeOrDefault returns a valid hook orchestration mode. +func (c ProjectConfig) ModeOrDefault() string { + mode := strings.ToLower(strings.TrimSpace(c.Mode)) + switch mode { + case "auto", "structured", "ad-hoc": + return mode + default: + return defaultMode + } +} + +// SessionStartOutputBytes returns a bounded session-start structure budget. +func (c ProjectConfig) SessionStartOutputBytes() int { + return clampBudget(c.Budgets.SessionStartBytes, limits.MaxStructureOutputBytes, limits.MaxContextOutputBytes) +} + +// DiffOutputBytes returns a bounded diff output budget. +func (c ProjectConfig) DiffOutputBytes() int { + return clampBudget(c.Budgets.DiffBytes, limits.MaxDiffOutputBytes, limits.MaxContextOutputBytes) +} + +// HubDisplayLimit returns how many hubs session-start should print. +func (c ProjectConfig) HubDisplayLimit() int { + return clampRange(c.Budgets.MaxHubs, defaultMaxHubs, 1, maxMaxHubs) +} + +// RoutingStrategyOrDefault returns a supported routing strategy. +func (c ProjectConfig) RoutingStrategyOrDefault() string { + strategy := strings.ToLower(strings.TrimSpace(c.Routing.Retrieval.Strategy)) + if strategy == defaultRoutingStrategy { + return strategy + } + return defaultRoutingStrategy +} + +// RoutingTopKOrDefault returns a bounded top-k value for prompt-submit routing. +func (c ProjectConfig) RoutingTopKOrDefault() int { + return clampRange(c.Routing.Retrieval.TopK, defaultRoutingTopK, 1, 20) } // ConfigPath returns the path to .codemap/config.json for the given root. diff --git a/config/config_test.go b/config/config_test.go index 11f9c31..4e140b6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "codemap/limits" ) func TestLoad_MissingFile(t *testing.T) { @@ -23,7 +25,32 @@ func TestLoad_ValidConfig(t *testing.T) { data := `{ "only": ["rs", "sh", "sql"], "exclude": ["docs/reference", "vendor"], - "depth": 3 + "depth": 3, + "mode": "structured", + "budgets": { + "session_start_bytes": 28000, + "diff_bytes": 11000, + "max_hubs": 6 + }, + "routing": { + "retrieval": { + "strategy": "keyword", + "top_k": 4 + }, + "subsystems": [ + { + "id": "watching", + "keywords": ["hook", "daemon"], + "docs": ["docs/HOOKS.md"], + "agents": ["codemap-hook-triage"] + } + ] + }, + "drift": { + "enabled": true, + "recent_commits": 9, + "require_docs_for": ["watching"] + } }` if err := os.WriteFile(filepath.Join(codemapDir, "config.json"), []byte(data), 0644); err != nil { t.Fatal(err) @@ -39,6 +66,33 @@ func TestLoad_ValidConfig(t *testing.T) { if cfg.Depth != 3 { t.Errorf("unexpected Depth: %d", cfg.Depth) } + if cfg.Mode != "structured" { + t.Errorf("unexpected Mode: %q", cfg.Mode) + } + if cfg.Budgets.SessionStartBytes != 28000 { + t.Errorf("unexpected SessionStartBytes: %d", cfg.Budgets.SessionStartBytes) + } + if cfg.Budgets.DiffBytes != 11000 { + t.Errorf("unexpected DiffBytes: %d", cfg.Budgets.DiffBytes) + } + if cfg.Budgets.MaxHubs != 6 { + t.Errorf("unexpected MaxHubs: %d", cfg.Budgets.MaxHubs) + } + if got := cfg.RoutingTopKOrDefault(); got != 4 { + t.Errorf("unexpected routing top_k: %d", got) + } + if len(cfg.Routing.Subsystems) != 1 || cfg.Routing.Subsystems[0].ID != "watching" { + t.Errorf("unexpected Routing.Subsystems: %+v", cfg.Routing.Subsystems) + } + if !cfg.Drift.Enabled { + t.Errorf("expected drift enabled, got false") + } + if cfg.Drift.RecentCommits != 9 { + t.Errorf("unexpected Drift.RecentCommits: %d", cfg.Drift.RecentCommits) + } + if len(cfg.Drift.RequireDocsFor) != 1 || cfg.Drift.RequireDocsFor[0] != "watching" { + t.Errorf("unexpected Drift.RequireDocsFor: %v", cfg.Drift.RequireDocsFor) + } } func TestLoad_PartialConfig(t *testing.T) { @@ -108,3 +162,62 @@ func TestConfigPath(t *testing.T) { t.Errorf("ConfigPath = %q, want %q", got, want) } } + +func TestPolicyDefaultsAndClamps(t *testing.T) { + t.Run("defaults for empty config", func(t *testing.T) { + var cfg ProjectConfig + if got := cfg.ModeOrDefault(); got != "auto" { + t.Fatalf("ModeOrDefault() = %q, want auto", got) + } + if got := cfg.SessionStartOutputBytes(); got != limits.MaxStructureOutputBytes { + t.Fatalf("SessionStartOutputBytes() = %d, want %d", got, limits.MaxStructureOutputBytes) + } + if got := cfg.DiffOutputBytes(); got != limits.MaxDiffOutputBytes { + t.Fatalf("DiffOutputBytes() = %d, want %d", got, limits.MaxDiffOutputBytes) + } + if got := cfg.HubDisplayLimit(); got != 10 { + t.Fatalf("HubDisplayLimit() = %d, want 10", got) + } + if got := cfg.RoutingStrategyOrDefault(); got != "keyword" { + t.Fatalf("RoutingStrategyOrDefault() = %q, want keyword", got) + } + if got := cfg.RoutingTopKOrDefault(); got != 3 { + t.Fatalf("RoutingTopKOrDefault() = %d, want 3", got) + } + }) + + t.Run("clamps unsafe values", func(t *testing.T) { + cfg := ProjectConfig{ + Mode: "invalid", + Budgets: HookBudgets{ + SessionStartBytes: limits.MaxContextOutputBytes * 5, + DiffBytes: limits.MaxContextOutputBytes * 4, + MaxHubs: 500, + }, + Routing: RoutingConfig{ + Retrieval: RetrievalConfig{ + Strategy: "semantic", + TopK: 0, + }, + }, + } + if got := cfg.ModeOrDefault(); got != "auto" { + t.Fatalf("ModeOrDefault() = %q, want auto", got) + } + if got := cfg.SessionStartOutputBytes(); got != limits.MaxContextOutputBytes { + t.Fatalf("SessionStartOutputBytes() = %d, want %d", got, limits.MaxContextOutputBytes) + } + if got := cfg.DiffOutputBytes(); got != limits.MaxContextOutputBytes { + t.Fatalf("DiffOutputBytes() = %d, want %d", got, limits.MaxContextOutputBytes) + } + if got := cfg.HubDisplayLimit(); got != 100 { + t.Fatalf("HubDisplayLimit() = %d, want 100", got) + } + if got := cfg.RoutingStrategyOrDefault(); got != "keyword" { + t.Fatalf("RoutingStrategyOrDefault() = %q, want keyword", got) + } + if got := cfg.RoutingTopKOrDefault(); got != 3 { + t.Fatalf("RoutingTopKOrDefault() = %d, want 3", got) + } + }) +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 21f7067..466bb6f 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -109,7 +109,30 @@ Example `.codemap/config.json`: { "only": ["rs", "sh", "sql", "toml", "yml"], "exclude": ["docs/reference", "docs/research"], - "depth": 4 + "depth": 4, + "mode": "auto", + "budgets": { + "session_start_bytes": 30000, + "diff_bytes": 15000, + "max_hubs": 8 + }, + "routing": { + "retrieval": { "strategy": "keyword", "top_k": 3 }, + "subsystems": [ + { + "id": "watching", + "paths": ["watch/**"], + "keywords": ["hook", "daemon", "events"], + "docs": ["docs/HOOKS.md"], + "agents": ["codemap-hook-triage"] + } + ] + }, + "drift": { + "enabled": true, + "recent_commits": 10, + "require_docs_for": ["watching"] + } } ``` @@ -117,6 +140,10 @@ All fields are optional. When set: - `only` — session-start tree shows only files with these extensions - `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`) +- `routing` — optional prompt-submit routing hints (keyword `strategy`, `top_k`, subsystem docs/agents) +- `drift` — optional drift policy metadata for external checks CLI flags (`--only`, `--exclude`, `--depth`) always override config values. The bare `codemap` command also respects this config. diff --git a/watch/events.go b/watch/events.go index 7f13f08..5570ec7 100644 --- a/watch/events.go +++ b/watch/events.go @@ -18,11 +18,68 @@ import ( "github.com/fsnotify/fsnotify" ) +// eventDebouncer coalesces rapid successive WRITE events for the same path. +// Non-WRITE operations are never debounced so create/remove transitions stay accurate. +type eventDebouncer struct { + window time.Duration + pruneAfter time.Duration + lastSeen map[string]time.Time + lastPruned time.Time +} + +func newEventDebouncer(window time.Duration) *eventDebouncer { + pruneAfter := 10 * window + if pruneAfter < time.Second { + pruneAfter = time.Second + } + return &eventDebouncer{ + window: window, + pruneAfter: pruneAfter, + lastSeen: make(map[string]time.Time), + } +} + +func (d *eventDebouncer) shouldSkip(event fsnotify.Event, now time.Time) bool { + op := event.Op + // Never debounce transitions that include create/remove/rename bits, + // even if they also carry WRITE, so lifecycle tracking stays accurate. + if op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { + return false + } + // Only debounce pure write events (allow CHMOD alongside WRITE). + if op&fsnotify.Write == 0 { + return false + } + allowedWriteMask := fsnotify.Write | fsnotify.Chmod + if op&^allowedWriteMask != 0 { + return false + } + + if last, exists := d.lastSeen[event.Name]; exists && now.Sub(last) < d.window { + return true + } + d.lastSeen[event.Name] = now + + if d.lastPruned.IsZero() || now.Sub(d.lastPruned) >= d.pruneAfter { + d.prune(now) + d.lastPruned = now + } + + return false +} + +func (d *eventDebouncer) prune(now time.Time) { + cutoff := now.Add(-d.pruneAfter) + for path, ts := range d.lastSeen { + if ts.Before(cutoff) { + delete(d.lastSeen, path) + } + } +} + // eventLoop processes file system events func (d *Daemon) eventLoop() { - // Debounce rapid changes (e.g., save + format) - debounce := make(map[string]time.Time) - debounceWindow := 100 * time.Millisecond + debouncer := newEventDebouncer(100 * time.Millisecond) for { select { @@ -50,13 +107,9 @@ func (d *Daemon) eventLoop() { } } - // Debounce rapid events on same file - if last, exists := debounce[event.Name]; exists { - if time.Since(last) < debounceWindow { - continue - } + if debouncer.shouldSkip(event, time.Now()) { + continue } - debounce[event.Name] = time.Now() // Process the event d.handleEvent(event) @@ -125,6 +178,12 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { case "CREATE", "WRITE": info, err := os.Stat(fsEvent.Name) if err != nil { + // Event delivery can race file deletion (e.g., atomic saves or temp + // files); if the path disappeared, clear any stale tracked entry. + if os.IsNotExist(err) { + delete(d.graph.Files, relPath) + delete(d.graph.State, relPath) + } d.graph.mu.Unlock() return } diff --git a/watch/events_debounce_test.go b/watch/events_debounce_test.go new file mode 100644 index 0000000..f4934b6 --- /dev/null +++ b/watch/events_debounce_test.go @@ -0,0 +1,78 @@ +package watch + +import ( + "testing" + "time" + + "github.com/fsnotify/fsnotify" +) + +func TestEventDebouncerSkipsRapidWrites(t *testing.T) { + debouncer := newEventDebouncer(100 * time.Millisecond) + base := time.Unix(0, 0) + event := fsnotify.Event{Name: "src/file.go", Op: fsnotify.Write} + + if debouncer.shouldSkip(event, base) { + t.Fatal("first write should not be skipped") + } + if !debouncer.shouldSkip(event, base.Add(50*time.Millisecond)) { + t.Fatal("rapid write should be skipped") + } + if debouncer.shouldSkip(event, base.Add(150*time.Millisecond)) { + t.Fatal("write outside debounce window should not be skipped") + } +} + +func TestEventDebouncerDoesNotSkipNonWriteOps(t *testing.T) { + debouncer := newEventDebouncer(100 * time.Millisecond) + base := time.Unix(0, 0) + path := "src/tmp.go" + + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Create}, base) { + t.Fatal("create should not be skipped") + } + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Remove}, base.Add(5*time.Millisecond)) { + t.Fatal("remove should not be skipped even after rapid create") + } + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Write}, base.Add(10*time.Millisecond)) { + t.Fatal("first write should not be skipped") + } + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Rename}, base.Add(15*time.Millisecond)) { + t.Fatal("rename should not be skipped even after rapid write") + } +} + +func TestEventDebouncerDoesNotSkipCombinedLifecycleOps(t *testing.T) { + debouncer := newEventDebouncer(100 * time.Millisecond) + base := time.Unix(0, 0) + path := "src/tmp.go" + + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Create | fsnotify.Write}, base) { + t.Fatal("create+write should not be skipped") + } + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Rename | fsnotify.Write}, base.Add(10*time.Millisecond)) { + t.Fatal("rename+write should not be skipped") + } + if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Remove | fsnotify.Write}, base.Add(20*time.Millisecond)) { + t.Fatal("remove+write should not be skipped") + } +} + +func TestEventDebouncerPrunesStaleEntries(t *testing.T) { + debouncer := newEventDebouncer(100 * time.Millisecond) + base := time.Unix(0, 0) + + debouncer.shouldSkip(fsnotify.Event{Name: "src/old.go", Op: fsnotify.Write}, base) + if len(debouncer.lastSeen) != 1 { + t.Fatalf("expected 1 tracked path, got %d", len(debouncer.lastSeen)) + } + + debouncer.shouldSkip(fsnotify.Event{Name: "src/new.go", Op: fsnotify.Write}, base.Add(2*time.Second)) + + if _, exists := debouncer.lastSeen["src/old.go"]; exists { + t.Fatal("expected stale path entry to be pruned") + } + if _, exists := debouncer.lastSeen["src/new.go"]; !exists { + t.Fatal("expected recent path entry to be retained") + } +} diff --git a/watch/events_handle_test.go b/watch/events_handle_test.go new file mode 100644 index 0000000..025016d --- /dev/null +++ b/watch/events_handle_test.go @@ -0,0 +1,40 @@ +package watch + +import ( + "path/filepath" + "testing" + + "codemap/scanner" + + "github.com/fsnotify/fsnotify" +) + +func TestHandleEventMissingWriteClearsStaleTrackedFile(t *testing.T) { + root := t.TempDir() + rel := "ghost.go" + abs := filepath.Join(root, rel) + + d := &Daemon{ + root: root, + graph: &Graph{ + Files: map[string]*scanner.FileInfo{ + rel: {Path: rel, Size: 32, Ext: ".go"}, + }, + State: map[string]*FileState{ + rel: {Lines: 3, Size: 32}, + }, + }, + } + + d.handleEvent(fsnotify.Event{Name: abs, Op: fsnotify.Write}) + + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + + if _, exists := d.graph.Files[rel]; exists { + t.Fatalf("expected stale file %q to be removed from graph.Files", rel) + } + if _, exists := d.graph.State[rel]; exists { + t.Fatalf("expected stale file %q to be removed from graph.State", rel) + } +}