diff --git a/cmd/gortex/doctor.go b/cmd/gortex/doctor.go index 3872200a..895c2bc2 100644 --- a/cmd/gortex/doctor.go +++ b/cmd/gortex/doctor.go @@ -198,8 +198,10 @@ func runDoctor(cmd *cobra.Command, _ []string) error { // second error line — the findings already said what is wrong, and cobra // would otherwise echo the error on top of them. func doctorExit(r doctorRuntime) error { - if doctor.HasBlocker(r.Findings) { - return errDoctorBlocker + for _, a := range r.Agents { + if doctor.HasBlocker(a.Findings) { + return errDoctorBlocker + } } return nil } diff --git a/cmd/gortex/doctor_runtime.go b/cmd/gortex/doctor_runtime.go index 349ebc10..8700cd9a 100644 --- a/cmd/gortex/doctor_runtime.go +++ b/cmd/gortex/doctor_runtime.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/zzet/gortex/internal/agents/claudecode" "github.com/zzet/gortex/internal/agents/codex" "github.com/zzet/gortex/internal/doctor" "github.com/zzet/gortex/internal/hooks" @@ -32,11 +33,35 @@ type doctorRuntime struct { Window string `json:"window"` Since time.Time `json:"since"` Redacted bool `json:"redacted"` - Codex codex.InstallState `json:"codex"` Hooks hooks.EffectivenessSummary `json:"hooks"` - Adoption doctor.Adoption `json:"adoption"` + Agents []doctorAgentRuntime `json:"agents"` Savings doctorSavings `json:"savings"` - Findings []doctor.Finding `json:"findings"` +} + +// doctorAgentRuntime is one agent's runtime slice. Reporting a list rather +// than a single agent is the difference between "here is your machine" and +// "here is the one agent doctor happens to know about" — the invocation log +// has always been partitioned per agent, and only the rendering singled Codex +// out. +type doctorAgentRuntime struct { + Agent string `json:"agent"` + Install doctorAgentInstall `json:"install"` + HookEvents []string `json:"hook_events"` + Activity map[string]hooks.EventActivity `json:"activity"` + Adoption doctor.Adoption `json:"adoption"` + Findings []doctor.Finding `json:"findings"` +} + +// doctorAgentInstall is the shape both adapters' Inspect calls lower into, so +// the renderer does not branch per agent. +type doctorAgentInstall struct { + ConfigPath string `json:"config_path"` + ConfigPresent bool `json:"config_present"` + MCPServer bool `json:"mcp_server"` + Hooks map[string]int `json:"hooks"` + InstructionsPath string `json:"instructions_path,omitempty"` + InstructionsWired bool `json:"instructions_wired"` + ShadowedBy string `json:"shadowed_by,omitempty"` } type doctorSavings struct { @@ -65,42 +90,104 @@ func collectRuntime(home string, days int) doctorRuntime { now := time.Now() since := now.Add(-time.Duration(days) * 24 * time.Hour) - state := codex.Inspect(home) - if doctorRedact { - state.ConfigPath = doctorPath(state.ConfigPath) - state.InstructionsPath = doctorPath(state.InstructionsPath) - state.ShadowedBy = doctorPath(state.ShadowedBy) - } activity, err := hooks.ReadEffectiveness(since) if err != nil { // A log we cannot read is not fatal; the other sections still carry // signal, and the empty summary renders as "no hook has ever run". activity.Path = hooks.EffectivenessLogPath() } - adoption := doctor.ScanCodexSessions(doctor.CodexHome(), since, 10) out := doctorRuntime{ Window: fmt.Sprintf("last %dd", days), Since: since, Redacted: doctorRedact, - Codex: state, Hooks: activity, - Adoption: adoption, Savings: collectSavings(since), } - out.Findings = doctor.Diagnose(doctor.AgentHooks{ - Agent: codex.Name, - Configured: state.Hooks, - RequiresTrust: true, - TrustRemedy: codex.TrustRemedy, - InstructionsPath: state.InstructionsPath, - InstructionsWired: state.InstructionsWired, - InstructionsShadowed: state.ShadowedBy, - }, activity, adoption, now) + for _, probe := range doctorAgentProbes(home) { + out.Agents = append(out.Agents, probe(since, activity, now)) + } + if doctorRedact { + out.Hooks.Path = doctorPath(out.Hooks.Path) + } + return out +} + +// agentProbe collects one agent's runtime slice. +type agentProbe func(since time.Time, activity hooks.EffectivenessSummary, now time.Time) doctorAgentRuntime +// doctorAgentProbes returns a probe per agent doctor can speak about. Adding +// an agent here is the whole cost of covering it — the finding rules, the +// per-agent log partition, and the renderer are all already generic. +func doctorAgentProbes(home string) []agentProbe { + return []agentProbe{ + func(since time.Time, activity hooks.EffectivenessSummary, now time.Time) doctorAgentRuntime { + state := codex.Inspect(home) + return buildAgentRuntime(agentRuntimeInput{ + agent: codex.Name, + hookEvents: codex.HookEvents, + install: doctorAgentInstall{ + ConfigPath: state.ConfigPath, ConfigPresent: state.ConfigPresent, + MCPServer: state.MCPServer, Hooks: state.Hooks, + InstructionsPath: state.InstructionsPath, InstructionsWired: state.InstructionsWired, + ShadowedBy: state.ShadowedBy, + }, + // Codex is the one harness that gates hook execution on an + // explicit per-hook approval. + requiresTrust: true, + trustRemedy: codex.TrustRemedy, + adoption: doctor.ScanCodexSessions(doctor.CodexHome(), since, 10), + }, activity, now) + }, + func(since time.Time, activity hooks.EffectivenessSummary, now time.Time) doctorAgentRuntime { + state := claudecode.Inspect(home) + return buildAgentRuntime(agentRuntimeInput{ + agent: hooks.AgentClaudeCode, + hookEvents: claudecode.HookEvents, + install: doctorAgentInstall{ + ConfigPath: state.ConfigPath, ConfigPresent: state.ConfigPresent, + MCPServer: state.MCPServer, Hooks: state.Hooks, + InstructionsPath: state.InstructionsPath, InstructionsWired: state.InstructionsWired, + }, + adoption: doctor.ScanClaudeSessions(doctor.ClaudeHome(), since, 10), + }, activity, now) + }, + } +} + +type agentRuntimeInput struct { + agent string + hookEvents []string + install doctorAgentInstall + requiresTrust bool + trustRemedy string + adoption doctor.Adoption +} + +func buildAgentRuntime(in agentRuntimeInput, activity hooks.EffectivenessSummary, now time.Time) doctorAgentRuntime { + if doctorRedact { + in.install.ConfigPath = doctorPath(in.install.ConfigPath) + in.install.InstructionsPath = doctorPath(in.install.InstructionsPath) + in.install.ShadowedBy = doctorPath(in.install.ShadowedBy) + } + out := doctorAgentRuntime{ + Agent: in.agent, + Install: in.install, + HookEvents: in.hookEvents, + Activity: activity.ForAgent(in.agent), + Adoption: in.adoption, + } + out.Findings = doctor.Diagnose(doctor.AgentHooks{ + Agent: in.agent, + Configured: in.install.Hooks, + RequiresTrust: in.requiresTrust, + TrustRemedy: in.trustRemedy, + InstructionsPath: in.install.InstructionsPath, + InstructionsWired: in.install.InstructionsWired, + InstructionsShadowed: in.install.ShadowedBy, + }, activity, in.adoption, now) if doctorRedact { out.Adoption = redactAdoption(out.Adoption) - out.Hooks.Path = doctorPath(out.Hooks.Path) } return out } @@ -172,44 +259,72 @@ func redactName(value string) string { } func printDoctorRuntime(w io.Writer, r doctorRuntime) { - fmt.Fprintf(w, "Gortex doctor — runtime evidence (%s):\n\n", r.Window) + fmt.Fprintf(w, "Gortex doctor — runtime evidence (%s):\n", r.Window) - fmt.Fprintln(w, " codex install") - if !r.Codex.ConfigPresent { - fmt.Fprintf(w, " %s %s missing\n", glyphCross, r.Codex.ConfigPath) - } else { - fmt.Fprintf(w, " %s mcp_servers.gortex\n", mark(r.Codex.MCPServer)) - for _, event := range codex.HookEvents { - fmt.Fprintf(w, " %s hook %-17s %d configured\n", mark(r.Codex.Hooks[event] > 0), event, r.Codex.Hooks[event]) + for _, agent := range r.Agents { + printAgentRuntime(w, agent, r.Hooks) + } + printSavings(w, r.Savings) + + fmt.Fprintln(w, " findings") + for _, agent := range r.Agents { + for _, f := range agent.Findings { + glyph := "·" + switch f.Severity { + case doctor.SeverityBlocker: + glyph = glyphCross + case doctor.SeverityWarn: + glyph = glyphWarn + case doctor.SeverityOK: + glyph = glyphCheck + } + fmt.Fprintf(w, " %s %-7s [%s] %s\n", glyph, f.Severity, agent.Agent, f.Summary) + if f.Remedy != "" { + fmt.Fprintf(w, " → %s\n", f.Remedy) + } } } - fmt.Fprintf(w, " %s rule block in %s\n", mark(r.Codex.InstructionsWired), r.Codex.InstructionsPath) - if r.Codex.ShadowedBy != "" { - fmt.Fprintf(w, " %s shadowed by %s\n", glyphCross, r.Codex.ShadowedBy) + if !r.Redacted { + fmt.Fprintln(w, "\n sharing this? re-run with --redact to hash repo paths and branch names.") } fmt.Fprintln(w) +} + +func printAgentRuntime(w io.Writer, a doctorAgentRuntime, summary hooks.EffectivenessSummary) { + fmt.Fprintf(w, "\n %s — install\n", a.Agent) + if !a.Install.ConfigPresent { + fmt.Fprintf(w, " %s %s missing\n", glyphAbsent, a.Install.ConfigPath) + } else { + fmt.Fprintf(w, " %s mcp server registered\n", mark(a.Install.MCPServer)) + for _, event := range a.HookEvents { + fmt.Fprintf(w, " %s hook %-17s %d configured\n", mark(a.Install.Hooks[event] > 0), event, a.Install.Hooks[event]) + } + } + if a.Install.InstructionsPath != "" { + fmt.Fprintf(w, " %s rule block in %s\n", mark(a.Install.InstructionsWired), a.Install.InstructionsPath) + } + if a.Install.ShadowedBy != "" { + fmt.Fprintf(w, " %s shadowed by %s\n", glyphWarn, a.Install.ShadowedBy) + } - fmt.Fprintf(w, " hook activity (did %s's hook processes actually run?)\n", codex.Name) - if !r.Hooks.Present { - fmt.Fprintf(w, " no %s — no hook has ever run on this machine\n", r.Hooks.Path) + fmt.Fprintf(w, "\n %s — hook activity\n", a.Agent) + if !summary.Present { + fmt.Fprintf(w, " no %s — no hook has ever run on this machine\n", summary.Path) } else { - scoped := r.Hooks.ForAgent(codex.Name) fmt.Fprintf(w, " %-18s %7s %9s %10s %8s %s\n", "event", "runs", "injected", "daemon-up", "unattrib", "last seen") - for _, event := range doctorEventOrder(r.Hooks) { - stats := scoped[event] + for _, event := range a.HookEvents { + stats := a.Activity[event] daemon := "n/a" if stats.DaemonKnown > 0 { daemon = fmt.Sprintf("%d/%d", stats.DaemonUp, stats.DaemonKnown) } // A zero in `runs` is only alarming when `unattrib` is zero too; - // showing them side by side is what keeps the reader from - // reaching the conclusion the findings deliberately withheld. - ambiguous := r.Hooks.AmbiguousRuns(event) + // showing them side by side keeps the reader from reaching the + // conclusion the findings deliberately withheld. + ambiguous := summary.AmbiguousRuns(event) seen := stats.LastSeen if seen.IsZero() { - // The event did run, we just cannot say for whom — dating it - // "never" would contradict the count beside it. - seen = r.Hooks.Unattributed[event].LastSeen + seen = summary.Unattributed[event].LastSeen } last := "never" if !seen.IsZero() { @@ -217,61 +332,48 @@ func printDoctorRuntime(w io.Writer, r doctorRuntime) { } fmt.Fprintf(w, " %-18s %7d %9d %10s %8d %s\n", event, stats.Runs, stats.Emitted, daemon, ambiguous, last) } - fmt.Fprintf(w, " %d row(s) in window, %d in the whole log\n", r.Hooks.WindowRows, r.Hooks.TotalRows) - if agents := r.Hooks.AgentNames(); len(agents) > 0 { - fmt.Fprintf(w, " agents seen %s\n", joinComma(agents)) - } - if r.Hooks.UnattributedRows > 0 { - // Rows written before the agent field existed cannot be assigned - // to anyone. They are held out of every agent's counts rather - // than shared into all of them, and a `runs` of 0 beside a - // non-zero `unattrib` means "cannot tell", not "did not run". - fmt.Fprintf(w, " note: %d row(s) predate per-agent attribution (the unattrib column).\n", r.Hooks.UnattributedRows) - fmt.Fprintln(w, " They can neither confirm nor rule out an agent's hooks, so a 0 in") - fmt.Fprintln(w, " runs beside a non-zero unattrib is withheld from the findings.") - fmt.Fprintln(w, " They clear as the window rolls past the upgrade.") - } } - fmt.Fprintln(w) - fmt.Fprintln(w, " codex adoption (what the model actually called)") - if r.Adoption.FilesFound == 0 { - fmt.Fprintf(w, " no session transcripts under %s\n", r.Adoption.Root) + fmt.Fprintf(w, "\n %s — adoption (what the model actually called)\n", a.Agent) + if a.Adoption.FilesFound == 0 { + fmt.Fprintf(w, " no session transcripts under %s\n", a.Adoption.Root) } else { - fmt.Fprintf(w, " sessions with tool calls %d of %d on disk\n", r.Adoption.InWindow, r.Adoption.FilesFound) - fmt.Fprintf(w, " gortex MCP calls %d\n", r.Adoption.GortexCalls) - fmt.Fprintf(w, " other MCP calls %d\n", r.Adoption.OtherMCP) - fmt.Fprintf(w, " shell calls %d (%d read/search shaped)\n", r.Adoption.ShellCalls, r.Adoption.ShellReads) - if len(r.Adoption.Tools) > 0 { - fmt.Fprintf(w, " gortex tools used %s\n", topCounts(r.Adoption.Tools, 8)) + fmt.Fprintf(w, " sessions with tool calls %d of %d in window\n", a.Adoption.InWindow, a.Adoption.FilesFound) + fmt.Fprintf(w, " gortex MCP calls %d\n", a.Adoption.GortexCalls) + fmt.Fprintf(w, " other MCP calls %d\n", a.Adoption.OtherMCP) + fmt.Fprintf(w, " native read/shell calls %d (%d read/search shaped)\n", a.Adoption.ShellCalls, a.Adoption.ShellReads) + if len(a.Adoption.Tools) > 0 { + fmt.Fprintf(w, " gortex tools used %s\n", topCounts(a.Adoption.Tools, 8)) } - if len(r.Adoption.Models) > 0 { - fmt.Fprintf(w, " models %s\n", topCounts(r.Adoption.Models, 4)) + if len(a.Adoption.Models) > 0 { + fmt.Fprintf(w, " models %s\n", topCounts(a.Adoption.Models, 4)) } } - fmt.Fprintln(w, " note: Codex does not persist hook-injected context into transcripts, so") - fmt.Fprintln(w, " this section cannot prove a hook fired — hook activity above does.") fmt.Fprintln(w) +} +func printSavings(w io.Writer, sv doctorSavings) { fmt.Fprintln(w, " savings ledger") switch { - case !r.Savings.Available: - fmt.Fprintf(w, " unavailable: %s\n", r.Savings.Error) - case r.Savings.Events == 0: + case !sv.Available: + fmt.Fprintf(w, " unavailable: %s\n", sv.Error) + case sv.Events == 0: fmt.Fprintln(w, " no recorded events in window") default: - baseline := r.Savings.Saved + r.Savings.Returned + baseline := sv.Saved + sv.Returned pct := 0.0 if baseline > 0 { - pct = 100 * float64(r.Savings.Saved) / float64(baseline) + pct = 100 * float64(sv.Saved) / float64(baseline) } - fmt.Fprintf(w, " events %d\n", r.Savings.Events) - fmt.Fprintf(w, " saved / baseline %d / %d (%.1f%%)\n", r.Savings.Saved, baseline, pct) - fmt.Fprintf(w, " without client / model %d / %d\n", r.Savings.NoClient, r.Savings.NoModel) - for label, rows := range map[string][]doctorDimTotal{ - "by tool": r.Savings.ByTool, "by client": r.Savings.ByClient, "by model": r.Savings.ByModel, - } { - for i, row := range rows { + fmt.Fprintf(w, " events %d\n", sv.Events) + fmt.Fprintf(w, " saved / baseline %d / %d (%.1f%%)\n", sv.Saved, baseline, pct) + fmt.Fprintf(w, " without client / model %d / %d\n", sv.NoClient, sv.NoModel) + for _, group := range []struct { + label string + rows []doctorDimTotal + }{{"by tool", sv.ByTool}, {"by client", sv.ByClient}, {"by model", sv.ByModel}} { + label := group.label + for i, row := range group.rows { if i >= 6 { break } @@ -284,45 +386,6 @@ func printDoctorRuntime(w io.Writer, r doctorRuntime) { fmt.Fprintln(w, " returned). explore / search / relations / trace record nothing, and") fmt.Fprintln(w, " reading a whole file records 0 — so this describes those calls only.") fmt.Fprintln(w) - - fmt.Fprintln(w, " findings") - for _, f := range r.Findings { - glyph := "·" - switch f.Severity { - case doctor.SeverityBlocker: - glyph = glyphCross - case doctor.SeverityWarn: - glyph = "!" - case doctor.SeverityOK: - glyph = glyphCheck - } - fmt.Fprintf(w, " %s %-7s %s\n", glyph, f.Severity, f.Summary) - if f.Remedy != "" { - fmt.Fprintf(w, " → %s\n", f.Remedy) - } - } - if !r.Redacted { - fmt.Fprintln(w, "\n sharing this? re-run with --redact to hash repo paths and branch names.") - } - fmt.Fprintln(w) -} - -// doctorEventOrder lists the adapter's own events first, in lifecycle order, -// then anything else the log saw — so the events a reader is looking for are -// never buried under LocalizationTerminal rows. -func doctorEventOrder(s hooks.EffectivenessSummary) []string { - seen := map[string]bool{} - out := make([]string, 0, len(s.Events)+len(codex.HookEvents)) - for _, event := range codex.HookEvents { - out = append(out, event) - seen[event] = true - } - for _, event := range s.EventNames() { - if !seen[event] { - out = append(out, event) - } - } - return out } func topCounts(counts map[string]int, limit int) string { diff --git a/internal/agents/claudecode/inspect.go b/internal/agents/claudecode/inspect.go new file mode 100644 index 00000000..29709af7 --- /dev/null +++ b/internal/agents/claudecode/inspect.go @@ -0,0 +1,92 @@ +package claudecode + +import ( + "encoding/json" + "os" + "strings" + + "github.com/zzet/gortex/internal/agents" +) + +// inspect.go is the read-only counterpart to Apply, mirroring codex.Inspect so +// `gortex doctor` can report every agent the same way instead of singling one +// out. It reuses this package's own path helpers, so a file the adapter writes +// and a file the reporter looks for cannot drift apart. + +// InstallState is the observed state of the Claude Code integration. +type InstallState struct { + ConfigPath string `json:"config_path"` + ConfigPresent bool `json:"config_present"` + MCPServer bool `json:"mcp_server"` + Hooks map[string]int `json:"hooks"` + InstructionsPath string `json:"instructions_path,omitempty"` + InstructionsWired bool `json:"instructions_wired"` +} + +// HookEvents are the lifecycle events this adapter installs. SessionStart +// leads for the same reason it does everywhere: it is what states the Gortex +// rule for the session. +var HookEvents = []string{"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"} + +// Inspect reads the user-level Claude Code config. Every failure degrades to +// "not configured" rather than an error — doctor's job is to describe a broken +// machine, not to fail on one. +func Inspect(home string) InstallState { + state := InstallState{Hooks: map[string]int{}} + if home == "" { + return state + } + for _, event := range HookEvents { + state.Hooks[event] = 0 + } + + // Hooks live in settings.local.json; the MCP server stanza lives in + // ~/.claude.json. Report the hooks file as the config path, since that is + // the one whose contents the runtime section is about. + state.ConfigPath = userSettingsLocalPath(home) + if data, err := os.ReadFile(state.ConfigPath); err == nil { + state.ConfigPresent = true + var settings struct { + Hooks map[string][]struct { + Hooks []struct { + Command string `json:"command"` + } `json:"hooks"` + } `json:"hooks"` + } + if json.Unmarshal(data, &settings) == nil { + for event, entries := range settings.Hooks { + if _, tracked := state.Hooks[event]; !tracked { + continue + } + for _, entry := range entries { + for _, h := range entry.Hooks { + // Same recogniser the installer's idempotency check + // uses: a gortex-invoking command, however it is + // spelled or pathed. + if strings.Contains(h.Command, "gortex") { + state.Hooks[event]++ + break + } + } + } + } + } + } + + if data, err := os.ReadFile(userClaudeJSONPath(home)); err == nil { + var root struct { + MCPServers map[string]any `json:"mcpServers"` + } + if json.Unmarshal(data, &root) == nil { + _, state.MCPServer = root.MCPServers["gortex"] + } + } + + state.InstructionsPath = UserClaudeMdPath(home) + if body, err := os.ReadFile(state.InstructionsPath); err == nil { + text := string(body) + state.InstructionsWired = strings.Contains(text, agents.GlobalRulesStartMarker) || + strings.Contains(text, agents.InstructionsSentinel) + } + return state +} diff --git a/internal/agents/claudecode/inspect_test.go b/internal/agents/claudecode/inspect_test.go new file mode 100644 index 00000000..3cb5587e --- /dev/null +++ b/internal/agents/claudecode/inspect_test.go @@ -0,0 +1,94 @@ +package claudecode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zzet/gortex/internal/agents" + "github.com/zzet/gortex/internal/agents/agentstest" +) + +// TestInspectSeesWhatApplyWrote is the drift fence between the writer and the +// reader, mirroring the codex adapter's. `gortex doctor` reports "hooks +// configured" from Inspect; if applyGlobal's hook shape changes without +// Inspect following, doctor would report a correct install as unconfigured — +// turning the diagnostic into a second bug rather than a tool for the first. +func TestInspectSeesWhatApplyWrote(t *testing.T) { + env, _ := agentstest.NewEnv(t) + env.Mode = agents.ModeGlobal + env.InstallGlobalInstructions = true + env.InstallHooks = true + + before := Inspect(env.Home) + if before.ConfigPresent || before.MCPServer || before.InstructionsWired { + t.Fatalf("fixture home should start empty: %+v", before) + } + + if _, err := New().Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil { + t.Fatalf("apply: %v", err) + } + + after := Inspect(env.Home) + if !after.ConfigPresent { + t.Error("settings.local.json not seen after install") + } + if !after.MCPServer { + t.Error("mcpServers.gortex not seen after install") + } + if !after.InstructionsWired { + t.Error("the CLAUDE.md rule block is not visible to Inspect") + } + var wired int + for _, event := range HookEvents { + wired += after.Hooks[event] + } + if wired == 0 { + t.Errorf("Apply wrote hooks that Inspect cannot see: %+v", after.Hooks) + } +} + +// TestInspectIgnoresForeignHooks keeps doctor from crediting Gortex for +// someone else's hook, which would mask a missing Gortex one. +func TestInspectIgnoresForeignHooks(t *testing.T) { + env, _ := agentstest.NewEnv(t) + path := userSettingsLocalPath(env.Home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + body := `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"/usr/local/bin/other-tool notify"}]}]}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + state := Inspect(env.Home) + if !state.ConfigPresent { + t.Fatal("settings.local.json not seen") + } + if state.Hooks["SessionStart"] != 0 { + t.Errorf("counted a non-Gortex hook: %d", state.Hooks["SessionStart"]) + } +} + +// TestInspectSurvivesBrokenConfig — doctor exists to describe broken machines. +func TestInspectSurvivesBrokenConfig(t *testing.T) { + env, _ := agentstest.NewEnv(t) + path := userSettingsLocalPath(env.Home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + state := Inspect(env.Home) + if !state.ConfigPresent { + t.Error("a malformed config still exists on disk") + } + if state.MCPServer { + t.Error("nothing parseable, so nothing should be claimed") + } + + if got := Inspect(""); got.ConfigPresent { + t.Errorf("empty home should report nothing, got %+v", got) + } +} diff --git a/internal/doctor/sessions_claude.go b/internal/doctor/sessions_claude.go new file mode 100644 index 00000000..d29d816b --- /dev/null +++ b/internal/doctor/sessions_claude.go @@ -0,0 +1,179 @@ +package doctor + +import ( + "bufio" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// sessions_claude.go reads Claude Code transcripts, the counterpart to the +// Codex rollout scanner. Same output type, same question — did the model reach +// for the graph or for the filesystem — so doctor can report both agents side +// by side instead of describing one and leaving the other blank. +// +// The formats differ enough to keep the parsers apart: Codex writes flat +// `function_call` payloads with a `namespace`, Claude writes assistant +// messages whose `content` array carries `tool_use` parts, and the tool name +// alone says whether it went through MCP. + +// claudeNativeReadTools are the built-in tools whose job the graph tools are +// meant to take over. Bash is counted separately: only a read- or +// search-shaped command competes with a graph query. +var claudeNativeReadTools = map[string]bool{ + "Read": true, "Grep": true, "Glob": true, "NotebookRead": true, +} + +// ClaudeHome resolves Claude Code's config root, honouring the same override +// the adapter uses so a non-default profile is not silently missed. +func ClaudeHome() string { + if v := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".claude") +} + +// ScanClaudeSessions tallies tool calls across Claude Code transcripts +// (/projects//.jsonl, one JSON object per line). +// +// Transcripts accumulate per project and can number in the thousands, so the +// newest are read and the rest skipped — a diagnostic nobody waits for does +// not get run. Sessions with no tool calls are excluded: they say nothing +// about tool choice and would dilute the ratio. +func ScanClaudeSessions(claudeHome string, since time.Time, limit int) Adoption { + root := filepath.Join(claudeHome, "projects") + out := Adoption{ + Root: root, + Tools: map[string]int{}, + Models: map[string]int{}, + CLIVersions: map[string]int{}, + } + if claudeHome == "" { + return out + } + + type candidate struct { + path string + mod time.Time + } + var files []candidate + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() || !strings.HasSuffix(path, ".jsonl") { + return nil + } + info, statErr := d.Info() + if statErr != nil { + return nil + } + if info.ModTime().Before(since) { + return nil // cheap reject before the file is opened + } + files = append(files, candidate{path: path, mod: info.ModTime()}) + return nil + }) + out.FilesFound = len(files) + sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) }) + if len(files) > maxSessionFiles { + files = files[:maxSessionFiles] + } + + for _, file := range files { + row, ok := scanClaudeTranscript(file.path) + if !ok { + continue + } + out.InWindow++ + out.GortexCalls += row.Gortex + out.OtherMCP += row.OtherMCP + out.ShellCalls += row.Shell + out.ShellReads += row.ShellRead + if row.Model != "" { + out.Models[row.Model]++ + } + for name, n := range row.tools { + out.Tools[name] += n + } + if len(out.Sessions) < limit { + out.Sessions = append(out.Sessions, row.SessionRow) + } + } + return out +} + +func scanClaudeTranscript(path string) (rollupRow, bool) { + handle, err := os.Open(path) + if err != nil { + return rollupRow{}, false + } + defer handle.Close() + + row := rollupRow{tools: map[string]int{}} + scanner := bufio.NewScanner(handle) + scanner.Buffer(make([]byte, 0, 64<<10), maxRolloutLine) + for scanner.Scan() { + var rec struct { + Type string `json:"type"` + CWD string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Version string `json:"version"` + Timestamp string `json:"timestamp"` + Message struct { + Model string `json:"model"` + Content []struct { + Type string `json:"type"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } `json:"content"` + } `json:"message"` + } + if json.Unmarshal(scanner.Bytes(), &rec) != nil { + continue + } + if rec.CWD != "" && row.CWD == "" { + row.CWD = rec.CWD + row.Branch = rec.GitBranch + row.CLIVersion = rec.Version + row.Started = rec.Timestamp + } + if rec.Message.Model != "" { + row.Model = rec.Message.Model + } + for _, part := range rec.Message.Content { + if part.Type != "tool_use" || part.Name == "" { + continue + } + switch { + case strings.HasPrefix(part.Name, "mcp__gortex__"): + row.Gortex++ + row.tools[strings.TrimPrefix(part.Name, "mcp__gortex__")]++ + case strings.HasPrefix(part.Name, "mcp__"): + row.OtherMCP++ + case part.Name == "Bash": + row.Shell++ + if readLike.Match(part.Input) { + row.ShellRead++ + } + case claudeNativeReadTools[part.Name]: + // A native Read/Grep/Glob is the same competing behaviour a + // shell read is, and belongs in the same denominator. + row.Shell++ + row.ShellRead++ + } + } + } + if row.Gortex == 0 && row.Shell == 0 && row.OtherMCP == 0 { + return rollupRow{}, false + } + return row, true +} diff --git a/internal/doctor/sessions_test.go b/internal/doctor/sessions_test.go index ba4fff29..de4c467d 100644 --- a/internal/doctor/sessions_test.go +++ b/internal/doctor/sessions_test.go @@ -175,3 +175,103 @@ func TestScanCodexSessionsLimitsListedRows(t *testing.T) { t.Errorf("listed rows=%d want 2", len(got.Sessions)) } } + +// claudeTranscript writes a Claude Code transcript: assistant messages whose +// content array carries tool_use parts, which is where the tool name lives. +func claudeTranscript(t *testing.T, home, slug, name string, tools []string, mod time.Time) { + t.Helper() + dir := filepath.Join(home, "projects", slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + var buf strings.Builder + content := []map[string]any{} + for _, tool := range tools { + content = append(content, map[string]any{"type": "tool_use", "name": tool, "input": map[string]any{"command": "rg TODO ."}}) + } + rec := map[string]any{ + "type": "assistant", "cwd": "/repo", "gitBranch": "main", "version": "2.0.0", + "message": map[string]any{"model": "claude-sonnet-5", "content": content}, + } + blob, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + buf.Write(blob) + buf.WriteByte('\n') + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(buf.String()), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatal(err) + } +} + +func TestScanClaudeSessionsClassifiesToolCalls(t *testing.T) { + home := t.TempDir() + now := time.Now() + claudeTranscript(t, home, "-repo", "a.jsonl", []string{ + "mcp__gortex__explore", "mcp__gortex__read", + "mcp__other__lookup", + "Read", "Grep", // native reads compete with graph queries + "Bash", // its input is a rg command, so read-shaped + "TodoWrite", // neither + }, now) + + got := ScanClaudeSessions(home, now.Add(-24*time.Hour), 10) + + if got.GortexCalls != 2 { + t.Errorf("gortex=%d want 2", got.GortexCalls) + } + if got.OtherMCP != 1 { + t.Errorf("otherMCP=%d want 1", got.OtherMCP) + } + // Read + Grep + Bash: a native Read is the same competing behaviour as a + // shell read and belongs in the same denominator. + if got.ShellCalls != 3 { + t.Errorf("shell=%d want 3", got.ShellCalls) + } + if got.ShellReads != 3 { + t.Errorf("shellReads=%d want 3", got.ShellReads) + } + if got.Tools["explore"] != 1 || got.Tools["read"] != 1 { + t.Errorf("tools=%v — the mcp__gortex__ prefix should be stripped", got.Tools) + } + if got.Models["claude-sonnet-5"] != 1 { + t.Errorf("models=%v", got.Models) + } + if len(got.Sessions) != 1 || got.Sessions[0].CWD != "/repo" || got.Sessions[0].Branch != "main" { + t.Errorf("session row=%+v", got.Sessions) + } +} + +func TestScanClaudeSessionsWindowAndEmpties(t *testing.T) { + home := t.TempDir() + now := time.Now() + claudeTranscript(t, home, "-repo", "recent.jsonl", []string{"mcp__gortex__read"}, now) + claudeTranscript(t, home, "-repo", "old.jsonl", []string{"Read"}, now.Add(-30*24*time.Hour)) + claudeTranscript(t, home, "-repo", "chat.jsonl", nil, now) + + got := ScanClaudeSessions(home, now.Add(-24*time.Hour), 10) + + if got.InWindow != 1 { + t.Errorf("inWindow=%d want 1", got.InWindow) + } + if got.ShellCalls != 0 { + t.Errorf("shell=%d — the out-of-window transcript leaked in", got.ShellCalls) + } + if got.GortexCalls != 1 { + t.Errorf("gortex=%d want 1", got.GortexCalls) + } +} + +func TestScanClaudeSessionsMissingHome(t *testing.T) { + if got := ScanClaudeSessions("", time.Now().Add(-time.Hour), 10); got.FilesFound != 0 { + t.Errorf("empty home should yield nothing, got %+v", got) + } + got := ScanClaudeSessions(filepath.Join(t.TempDir(), "absent"), time.Now().Add(-time.Hour), 10) + if got.FilesFound != 0 || got.GortexShare() != -1 { + t.Errorf("missing projects dir should yield nothing, got %+v", got) + } +}