From 9dd79f591475a836129c75abdbe696cbd2dfd271 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:20:59 +0530 Subject: [PATCH 1/8] fix(cli): rebrand root command to 'trace' NewRootCmd reported Use: "entire" / Short: "Entire CLI" and its getting-started help pointed at docs.entire.io, which no longer exists. The command tree is surfaced as 'hawk trace ...' and as a standalone 'trace' binary (cmd/trace), so the root name now matches the repo identity: - Use derives from the new agent.BinaryName constant ("trace") - Short matches the README tagline - getting-started help drops the dead docs.entire.io URL - version banner reads 'Trace CLI' - hidden-alias deprecation hints point at 'trace ...' commands - agent-help drill-down pointer derives from the live root name agent.BinaryName lives in the shared agent package because both the strategy layer and agent integrations need it and cannot import package cli without a cycle. --- CHANGELOG.md | 6 ++++++ cli/agent/binary.go | 19 +++++++++++++++++++ cli/agent_help_cmd.go | 2 +- cli/agent_help_cmd_test.go | 4 ++-- cli/root.go | 34 +++++++++++++++++----------------- 5 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 cli/agent/binary.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a863797..bf60f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). This project ad - asciinema export — `trace session export --format asciinema` renders a transcript as a playable v2 cast. ### Changed +- **Root command rebranded to `trace`** — `cli.NewRootCmd()` now reports + `Use: "trace"` (was `entire`), `Short` matches the README tagline ("Git-native + session capture for AI coding agents"), the getting-started help no longer + points at the defunct `docs.entire.io` docs, the version banner reads + `Trace CLI`, hidden-alias deprecation hints name `trace …` commands, and the + agent-help drill-down pointer derives from the live root command name. - **Version re-baselined to `0.1.0`** in `cli/versioninfo/versioninfo.go`. Aligns trace with the rest of the hawk-eco ecosystem (`hawk`, `tok`, `eyrie`, `yaad`, `sight`, diff --git a/cli/agent/binary.go b/cli/agent/binary.go new file mode 100644 index 0000000..51a597c --- /dev/null +++ b/cli/agent/binary.go @@ -0,0 +1,19 @@ +package agent + +// Binary names for hook-command generation and recognition. +// +// These live in the shared agent package (not package cli) because both the +// strategy layer (git hooks) and every agent integration (wrapper probes) +// need them, while package cli imports both of those and cannot be imported +// by them without a cycle. +const ( + // BinaryName is the canonical name of this CLI's binary. It is the name + // hooks assume when nothing more specific can be resolved, and the name + // the root cobra command reports (see cli.NewRootCmd). + BinaryName = "trace" + + // LegacyBinaryName is the pre-rebrand binary name. Hook commands and + // config files installed by older builds still reference it, so it is + // kept as a recognition and fallback name. + LegacyBinaryName = "entire" +) diff --git a/cli/agent_help_cmd.go b/cli/agent_help_cmd.go index 9a20410..0034bd6 100644 --- a/cli/agent_help_cmd.go +++ b/cli/agent_help_cmd.go @@ -327,7 +327,7 @@ func renderAgentHelpCommand(cmd *cobra.Command, repoLine string, trailsEnabled b names = append(names, sub.Name()) } fmt.Fprintf(&b, "\nSubcommands: %s\n", strings.Join(names, " · ")) - fmt.Fprintf(&b, "Next: entire agent-help %s \n", strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" ")) + fmt.Fprintf(&b, "Next: %s agent-help %s \n", cmd.Root().Name(), strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" ")) } return b.String() } diff --git a/cli/agent_help_cmd_test.go b/cli/agent_help_cmd_test.go index 864f736..2244552 100644 --- a/cli/agent_help_cmd_test.go +++ b/cli/agent_help_cmd_test.go @@ -537,7 +537,7 @@ func TestAgentHelpCmd_Execute(t *testing.T) { if err := json.Unmarshal(jbuf.Bytes(), &parsed); err != nil { t.Fatalf("output not valid JSON: %v\n%s", err, jbuf.String()) } - if parsed.Command != "entire status" { - t.Errorf("json command = %q, want %q", parsed.Command, "entire status") + if parsed.Command != "trace status" { + t.Errorf("json command = %q, want %q", parsed.Command, "trace status") } } diff --git a/cli/root.go b/cli/root.go index b73cfbb..da2d3ea 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,6 +4,7 @@ import ( "fmt" "runtime" + "github.com/GrayCodeAI/trace/cli/agent" "github.com/GrayCodeAI/trace/cli/experimental" "github.com/GrayCodeAI/trace/cli/investigate" "github.com/GrayCodeAI/trace/cli/paths" @@ -18,10 +19,9 @@ import ( const gettingStarted = ` Getting Started: - To get started with Entire CLI, run 'entire enable' to enable - session tracking in your repository, then 'entire agent add ' - to install hooks for a specific agent. For more information, visit: - https://docs.entire.io/overview + To get started with Trace, run 'trace enable' to enable + session tracking in your repository, then 'trace agent add ' + to install hooks for a specific agent. ` @@ -51,9 +51,9 @@ func inGroup(c *cobra.Command, groupID string) *cobra.Command { func NewRootCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "entire", - Short: "Entire CLI", - Long: "The command-line interface for Entire" + gettingStarted + accessibilityHelp, + Use: agent.BinaryName, + Short: "Git-native session capture for AI coding agents", + Long: "The command-line interface for Trace" + gettingStarted + accessibilityHelp, Version: versioninfo.Version, // Let main.go handle error printing to avoid duplication SilenceErrors: true, @@ -102,9 +102,9 @@ func NewRootCmd() *cobra.Command { }, } - // Help groups; AddGroup order is display order in `entire --help`. + // Help groups; AddGroup order is display order in `trace --help`. cmd.AddGroup( - &cobra.Group{ID: groupSetup, Title: "Entire Setup:"}, + &cobra.Group{ID: groupSetup, Title: "Trace Setup:"}, &cobra.Group{ID: groupSessions, Title: "Sessions & Checkpoints:"}, &cobra.Group{ID: groupAccount, Title: "Account:"}, &cobra.Group{ID: groupControlPlane, Title: "Control Plane:"}, @@ -150,16 +150,16 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(inGroup(newCIInitCmd(), groupSetup)) // 'ci-init' — configure CI session auto-capture cmd.AddCommand(inGroup(newOplogCmd(), groupSessions)) // 'log' — show trace's operation log cmd.AddCommand(inGroup(newAPICmd(), groupControlPlane)) // authenticated passthrough to core/cell APIs - cmd.AddCommand(newAgentHelpCmd(cmd)) // visible: agents on transports without context injection discover it via `entire help` + cmd.AddCommand(newAgentHelpCmd(cmd)) // visible: agents on transports without context injection discover it via `trace help` // Hidden top-level shortcuts. Functional but print a deprecation hint. - cmd.AddCommand(hideAsAlias(newResumeCmd(), "entire session resume")) - cmd.AddCommand(hideAsAlias(newAttachCmd(), "entire session attach")) - cmd.AddCommand(hideAsAlias(newExplainCmd(), "entire checkpoint explain")) - cmd.AddCommand(hideAsAlias(newTraceCmd(), "entire doctor trace")) - experimental.Register(cmd, newSearchCmd()) // 'entire search' = 'checkpoint search' (experimental) + cmd.AddCommand(hideAsAlias(newResumeCmd(), "trace session resume")) + cmd.AddCommand(hideAsAlias(newAttachCmd(), "trace session attach")) + cmd.AddCommand(hideAsAlias(newExplainCmd(), "trace checkpoint explain")) + cmd.AddCommand(hideAsAlias(newTraceCmd(), "trace doctor trace")) + experimental.Register(cmd, newSearchCmd()) // 'trace search' = 'checkpoint search' (experimental) - // Experimental labs commands (listed via `entire labs`; not deprecation shortcuts). + // Experimental labs commands (listed via `trace labs`; not deprecation shortcuts). experimental.Register(cmd, newExpertsCmd()) // 'experts' (experimental); agent/workflow provenance // Deprecated top-level commands (functional; the constructors mark them @@ -187,7 +187,7 @@ func NewRootCmd() *cobra.Command { } func versionString() string { - return fmt.Sprintf("Entire CLI %s\nGo version: %s\nOS/Arch: %s/%s\n", + return fmt.Sprintf("Trace CLI %s\nGo version: %s\nOS/Arch: %s/%s\n", versioninfo.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) } From 9d838ca00c0232d97eca1d34aa6611cb786c1af0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:34:41 +0530 Subject: [PATCH 2/8] fix(agent,strategy): resolve hook binary instead of hardcoding 'entire' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default git-hook prefix (strategy/hookCmdPrefix) and every production wrapper probe (agent.WrapProduction*) referenced a binary named entire, which does not exist in hawk distributions. Hooks installed under hawk probed 'command -v entire', silently exited 0, and disabled session capture unless flags or PATH happened to provide the legacy binary. Resolution now flows through agent.HookCommandPrefix in priority order: 1. an explicitly-configured prefix still wins (local-dev launcher and --absolute-git-hook-path are checked first, unchanged); 2. the basename of the running executable when it is a known name: trace, entire, or hawk — hawk embeds this CLI as 'hawk trace'; 3. 'trace' when a binary of that name is reachable on PATH; 4. legacy 'entire' when only it is on PATH, and as the final fallback, matching the historical baked default so existing installs and already-installed hooks keep working (never worse than before). The wrapper generators rewrite the leading legacy binary token of the wrapped command to the resolved prefix ('hawk trace hooks ...'), and wrapper recognition accepts every binary-name form so hooks installed by older builds are still detected for removal and upgrade. Tests pin resolution via SetHookBinaryResolutionForTesting / PinLegacyHookBinaryForTesting because hosts may carry unrelated binaries named trace or entire on PATH. --- CHANGELOG.md | 11 ++ cli/agent/binary.go | 135 ++++++++++++++++++++ cli/agent/binary_test.go | 220 +++++++++++++++++++++++++++++++++ cli/agent/codex/hooks_test.go | 4 + cli/agent/hook_command.go | 122 ++++++++++++------ cli/agent/hook_command_test.go | 12 +- cli/strategy/hooks.go | 30 ++++- cli/strategy/hooks_test.go | 5 + 8 files changed, 492 insertions(+), 47 deletions(-) create mode 100644 cli/agent/binary_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bf60f0b..0c58a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). This project ad - asciinema export — `trace session export --format asciinema` renders a transcript as a playable v2 cast. ### Changed +- **Hook binary resolution survives the rebrand and hawk embedding** — the + default git-hook prefix and the production wrapper probes no longer hardcode + the literal `entire`. They resolve through the new `agent.HookCommandPrefix` + in priority order: the basename of the running executable when it is a known + name (`trace`, `entire`, or `hawk`, which embeds this CLI as `hawk trace`), + then `trace` when reachable on PATH, then legacy `entire`, with `entire` as + the final fallback so already-installed setups keep working. Under hawk + distributions (no `entire` on PATH) wrappers now probe and exec `hawk trace` + instead of silently exiting and disabling capture. Wrapper recognition + (uninstall/upgrade) accepts every binary-name form, including wrappers + installed by older builds. - **Root command rebranded to `trace`** — `cli.NewRootCmd()` now reports `Use: "trace"` (was `entire`), `Short` matches the README tagline ("Git-native session capture for AI coding agents"), the getting-started help no longer diff --git a/cli/agent/binary.go b/cli/agent/binary.go index 51a597c..122a875 100644 --- a/cli/agent/binary.go +++ b/cli/agent/binary.go @@ -1,5 +1,12 @@ package agent +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + // Binary names for hook-command generation and recognition. // // These live in the shared agent package (not package cli) because both the @@ -16,4 +23,132 @@ const ( // config files installed by older builds still reference it, so it is // kept as a recognition and fallback name. LegacyBinaryName = "entire" + + // EmbeddedHostPrefix is the invocation prefix used when this CLI runs + // inside the hawk binary, which embeds the root command as its `trace` + // subcommand (hawk's cmd/trace.go: rootCmd.AddCommand(tracecli.NewRootCmd())). + EmbeddedHostPrefix = "hawk trace" +) + +// hookBinaryExecutable and hookBinaryLookPath are indirected so tests can +// simulate the running binary and the searchable PATH. Override them via +// SetHookBinaryResolutionForTesting. +var ( + hookBinaryExecutable = os.Executable + hookBinaryLookPath = exec.LookPath ) + +// SetHookBinaryResolutionForTesting overrides how HookCommandPrefix observes +// the running executable and the PATH, and returns a restore function. +// Test-only; non-parallel tests only (parallel tests read these vars). +func SetHookBinaryResolutionForTesting(exe func() (string, error), lookPath func(string) (string, error)) func() { + oldExe, oldLookPath := hookBinaryExecutable, hookBinaryLookPath + hookBinaryExecutable = exe + hookBinaryLookPath = lookPath + return func() { + hookBinaryExecutable = oldExe + hookBinaryLookPath = oldLookPath + } +} + +// PinLegacyHookBinaryForTesting pins hook-binary resolution to the legacy +// name so tests asserting the historical wrapper strings stay deterministic +// regardless of what trace/entire binaries happen to be on the host PATH +// (e.g. macOS ships an unrelated /usr/bin/trace). Test-only; non-parallel +// tests only. +func PinLegacyHookBinaryForTesting() func() { + notFound := func(string) (string, error) { + return "", &os.PathError{Op: "exec.LookPath", Path: "x", Err: os.ErrNotExist} + } + return SetHookBinaryResolutionForTesting(func() (string, error) { + return "/usr/local/bin/entire", nil + }, notFound) +} + +// HookCommandPrefix returns the command prefix installed hooks should use to +// invoke this CLI ("trace", "entire", or "hawk trace"), resolved in priority +// order: +// +// 1. the basename of the running executable, when it is a known name +// (trace, entire, or the hawk host that embeds this CLI); +// 2. BinaryName ("trace"), when a binary of that name is reachable on PATH; +// 3. LegacyBinaryName ("entire"), when only the legacy name is on PATH; +// 4. LegacyBinaryName — the final fallback, matching the historical baked +// default so resolution is never worse than older builds' behavior. +// +// Callers that offer an explicit override (the local-dev launcher or +// --absolute-git-hook-path) apply it before consulting this function. +// +// Like UseWindowsProductionHooks, this is deliberately not memoized: it runs +// once per hook install, and re-probing lets a host that gains or loses a +// binary migrate its hooks on the next install. +func HookCommandPrefix() string { + if exe, err := hookBinaryExecutable(); err == nil { + if prefix, ok := knownExecutableHookPrefix(filepath.Base(exe)); ok { + return prefix + } + } + if _, err := hookBinaryLookPath(BinaryName); err == nil { + return BinaryName + } + if _, err := hookBinaryLookPath(LegacyBinaryName); err == nil { + return LegacyBinaryName + } + return LegacyBinaryName +} + +// knownExecutableHookPrefix maps a known executable basename to the hook +// invocation prefix for it. Windows basenames keep their ".exe" suffix. +func knownExecutableHookPrefix(base string) (string, bool) { + base = strings.TrimSuffix(base, ".exe") + switch base { + case BinaryName: + return BinaryName, true + case LegacyBinaryName: + return LegacyBinaryName, true + case "hawk": + return EmbeddedHostPrefix, true + } + return "", false +} + +// hookBinaryProbeNames lists every binary name that generated wrappers may +// probe for. Recognition uses the full set so hooks installed under any name +// (including by older builds) stay recognizable for removal and upgrade. +func hookBinaryProbeNames() []string { + return []string{BinaryName, LegacyBinaryName, "hawk"} +} + +// managedBinaryCommandPrefixes are command prefixes for every binary-name +// form hooks may be invoked through, independent of caller-supplied prefix +// lists. They keep uninstall/upgrade flows recognizing hooks after the rebrand +// renamed the binary (trace) and hawk embedding introduced the "hawk trace" +// form, without requiring every agent package to extend its own prefix list. +func managedBinaryCommandPrefixes() []string { + return []string{ + BinaryName + " ", + LegacyBinaryName + " ", + EmbeddedHostPrefix + " ", + } +} + +// productionHookWrapperTarget resolves the binary a production wrapper should +// probe and exec. The wrapped command's leading legacy binary token (agents +// still build commands as "entire hooks ") is rewritten to the +// resolved invocation prefix; commands that do not start with the legacy +// binary name (local-dev launchers, absolute paths) pass through unchanged. +func productionHookWrapperTarget(command string) (probe, adaptedCommand string) { + prefix := HookCommandPrefix() + adapted := command + if prefix != LegacyBinaryName && strings.HasPrefix(command, LegacyBinaryName+" ") { + adapted = prefix + command[len(LegacyBinaryName):] + } + return hookCommandProbeName(prefix), adapted +} + +// hookCommandProbeName returns the single PATH entry name a wrapper probes +// for the given invocation prefix ("hawk trace" probes "hawk"). +func hookCommandProbeName(prefix string) string { + name, _, _ := strings.Cut(prefix, " ") + return name +} diff --git a/cli/agent/binary_test.go b/cli/agent/binary_test.go new file mode 100644 index 0000000..7ecf0a9 --- /dev/null +++ b/cli/agent/binary_test.go @@ -0,0 +1,220 @@ +package agent + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fakeExe returns an executable resolver pointing at a binary named name. +func fakeExe(name string) func() (string, error) { + return func() (string, error) { return filepath.Join("/usr/local/bin", name), nil } +} + +// failingExe simulates an os.Executable that cannot resolve the running binary. +func failingExe() func() (string, error) { + return func() (string, error) { return "", errors.New("unavailable") } +} + +// notOnPath simulates LookPath finding nothing. +func notOnPath(string) (string, error) { + return "", &os.PathError{Op: "exec.LookPath", Path: "x", Err: os.ErrNotExist} +} + +// onPath returns a LookPath stub that resolves exactly the given names. +func onPath(names ...string) func(string) (string, error) { + return func(name string) (string, error) { + for _, want := range names { + if name == want { + return filepath.Join("/bin", name), nil + } + } + return "", &os.PathError{Op: "exec.LookPath", Path: name, Err: os.ErrNotExist} + } +} + +// TestHookCommandPrefix_ResolutionOrder covers the priority order with +// injected executable/PATH probes: +// +// 1. known executable basename (trace / entire / hawk, ".exe" stripped) +// 2. "trace" reachable on PATH +// 3. legacy "entire" reachable on PATH +// 4. legacy "entire" as final fallback (the historical baked default) +func TestHookCommandPrefix_ResolutionOrder(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + cases := []struct { + name string + exe func() (string, error) + lookPath func(string) (string, error) + want string + }{ + {"trace executable wins over PATH", fakeExe("trace"), onPath("entire"), "trace"}, + {"entire executable keeps legacy name", fakeExe("entire"), notOnPath, "entire"}, + {"hawk executable embeds as hawk trace", fakeExe("hawk"), notOnPath, "hawk trace"}, + {"windows exe suffix is stripped", fakeExe("hawk.exe"), notOnPath, "hawk trace"}, + {"unknown executable falls back to trace on PATH", fakeExe("cli.test"), onPath("trace"), "trace"}, + {"unknown executable prefers trace over legacy entire", fakeExe("cli.test"), onPath("trace", "entire"), "trace"}, + {"unknown executable falls back to legacy entire", fakeExe("cli.test"), onPath("entire"), "entire"}, + {"nothing resolvable keeps historical default", failingExe(), notOnPath, "entire"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + restore := SetHookBinaryResolutionForTesting(tc.exe, tc.lookPath) + defer restore() + if got := HookCommandPrefix(); got != tc.want { + t.Fatalf("HookCommandPrefix() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestHookCommandPrefix_FakePATH exercises the PATH-probing steps with the +// real exec.LookPath against a controlled PATH containing fake binaries. +func TestHookCommandPrefix_FakePATH(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam and PATH. + + makeBinDir := func(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("writing fake %s: %v", name, err) + } + } + return dir + } + + cases := []struct { + name string + bins []string + want string + }{ + {"trace on PATH", []string{"trace"}, "trace"}, + {"only legacy entire on PATH", []string{"entire"}, "entire"}, + {"both on PATH prefers trace", []string{"trace", "entire"}, "trace"}, + {"empty PATH keeps legacy default", nil, "entire"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PATH", makeBinDir(t, tc.bins...)) + restore := SetHookBinaryResolutionForTesting(failingExe(), exec.LookPath) + defer restore() + if got := HookCommandPrefix(); got != tc.want { + t.Fatalf("HookCommandPrefix() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestWrapProductionSilentHookCommand_AdaptsResolvedBinary verifies the +// wrapper probes and execs the resolved binary instead of the legacy literal. +func TestWrapProductionSilentHookCommand_AdaptsResolvedBinary(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("hawk"), notOnPath) + defer restore() + + command := WrapProductionSilentHookCommand("entire hooks claude-code stop") + + if !strings.Contains(command, "command -v hawk >/dev/null 2>&1") { + t.Fatalf("wrapper should probe the embedding host binary, got %q", command) + } + if !strings.Contains(command, "exec hawk trace hooks claude-code stop") { + t.Fatalf("wrapper should exec the embedded command path, got %q", command) + } + if strings.Contains(command, "exec entire") { + t.Fatalf("wrapper must not exec the legacy binary once resolved, got %q", command) + } +} + +// TestWrapWindowsProductionSilentHookCommand_AdaptsResolvedBinary is the +// cmd.exe-wrapper counterpart of the adaptation check. +func TestWrapWindowsProductionSilentHookCommand_AdaptsResolvedBinary(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("trace"), notOnPath) + defer restore() + + command := WrapWindowsProductionSilentHookCommand("entire hooks codex stop") + + if !strings.Contains(command, "where.exe trace >nul 2>nul") { + t.Fatalf("windows wrapper should probe the resolved binary, got %q", command) + } + if !strings.Contains(command, "(trace hooks codex stop)") { + t.Fatalf("windows wrapper should exec the resolved binary, got %q", command) + } +} + +// TestWrapProductionSilentHookCommand_KeepsNonLegacyCommands verifies local-dev +// and absolute-path commands pass through the wrapper unmodified. +func TestWrapProductionSilentHookCommand_KeepsNonLegacyCommands(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("hawk"), notOnPath) + defer restore() + + command := WrapProductionSilentHookCommand(`"$(git rev-parse --show-toplevel)"/scripts/entire-dev hooks cursor stop`) + + if !strings.Contains(command, `exec "$(git rev-parse --show-toplevel)"/scripts/entire-dev hooks cursor stop`) { + t.Fatalf("non-legacy command should pass through unchanged, got %q", command) + } +} + +// TestWrapProductionSilentHookCommand_LegacyResolutionIsUnchanged pins the +// historical output when resolution lands on the legacy binary. +func TestWrapProductionSilentHookCommand_LegacyResolutionIsUnchanged(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("entire"), notOnPath) + defer restore() + + command := WrapProductionSilentHookCommand("entire hooks codex stop") + want := `sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks codex stop'` + if command != want { + t.Fatalf("legacy resolution should reproduce the historical wrapper:\ngot: %s\nwant: %s", command, want) + } +} + +// TestIsManagedHookCommand_RecognizesRebrandedForms verifies uninstall/upgrade +// recognition keeps working for hooks baked with the new binary names even +// when the caller's prefix list still names only the legacy binary. +func TestIsManagedHookCommand_RecognizesRebrandedForms(t *testing.T) { + // No t.Parallel(): mutates the package-level resolution seam. + + restore := SetHookBinaryResolutionForTesting(fakeExe("hawk"), notOnPath) + defer restore() + legacyOnly := []string{"entire "} + + direct := []string{ + "trace hooks codex stop", + "hawk trace hooks claude-code session-start", + "entire hooks codex stop", // legacy direct form still recognized + } + for _, command := range direct { + if !IsManagedHookCommand(command, legacyOnly) { + t.Errorf("expected direct command to be managed: %q", command) + } + } + + wrapped := []string{ + WrapProductionSilentHookCommand("entire hooks cursor stop"), + WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", WarningFormatSingleLine), + WrapWindowsProductionSilentHookCommand("entire hooks codex stop"), + } + for _, command := range wrapped { + if !IsManagedHookCommand(command, legacyOnly) { + t.Errorf("expected rebranded wrapper to be managed: %q", command) + } + } + + // Legacy wrappers written by older builds remain recognized. + legacyWrapper := `sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks codex stop'` + if !IsManagedHookCommand(legacyWrapper, legacyOnly) { + t.Error("expected legacy wrapper to remain managed") + } +} diff --git a/cli/agent/codex/hooks_test.go b/cli/agent/codex/hooks_test.go index 9e93ae3..052b692 100644 --- a/cli/agent/codex/hooks_test.go +++ b/cli/agent/codex/hooks_test.go @@ -18,6 +18,10 @@ func setupTestEnv(t *testing.T) string { tempDir := t.TempDir() t.Chdir(tempDir) t.Setenv("CODEX_HOME", filepath.Join(tempDir, ".codex-home")) + // Pin hook-binary resolution to the legacy name so assertions on the + // installed wrapper strings are deterministic regardless of any trace / + // entire binaries on the host PATH. + t.Cleanup(agentpkg.PinLegacyHookBinaryForTesting()) return tempDir } diff --git a/cli/agent/hook_command.go b/cli/agent/hook_command.go index 54da385..786fcc9 100644 --- a/cli/agent/hook_command.go +++ b/cli/agent/hook_command.go @@ -39,16 +39,19 @@ func MissingEntireWarning(format WarningFormat) string { const LocalDevHookScript = `"$(git rev-parse --show-toplevel)"/scripts/entire-dev` // WrapProductionSilentHookCommand exits successfully without output when the -// Entire CLI is missing from PATH. +// CLI binary is missing from PATH. The binary probed and exec'd is resolved +// from the running executable / PATH (see HookCommandPrefix). func WrapProductionSilentHookCommand(command string) string { + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec %s'`, + `sh -c 'if ! command -v %s >/dev/null 2>&1; then exit 0; fi; exec %s'`, + probe, command, ) } // WrapProductionJSONWarningHookCommand emits a JSON hook response with a -// systemMessage field on stdout when the Entire CLI is missing from PATH. +// systemMessage field on stdout when the CLI binary is missing from PATH. func WrapProductionJSONWarningHookCommand(command string, format WarningFormat) string { payload, err := jsonutil.MarshalWithNoHTMLEscape(struct { SystemMessage string `json:"systemMessage,omitempty"` @@ -60,8 +63,10 @@ func WrapProductionJSONWarningHookCommand(command string, format WarningFormat) return WrapProductionPlainTextWarningHookCommand(command, format) } + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `sh -c 'if ! command -v entire >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, + `sh -c 'if ! command -v %s >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, + probe, string(payload), command, ) @@ -73,25 +78,27 @@ func WrapProductionJSONWarningHookCommand(command string, format WarningFormat) // command to fall through to and correctness does NOT depend on `exit /b` // aborting the whole `cmd /c` line — behavior that is underspecified when the // `exit /b` sits inside a parenthesized block. Semantics: -// - entire present → `where.exe` succeeds (errorlevel 0) → else branch runs +// - binary present → `where.exe` succeeds (errorlevel 0) → else branch runs // the wrapped command and its exit code propagates (parity with the POSIX // `exec entire …` form). -// - entire absent → `where.exe` fails (errorlevel ≥ 1) → the if branch runs +// - binary absent → `where.exe` fails (errorlevel ≥ 1) → the if branch runs // (silently via `ver>nul`, or echoing the warning) and the line exits 0. // WrapWindowsProductionSilentHookCommand exits successfully without output when -// the Entire CLI is missing from PATH. It avoids sh so Codex hooks still work +// the CLI binary is missing from PATH. It avoids sh so Codex hooks still work // from native Windows shells. func WrapWindowsProductionSilentHookCommand(command string) string { + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `cmd.exe /d /s /c "where.exe entire >nul 2>nul & if errorlevel 1 (ver>nul) else (%s)"`, + `cmd.exe /d /s /c "where.exe %s >nul 2>nul & if errorlevel 1 (ver>nul) else (%s)"`, + probe, command, ) } // WrapWindowsProductionJSONWarningHookCommand emits a JSON hook response with a -// systemMessage field on stdout when the Entire CLI is missing from PATH. It -// avoids sh so Codex hooks still work from native Windows shells. Codex already +// systemMessage field on stdout when the CLI binary is missing from PATH. +// It avoids sh so Codex hooks still work from native Windows shells. Codex already // runs hook commands through cmd.exe /C, so this JSON-bearing command uses that // shell directly instead of adding a second quote-parsing layer. func WrapWindowsProductionJSONWarningHookCommand(command string, format WarningFormat) string { @@ -104,8 +111,10 @@ func WrapWindowsProductionJSONWarningHookCommand(command string, format WarningF return WrapWindowsProductionPlainTextWarningHookCommand(command, format) } + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `where.exe entire >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + `where.exe %s >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + probe, escapeWindowsCMD(string(payload)), command, ) @@ -114,56 +123,89 @@ func WrapWindowsProductionJSONWarningHookCommand(command string, format WarningF // WrapWindowsProductionPlainTextWarningHookCommand is the direct-shell fallback // for WrapWindowsProductionJSONWarningHookCommand when JSON marshaling fails. func WrapWindowsProductionPlainTextWarningHookCommand(command string, format WarningFormat) string { + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `where.exe entire >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + `where.exe %s >nul 2>nul & if errorlevel 1 (echo %s) else (%s)`, + probe, escapeWindowsCMD(windowsPlainTextWarning(format)), command, ) } // WrapProductionPlainTextWarningHookCommand emits the warning as plain -// text to stdout when the Entire CLI is missing from PATH. +// text to stdout when the CLI binary is missing from PATH. func WrapProductionPlainTextWarningHookCommand(command string, format WarningFormat) string { + payload := MissingEntireWarning(format) + probe, command := productionHookWrapperTarget(command) return fmt.Sprintf( - `sh -c 'if ! command -v entire >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, - MissingEntireWarning(format), + `sh -c 'if ! command -v %s >/dev/null 2>&1; then printf "%%s\n" %q; exit 0; fi; exec %s'`, + probe, + payload, command, ) } -const ( - productionHookWrapperPrefix = `sh -c 'if ! command -v entire >/dev/null 2>&1; then ` - windowsProductionHookWrapperPrefix = `where.exe entire >nul 2>nul & if errorlevel 1 ` - nestedWindowsProductionHookWrapperPrefix = `cmd.exe /d /s /c "where.exe entire >nul 2>nul & if errorlevel 1 ` -) +// shProductionHookWrapperPrefixes returns the wrapper-prefix forms generated +// for each probed binary name (see hookBinaryProbeNames). Recognition accepts +// the whole set so wrappers installed by any build stay recognizable. +func shProductionHookWrapperPrefixes() []string { + names := hookBinaryProbeNames() + prefixes := make([]string, 0, len(names)) + for _, name := range names { + prefixes = append(prefixes, fmt.Sprintf(`sh -c 'if ! command -v %s >/dev/null 2>&1; then `, name)) + } + return prefixes +} + +// windowsProductionHookWrapperPrefixes returns both native cmd.exe +// wrapper-prefix forms for each probed binary name (plain and nested inside +// an explicit cmd.exe /d /s /c shell). +func windowsProductionHookWrapperPrefixes() []string { + names := hookBinaryProbeNames() + prefixes := make([]string, 0, 2*len(names)) + for _, name := range names { + prefixes = append(prefixes, + fmt.Sprintf(`where.exe %s >nul 2>nul & if errorlevel 1 `, name), + fmt.Sprintf(`cmd.exe /d /s /c "where.exe %s >nul 2>nul & if errorlevel 1 `, name), + ) + } + return prefixes +} // IsManagedHookCommand reports whether command is either a direct Entire hook -// command or one of Entire's production wrapper forms that exec that command. +// command or one of the production wrapper forms that exec that command. +// Beyond the caller-supplied prefixes, the binary-name forms (trace / entire / +// hawk trace) always count as managed, so hooks baked with any resolved binary +// name are recognized for removal and upgrade. func IsManagedHookCommand(command string, prefixes []string) bool { - if hasManagedHookPrefix(command, prefixes) { + all := append(append([]string{}, prefixes...), managedBinaryCommandPrefixes()...) + if hasManagedHookPrefix(command, all) { return true } - if strings.HasPrefix(command, productionHookWrapperPrefix) { - _, wrappedCommand, ok := strings.Cut(command, "; fi; exec ") - if !ok { - return false - } + for _, prefix := range shProductionHookWrapperPrefixes() { + if strings.HasPrefix(command, prefix) { + _, wrappedCommand, ok := strings.Cut(command, "; fi; exec ") + if !ok { + return false + } - return hasManagedHookPrefix(wrappedCommand, prefixes) + return hasManagedHookPrefix(wrappedCommand, all) + } } - if strings.HasPrefix(command, windowsProductionHookWrapperPrefix) || - strings.HasPrefix(command, nestedWindowsProductionHookWrapperPrefix) { - // The wrapped command lives in the `else ()` branch. Take the - // last ` else (` so a warning string containing the marker can't fool us. - const elseMarker = " else (" - idx := strings.LastIndex(command, elseMarker) - if idx < 0 { - return false + for _, prefix := range windowsProductionHookWrapperPrefixes() { + if strings.HasPrefix(command, prefix) { + // The wrapped command lives in the `else ()` branch. Take the + // last ` else (` so a warning string containing the marker can't fool us. + const elseMarker = " else (" + idx := strings.LastIndex(command, elseMarker) + if idx < 0 { + return false + } + wrappedCommand := command[idx+len(elseMarker):] + wrappedCommand = strings.TrimSuffix(wrappedCommand, `"`) + wrappedCommand = strings.TrimSuffix(wrappedCommand, `)`) + return hasManagedHookPrefix(wrappedCommand, all) } - wrappedCommand := command[idx+len(elseMarker):] - wrappedCommand = strings.TrimSuffix(wrappedCommand, `"`) - wrappedCommand = strings.TrimSuffix(wrappedCommand, `)`) - return hasManagedHookPrefix(wrappedCommand, prefixes) } return false } diff --git a/cli/agent/hook_command_test.go b/cli/agent/hook_command_test.go index 3680cae..a0f303a 100644 --- a/cli/agent/hook_command_test.go +++ b/cli/agent/hook_command_test.go @@ -36,7 +36,8 @@ func TestUseWindowsProductionHooks(t *testing.T) { } func TestWrapProductionJSONWarningHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapProductionJSONWarningHookCommand("entire hooks claude-code session-start", WarningFormatMultiLine) @@ -58,7 +59,8 @@ func TestWrapProductionJSONWarningHookCommand(t *testing.T) { } func TestWrapProductionPlainTextWarningHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapProductionPlainTextWarningHookCommand("entire hooks factoryai-droid session-start", WarningFormatSingleLine) @@ -77,7 +79,8 @@ func TestWrapProductionPlainTextWarningHookCommand(t *testing.T) { } func TestWrapWindowsProductionJSONWarningHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapWindowsProductionJSONWarningHookCommand("entire hooks codex session-start", WarningFormatSingleLine) @@ -102,7 +105,8 @@ func TestWrapWindowsProductionJSONWarningHookCommand(t *testing.T) { } func TestWrapWindowsProductionSilentHookCommand(t *testing.T) { - t.Parallel() + // No t.Parallel(): pins hook-binary resolution for deterministic strings. + t.Cleanup(PinLegacyHookBinaryForTesting()) command := WrapWindowsProductionSilentHookCommand("entire hooks codex stop") diff --git a/cli/strategy/hooks.go b/cli/strategy/hooks.go index 043ed2e..d1858ee 100644 --- a/cli/strategy/hooks.go +++ b/cli/strategy/hooks.go @@ -12,6 +12,7 @@ import ( "sync" "syscall" + "github.com/GrayCodeAI/trace/cli/agent" "github.com/GrayCodeAI/trace/cli/settings" ) @@ -261,8 +262,8 @@ func gitHookCommand(cmdPrefix, args string, warnMissing bool) string { } func gitHookCommandAvailableTest(cmdPrefix string) (string, bool) { - if cmdPrefix == "entire" { - return "command -v entire >/dev/null 2>&1", true + if name, ok := plainCommandName(cmdPrefix); ok { + return fmt.Sprintf("command -v %s >/dev/null 2>&1", name), true } if isWindowsAbsoluteHookCommand(cmdPrefix) { return fmt.Sprintf("[ -f %s ]", cmdPrefix), true @@ -273,6 +274,26 @@ func gitHookCommandAvailableTest(cmdPrefix string) (string, bool) { return "", false } +// plainCommandName returns the leading token of cmdPrefix when that token is a +// plain PATH-resolvable command name (letters, digits, dashes, underscores — +// no separators or quotes). It covers the resolved hook binary names ("entire", +// "trace", or the "hawk" of "hawk trace") while leaving local-dev script paths +// ("./scripts/entire-dev"), absolute paths, and quoted paths to their branches. +func plainCommandName(cmdPrefix string) (string, bool) { + name, _, _ := strings.Cut(cmdPrefix, " ") + if name == "" { + return "", false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + default: + return "", false + } + } + return name, true +} + func isWindowsAbsoluteHookCommand(cmdPrefix string) bool { path := strings.TrimPrefix(cmdPrefix, "'") if len(path) < len("C:\\") || path[1] != ':' { @@ -467,6 +488,9 @@ fi // When absolutePath is true, resolves the full binary path via os.Executable() // and returns an error if resolution fails. This is needed for GUI git clients // (Xcode, Tower, etc.) that don't source shell profiles. +// Otherwise the prefix is resolved from the running executable and PATH (see +// agent.HookCommandPrefix): the embedding host's "hawk trace", the canonical +// "trace", or the legacy "entire" fallback so existing installs keep working. func hookCmdPrefix(localDev, absolutePath bool) (string, error) { if localDev { return localDevHookCmdPrefix, nil @@ -482,7 +506,7 @@ func hookCmdPrefix(localDev, absolutePath bool) (string, error) { } return shellQuote(resolved), nil } - return "entire", nil + return agent.HookCommandPrefix(), nil } // resolveHookExePath resolves exe through symlinks for embedding as an absolute diff --git a/cli/strategy/hooks_test.go b/cli/strategy/hooks_test.go index feb9661..07dbbfb 100644 --- a/cli/strategy/hooks_test.go +++ b/cli/strategy/hooks_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/trace/cli/agent" "github.com/GrayCodeAI/trace/cli/paths" ) @@ -65,6 +66,10 @@ func initHooksTestRepo(t *testing.T) (string, string) { t.Helper() tmpDir := t.TempDir() t.Chdir(tmpDir) + // Pin hook-binary resolution to the legacy name so assertions on the + // installed hook content are deterministic regardless of any trace / + // entire binaries on the host PATH. + t.Cleanup(agent.PinLegacyHookBinaryForTesting()) ctx := context.Background() cmd := exec.CommandContext(ctx, "git", "init") From d4a29eb5e01634511202b6e2a484a23236fbc5c1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:46:00 +0530 Subject: [PATCH 3/8] test(cli): update assertions to the renamed root command Follow-ups to the root-command rebrand: agent-help --json now emits 'trace trail'/'trace status' because CommandPath derives from the root Use field. --- cli/agent_help_cmd_test.go | 4 ++-- cli/mcp_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/agent_help_cmd_test.go b/cli/agent_help_cmd_test.go index 2244552..3efc2fd 100644 --- a/cli/agent_help_cmd_test.go +++ b/cli/agent_help_cmd_test.go @@ -475,8 +475,8 @@ func TestRunAgentHelp_Dispatch(t *testing.T) { if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json output not valid JSON: %v\n%s", err, jsonOut) } - if parsed.Command != "entire trail" { - t.Errorf("json command = %q, want %q", parsed.Command, "entire trail") + if parsed.Command != "trace trail" { + t.Errorf("json command = %q, want %q", parsed.Command, "trace trail") } if parsed.Repo != agentHelpTestRepo { t.Errorf("json repo = %q, want %q", parsed.Repo, agentHelpTestRepo) diff --git a/cli/mcp_test.go b/cli/mcp_test.go index a515871..c156296 100644 --- a/cli/mcp_test.go +++ b/cli/mcp_test.go @@ -199,8 +199,8 @@ func TestMCPServer_AgentHelpToolCall_Subcommand(t *testing.T) { if err := json.Unmarshal([]byte(mcpResultText(t, resps[0].Result)), &doc); err != nil { t.Fatalf("agent_help subcommand should return JSON: %v", err) } - if doc.Command != "entire status" { - t.Errorf("agent_help command=status should drill into `entire status`, got %q", doc.Command) + if doc.Command != "trace status" { + t.Errorf("agent_help command=status should drill into `trace status`, got %q", doc.Command) } } From 7bfa0cd631cebe1dd0f31f950ea1851208762d29 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:46:08 +0530 Subject: [PATCH 4/8] fix(settings): move settings to .trace/ with a read-compat shim The README documents .trace/settings.json and .trace/settings.local.json, but the code still used the pre-rebrand .entire/ locations. The canonical settings files now live under .trace/, implemented as a lazy, read-only compat shim rather than a destructive migration: - reads (Load, LoadProjectRaw/LoadLocalRaw, IsSetUp, IsSetUpAny, and the worktree-root variants) fall back to the legacy .entire/ files when the canonical .trace/ file is absent, so existing repositories keep working with zero migration steps; - the first save to a settings file copies the legacy content to the canonical location before writing (lazy migration); the legacy file itself is never deleted or rewritten; - exported helper signatures are unchanged (only constant values moved); - .trace/ is treated as CLI infrastructure (excluded from checkpoints) and gets its own .gitignore for settings.local.json; - enable's local-vs-project target detection and uninstall's directory cleanup handle both locations. Session data keeps using .entire/metadata on the checkpoint branch; migrating stored data and installed .entire git hooks is deliberately out of scope. --- CHANGELOG.md | 11 ++ cli/checkpoint/remote/util.go | 6 +- cli/explain_summary_provider_test.go | 30 +-- cli/investigate/cmd_internal_test.go | 8 +- cli/investigate/cmd_test.go | 18 +- cli/investigate/picker.go | 2 +- cli/paths/paths.go | 15 +- cli/remote_topology.go | 2 +- cli/resume.go | 2 +- cli/root.go | 8 +- cli/settings/settings.go | 149 ++++++++++++--- cli/settings/settings_path_test.go | 180 ++++++++++++++++++ cli/setup.go | 81 +++++--- cli/setup_test.go | 37 ++-- cli/strategy/common.go | 42 ++-- cli/strategy/manual_commit_opf_prompt_test.go | 12 +- cli/trace.go | 2 +- cli/trace_cmd.go | 2 +- 18 files changed, 476 insertions(+), 131 deletions(-) create mode 100644 cli/settings/settings_path_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c58a00..0cfe52a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). This project ad - asciinema export — `trace session export --format asciinema` renders a transcript as a playable v2 cast. ### Changed +- **Settings moved to `.trace/` with a read-compat shim** — the canonical + settings files are now `.trace/settings.json` and + `.trace/settings.local.json` (matching the README), replacing the + pre-rebrand `.entire/` locations. Reads fall back to the legacy `.entire/` + files when the canonical ones are absent (including setup detection via + `IsSetUp`/`IsSetUpAny` and the raw read-modify-write API), and the first + save copies the legacy content to the canonical location — a lazy, + non-destructive migration that never deletes or rewrites the legacy files. + Session data keeps using `.entire/metadata` on the checkpoint branch, and + both directories are excluded from checkpoints and cleaned up by + `uninstall --force`. - **Hook binary resolution survives the rebrand and hawk embedding** — the default git-hook prefix and the production wrapper probes no longer hardcode the literal `entire`. They resolve through the new `agent.HookCommandPrefix` diff --git a/cli/checkpoint/remote/util.go b/cli/checkpoint/remote/util.go index 2346050..ed37c03 100644 --- a/cli/checkpoint/remote/util.go +++ b/cli/checkpoint/remote/util.go @@ -210,7 +210,7 @@ func PushURL(ctx context.Context, pushRemoteName string) (string, bool, error) { ctx, "checkpoint-remote: ignoring checkpoint_remote that appears to belong to another owner; pushing checkpoints to the push remote instead", slog.String("checkpoint_repo", config.Repo), slog.String("reason", reason), - slog.String("hint", "if this checkpoint repo is yours, configure checkpoint_remote in .entire/settings.local.json"), + slog.String("hint", "if this checkpoint repo is yours, configure checkpoint_remote in .trace/settings.local.json"), ) return fallbackURL, false, nil } @@ -335,7 +335,7 @@ func GetPushURL(ctx context.Context, remoteName string) (string, error) { // looks like it belongs to an upstream project rather than to this developer, // along with a short reason for logging. // -// checkpoint_remote is normally committed in .entire/settings.json, so anyone who +// checkpoint_remote is normally committed in .trace/settings.json, so anyone who // forks or clones the project inherits it. Honoring it blindly would push a // contributor's session data into the upstream project's checkpoint repo — which // they typically cannot write to and should not be writing to. This is the check @@ -343,7 +343,7 @@ func GetPushURL(ctx context.Context, remoteName string) (string, error) { // // Ownership is decided from two local signals, no network: // -// 1. A checkpoint_remote in .entire/settings.local.json is gitignored and +// 1. A checkpoint_remote in .trace/settings.local.json is gitignored and // per-clone, so it cannot have been inherited — it is always ours. // 2. Otherwise, compare the CHECKPOINT repo's owner against ORIGIN's owner: // "am I working in a repo owned by whoever owns the checkpoint repo?" A fork diff --git a/cli/explain_summary_provider_test.go b/cli/explain_summary_provider_test.go index 4060775..cb1dfc4 100644 --- a/cli/explain_summary_provider_test.go +++ b/cli/explain_summary_provider_test.go @@ -546,14 +546,14 @@ func TestResolveDispatchSummaryProvider_ExplicitExternalProviderDoesNotWriteLoca testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true,"external_agents":false}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true,"external_agents":false}`), 0o644); err != nil { t.Fatalf("write settings.json: %v", err) } - localPath := filepath.Join(tmpDir, ".entire", "settings.local.json") + localPath := filepath.Join(tmpDir, ".trace", "settings.local.json") if tt.localContent != "" { if err := os.WriteFile(localPath, []byte(tt.localContent), 0o644); err != nil { t.Fatalf("write settings.local.json: %v", err) @@ -653,7 +653,7 @@ func TestResolveCheckpointSummaryProvider_SavesSingleInstalledProvider(t *testin // Auto-persist writes to settings.local.json (not tracked settings.json) // because provider selection is based on local PATH. - localPath := filepath.Join(tmpDir, ".entire", "settings.local.json") + localPath := filepath.Join(tmpDir, ".trace", "settings.local.json") s, err := settings.LoadFromFile(localPath) if err != nil { t.Fatalf("LoadFromFile() error = %v", err) @@ -666,7 +666,7 @@ func TestResolveCheckpointSummaryProvider_SavesSingleInstalledProvider(t *testin } // Tracked settings.json must not be dirtied. - projectPath := filepath.Join(tmpDir, ".entire", "settings.json") + projectPath := filepath.Join(tmpDir, ".trace", "settings.json") projectS, err := settings.LoadFromFile(projectPath) if err != nil { t.Fatalf("LoadFromFile(project) error = %v", err) @@ -800,10 +800,10 @@ func TestResolveCheckpointSummaryProvider_ConfiguredExternalProvider(t *testing. t.Chdir(tmpDir) const providerName = "external-summary-explain" - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true,"external_agents":true,"summary_generation":{"provider":"`+providerName+`","model":"external-model"}}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true,"external_agents":true,"summary_generation":{"provider":"`+providerName+`","model":"external-model"}}`), 0o644); err != nil { t.Fatalf("write settings: %v", err) } externalDir := t.TempDir() @@ -843,10 +843,10 @@ func TestPersistSummaryProviderSelection_ExternalFlipsFlagAndReturnsSignal(t *te testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { t.Fatalf("write settings: %v", err) } @@ -866,7 +866,7 @@ func TestPersistSummaryProviderSelection_ExternalFlipsFlagAndReturnsSignal(t *te t.Fatal("expected flagFlipped=true when external_agents was off and provider is external") } - s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".entire", "settings.local.json")) + s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".trace", "settings.local.json")) if err != nil { t.Fatalf("LoadFromFile() error = %v", err) } @@ -885,10 +885,10 @@ func TestPersistSummaryProviderSelection_BuiltInDoesNotFlipFlag(t *testing.T) { testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.json"), []byte(`{"enabled":true}`), 0o644); err != nil { t.Fatalf("write settings: %v", err) } @@ -900,7 +900,7 @@ func TestPersistSummaryProviderSelection_BuiltInDoesNotFlipFlag(t *testing.T) { t.Fatal("expected flagFlipped=false for a built-in provider") } - s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".entire", "settings.local.json")) + s, err := settings.LoadFromFile(filepath.Join(tmpDir, ".trace", "settings.local.json")) if err != nil { t.Fatalf("LoadFromFile() error = %v", err) } @@ -920,10 +920,10 @@ func TestPersistSummaryProviderSelection_ExternalAlreadyEnabledNoSignal(t *testi testutil.InitRepo(t, tmpDir) t.Chdir(tmpDir) - if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(tmpDir, ".trace"), 0o755); err != nil { t.Fatalf("mkdir .entire: %v", err) } - if err := os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.local.json"), []byte(`{"external_agents":true}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, ".trace", "settings.local.json"), []byte(`{"external_agents":true}`), 0o644); err != nil { t.Fatalf("write settings.local.json: %v", err) } diff --git a/cli/investigate/cmd_internal_test.go b/cli/investigate/cmd_internal_test.go index 99e2368..5972765 100644 --- a/cli/investigate/cmd_internal_test.go +++ b/cli/investigate/cmd_internal_test.go @@ -16,8 +16,8 @@ import ( ) // TestSaveInvestigateConfig_WritesLocalFile verifies that -// saveInvestigateConfig persists into .entire/settings.local.json (not the -// committed .entire/settings.json). Mirrors the review-side behaviour so +// saveInvestigateConfig persists into .trace/settings.local.json (not the +// committed .trace/settings.json). Mirrors the review-side behaviour so // agent picker output stays out of project settings. // // NOTE: This test uses t.Chdir, which Go forbids combining with @@ -35,14 +35,14 @@ func TestSaveInvestigateConfig_WritesLocalFile(t *testing.T) { require.NoError(t, saveInvestigateConfig(context.Background(), cfg)) // settings.json should NOT contain investigate. - base, err := os.ReadFile(filepath.Join(tmp, ".entire/settings.json")) + base, err := os.ReadFile(filepath.Join(tmp, ".trace/settings.json")) if err == nil { require.NotContains(t, string(base), `"investigate"`, "investigate must not be written to project settings") } // settings.local.json should contain investigate. - local, err := os.ReadFile(filepath.Join(tmp, ".entire/settings.local.json")) + local, err := os.ReadFile(filepath.Join(tmp, ".trace/settings.local.json")) require.NoError(t, err) require.Contains(t, string(local), `"agents"`) require.Contains(t, string(local), `"claude-code"`) diff --git a/cli/investigate/cmd_test.go b/cli/investigate/cmd_test.go index 34f2f07..3e3a7b8 100644 --- a/cli/investigate/cmd_test.go +++ b/cli/investigate/cmd_test.go @@ -725,7 +725,7 @@ func TestNewCommand_ContinueWithMissingState(t *testing.T) { // --- helpers --------------------------------------------------------------- // saveInvestigateSettings writes an InvestigateConfig into the CWD's -// .entire/settings.json. +// .trace/settings.json. func saveInvestigateSettings(cfg *settings.InvestigateConfig) error { ctx := context.Background() s, err := settings.Load(ctx) @@ -786,9 +786,9 @@ func TestRunFresh_SkipsMultipickerWhenAgentsFlagPresent(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code","codex"]}}`), 0o644, )) @@ -822,9 +822,9 @@ func TestRunFresh_InvokesMultipickerWhenTwoAgentsAndNoFlag(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code","codex"]}}`), 0o644, )) @@ -867,9 +867,9 @@ func TestRunInvestigate_SoftWarnAcceptedRunsLoop(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code"],"max_turns":1}}`), 0o644, )) @@ -910,9 +910,9 @@ func TestRunInvestigate_SoftWarnSilentInNonInteractive(t *testing.T) { testutil.WriteFile(t, tmp, "f.txt", "x") testutil.GitAdd(t, tmp, "f.txt") testutil.GitCommit(t, tmp, "init") - require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".entire"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".trace"), 0o755)) require.NoError(t, os.WriteFile( - filepath.Join(tmp, ".entire/settings.local.json"), + filepath.Join(tmp, ".trace/settings.local.json"), []byte(`{"investigate":{"agents":["claude-code"],"max_turns":1}}`), 0o644, )) diff --git a/cli/investigate/picker.go b/cli/investigate/picker.go index 885cb46..8b35385 100644 --- a/cli/investigate/picker.go +++ b/cli/investigate/picker.go @@ -38,7 +38,7 @@ func ConfirmFirstRunSetup(ctx context.Context, out io.Writer) bool { fmt.Fprintln(out) fmt.Fprintln(out, "You'll pick which agents take turns during an investigation, and the") fmt.Fprintln(out, "max-turns / quorum the loop should use. The selection is saved to local") - fmt.Fprintln(out, "preferences (.entire/settings.local.json, not committed); edit later with `entire investigate --edit`.") + fmt.Fprintln(out, "preferences (.trace/settings.local.json, not committed); edit later with `trace investigate --edit`.") fmt.Fprintln(out, "After setup, the investigation will run with your selection.") fmt.Fprintln(out) diff --git a/cli/paths/paths.go b/cli/paths/paths.go index 4b9df92..e718423 100644 --- a/cli/paths/paths.go +++ b/cli/paths/paths.go @@ -14,7 +14,14 @@ import ( // Directory constants const ( - EntireDir = ".entire" + // EntireDir is the on-disk infrastructure directory (session metadata, + // tmp, logs). The name predates the trace rebrand; stored session data + // keeps using it so existing repositories stay readable. + EntireDir = ".entire" + // TraceDir is the canonical config directory holding the settings files + // (settings.json, settings.local.json). The legacy .entire location is + // still read when .trace files are missing (see the settings package). + TraceDir = ".trace" EntireTmpDir = ".entire/tmp" EntireMetadataDir = ".entire/metadata" @@ -123,12 +130,12 @@ func AbsPath(ctx context.Context, relPath string) (string, error) { } // IsInfrastructurePath returns true if the path is part of CLI infrastructure -// (i.e., inside the .entire directory). It is used only to EXCLUDE infra paths -// from checkpoints/tracking, so it matches case-insensitively on +// (i.e., inside the .entire or .trace directories). It is used only to EXCLUDE +// infra paths from checkpoints/tracking, so it matches case-insensitively on // case-insensitive filesystems via IsProtectedSubpath. Do not use it as a // containment/allow gate. func IsInfrastructurePath(path string) bool { - return IsProtectedSubpath(EntireDir, path) + return IsProtectedSubpath(EntireDir, path) || IsProtectedSubpath(TraceDir, path) } // IsSubpath reports whether child is lexically under parent (or equal to it). diff --git a/cli/remote_topology.go b/cli/remote_topology.go index 602975a..24be31c 100644 --- a/cli/remote_topology.go +++ b/cli/remote_topology.go @@ -174,7 +174,7 @@ func (t remoteTopology) describeCheckpointDestination(w io.Writer, header string } fmt.Fprintln(w, " To pin one repository for checkpoints, set checkpoint_remote in") - fmt.Fprintln(w, " .entire/settings.json (or .entire/settings.local.json to keep it to this clone).") + fmt.Fprintln(w, " .trace/settings.json (or .trace/settings.local.json to keep it to this clone).") } // unpinnedNames lists the remotes whose checkpoint destination is not already diff --git a/cli/resume.go b/cli/resume.go index 19fe144..2a30465 100644 --- a/cli/resume.go +++ b/cli/resume.go @@ -881,7 +881,7 @@ func checkRemoteMetadata( } else { fmt.Fprintf(errW, "Checkpoint '%s' found in commit but its metadata could not be fetched from the checkpoint remote.\n", checkpointID) } - fmt.Fprintf(errW, "Ensure you have access to the checkpoint remote configured in .entire/settings.json.\n") + fmt.Fprintf(errW, "Ensure you have access to the checkpoint remote configured in .trace/settings.json.\n") } else { fmt.Fprintf(errW, "Checkpoint '%s' found in commit but the entire/checkpoints/v1 branch is not available locally or on the remote.\n", checkpointID) fmt.Fprintf(errW, "This can happen if the metadata branch was not pushed. Try:\n") diff --git a/cli/root.go b/cli/root.go index da2d3ea..f046a1c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -51,9 +51,9 @@ func inGroup(c *cobra.Command, groupID string) *cobra.Command { func NewRootCmd() *cobra.Command { cmd := &cobra.Command{ - Use: agent.BinaryName, - Short: "Git-native session capture for AI coding agents", - Long: "The command-line interface for Trace" + gettingStarted + accessibilityHelp, + Use: agent.BinaryName, + Short: "Git-native session capture for AI coding agents", + Long: "The command-line interface for Trace" + gettingStarted + accessibilityHelp, Version: versioninfo.Version, // Let main.go handle error printing to avoid duplication SilenceErrors: true, @@ -150,7 +150,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(inGroup(newCIInitCmd(), groupSetup)) // 'ci-init' — configure CI session auto-capture cmd.AddCommand(inGroup(newOplogCmd(), groupSessions)) // 'log' — show trace's operation log cmd.AddCommand(inGroup(newAPICmd(), groupControlPlane)) // authenticated passthrough to core/cell APIs - cmd.AddCommand(newAgentHelpCmd(cmd)) // visible: agents on transports without context injection discover it via `trace help` + cmd.AddCommand(newAgentHelpCmd(cmd)) // visible: agents on transports without context injection discover it via `trace help` // Hidden top-level shortcuts. Functional but print a deprecation hint. cmd.AddCommand(hideAsAlias(newResumeCmd(), "trace session resume")) diff --git a/cli/settings/settings.go b/cli/settings/settings.go index fe1e075..9d45383 100644 --- a/cli/settings/settings.go +++ b/cli/settings/settings.go @@ -27,12 +27,21 @@ import ( ) const ( - // EntireSettingsFile is the path to the Entire settings file - EntireSettingsFile = ".entire/settings.json" - // EntireSettingsLocalFile is the path to the local settings override file (not committed) - EntireSettingsLocalFile = ".entire/settings.local.json" + // EntireSettingsFile is the path to the settings file (canonical `.trace/` + // location). The name predates the trace rebrand and is kept for API + // compatibility. + EntireSettingsFile = ".trace/settings.json" + // EntireSettingsLocalFile is the path to the local settings override file (not committed). + EntireSettingsLocalFile = ".trace/settings.local.json" // ClonePreferencesFile is the path inside the git common dir for clone-local preferences. ClonePreferencesFile = "entire/preferences.json" + + // legacyEntireSettingsFile and legacyEntireSettingsLocalFile are the + // pre-rebrand settings locations. They are still READ when the canonical + // .trace file does not exist, and their content is copied to the canonical + // location on the first save (lazy migration — never a destructive move). + legacyEntireSettingsFile = ".entire/settings.json" + legacyEntireSettingsLocalFile = ".entire/settings.local.json" ) type worktreeRootContextKey struct{} @@ -461,9 +470,10 @@ func (s *EntireSettings) InvestigateConfig() *InvestigateConfig { return s.Investigate } -// Load loads the Entire settings from .entire/settings.json, then applies +// Load loads the settings from .trace/settings.json (falling back to the +// legacy .entire/settings.json when only that exists), then applies // clone-local preferences from the git common dir, then applies any overrides -// from .entire/settings.local.json if it exists. +// from .trace/settings.local.json (with the same legacy fallback) if it exists. // Returns default settings if no settings or preferences file exists. // Works correctly from any subdirectory within the repository. func Load(ctx context.Context) (*EntireSettings, error) { @@ -490,7 +500,8 @@ func Load(ctx context.Context) (*EntireSettings, error) { // settingsAbsPaths resolves the base and local settings file paths relative to // the current working directory, falling back to the relative path when -// absolute resolution fails. +// absolute resolution fails. Read paths additionally fall back to the legacy +// .entire location when the canonical .trace file does not exist. func settingsAbsPaths(ctx context.Context) (base, local string) { base, err := paths.AbsPath(ctx, EntireSettingsFile) if err != nil { @@ -500,13 +511,74 @@ func settingsAbsPaths(ctx context.Context) (base, local string) { if err != nil { local = EntireSettingsLocalFile // Fallback to relative } - return base, local + return readSettingsPath(base), readSettingsPath(local) } // worktreeSettingsPaths resolves the base and local settings file paths under -// an explicit worktree root. +// an explicit worktree root (with the same legacy read fallback as +// settingsAbsPaths). func worktreeSettingsPaths(worktreeRoot string) (base, local string) { - return filepath.Join(worktreeRoot, EntireSettingsFile), filepath.Join(worktreeRoot, EntireSettingsLocalFile) + return readSettingsPath(filepath.Join(worktreeRoot, EntireSettingsFile)), + readSettingsPath(filepath.Join(worktreeRoot, EntireSettingsLocalFile)) +} + +// legacySettingsPath maps a canonical .trace settings path to its pre-rebrand +// .entire counterpart. It returns "" for paths that are not one of the two +// settings files, so unrelated files are never remapped. +func legacySettingsPath(canonical string) string { + for _, pair := range [][2]string{ + {EntireSettingsFile, legacyEntireSettingsFile}, + {EntireSettingsLocalFile, legacyEntireSettingsLocalFile}, + } { + suffix := filepath.FromSlash(pair[0]) + if strings.HasSuffix(canonical, suffix) { + return strings.TrimSuffix(canonical, suffix) + filepath.FromSlash(pair[1]) + } + } + return "" +} + +// readSettingsPath returns the path a settings READ should use: the canonical +// .trace location when it exists, otherwise the legacy .entire location when +// that exists, otherwise the canonical path (callers treat missing files as +// defaults). +func readSettingsPath(canonical string) string { + if pathExists(canonical) { + return canonical + } + if legacy := legacySettingsPath(canonical); legacy != "" && pathExists(legacy) { + return legacy + } + return canonical +} + +// pathExists reports whether a file (any type) exists at path. +func pathExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +// migrateLegacySettingsFile lazily migrates a pre-rebrand .entire settings file +// to its canonical .trace location. When the canonical file does not exist but +// the legacy one does, the legacy bytes are copied to the canonical path before +// the caller's own write lands. This preserves the legacy content even if the +// subsequent write fails, and never deletes or rewrites the legacy file itself. +func migrateLegacySettingsFile(canonical string) { + if canonical == "" || pathExists(canonical) { + return + } + legacy := legacySettingsPath(canonical) + if legacy == "" { + return + } + data, err := os.ReadFile(legacy) //nolint:gosec // path derived from our own constants + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(canonical), 0o750); err != nil { + return + } + _ = os.WriteFile(canonical, data, 0o644) //nolint:gosec // best-effort pre-write copy } func loadForWorktreeRoot(ctx context.Context, worktreeRoot string) (*EntireSettings, error) { @@ -581,7 +653,8 @@ func LoadFromFile(filePath string) (*EntireSettings, error) { return loadFromFile(filePath) } -// LoadProjectRaw reads .entire/settings.json as a generic JSON object so +// LoadProjectRaw reads the project settings file (.trace/settings.json, with +// a read fallback to the legacy .entire/settings.json) as a generic JSON object so // callers can inspect or mutate individual keys without losing unrelated // fields to round-trip decoding. // @@ -612,13 +685,15 @@ func LoadLocalRaw(ctx context.Context) (path string, raw map[string]json.RawMess // loadRaw reads a settings file as a generic JSON object. label ("project" or // "local") only differentiates error wording so failures name the file -// actually being read. +// actually being read. The returned path is always the canonical .trace +// location (so paired saves migrate); the read itself falls back to the legacy +// .entire location when the canonical file is missing. func loadRaw(ctx context.Context, file, label string) (path string, raw map[string]json.RawMessage, exists bool, err error) { path, err = paths.AbsPath(ctx, file) if err != nil { path = file } - data, readErr := readConfined(path) + data, readErr := readConfined(readSettingsPath(path)) if readErr != nil { if errors.Is(readErr, fs.ErrNotExist) { return path, map[string]json.RawMessage{}, false, nil @@ -632,14 +707,14 @@ func loadRaw(ctx context.Context, file, label string) (path string, raw map[stri return path, raw, true, nil } -// SaveProjectRaw writes a generic JSON object back to .entire/settings.json -// atomically (temp file + rename). Callers should mutate the map returned by -// LoadProjectRaw and pass it back here so unrelated fields are preserved. +// SaveProjectRaw writes a generic JSON object back to the project settings +// file atomically (temp file + rename). Callers should mutate the map returned +// by LoadProjectRaw and pass it back here so unrelated fields are preserved. func SaveProjectRaw(path string, raw map[string]json.RawMessage) error { return saveRaw(path, "project", raw) } -// SaveLocalRaw writes a generic JSON object back to .entire/settings.local.json +// SaveLocalRaw writes a generic JSON object back to .trace/settings.local.json // atomically (temp file + rename). Mirrors SaveProjectRaw for the per-developer // overrides file; the only difference is the error wording, which says "local // settings" so failure messages match the file actually being written. @@ -652,7 +727,9 @@ func SaveLocalRaw(path string, raw map[string]json.RawMessage) error { } // saveRaw writes a generic JSON settings object atomically (temp file + -// rename). label matches loadRaw's error-wording convention. +// rename). label matches loadRaw's error-wording convention. Saves always +// target the canonical .trace path; a legacy .entire file is copied there +// first when the canonical file does not exist yet (lazy migration). func saveRaw(path, label string, raw map[string]json.RawMessage) error { data, err := jsonutil.MarshalIndentWithNewline(raw, "", " ") if err != nil { @@ -665,6 +742,7 @@ func saveRaw(path, label string, raw map[string]json.RawMessage) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("creating %s settings directory: %w", label, err) } + migrateLegacySettingsFile(path) if err := jsonutil.WriteFileAtomic(path, data, 0o644); err != nil { return fmt.Errorf("writing %s settings: %w", label, err) } @@ -1312,19 +1390,25 @@ func mergeStringMap(dst *map[string]string, raw json.RawMessage, field string) e } // IsSetUp returns true if Entire has been set up in the current repository. -// This checks if .entire/settings.json exists. +// This checks if the settings file exists (canonical .trace or legacy .entire). // Use this to avoid creating files/directories in repos where Entire was never enabled. func IsSetUp(ctx context.Context) bool { settingsFileAbs, err := paths.AbsPath(ctx, EntireSettingsFile) if err != nil { return false } - _, err = os.Lstat(settingsFileAbs) - return err == nil + if pathExists(settingsFileAbs) { + return true + } + if legacy := legacySettingsPath(settingsFileAbs); legacy != "" && pathExists(legacy) { + return true + } + return false } // IsSetUpAny returns true if Entire has been set up in the current repository, -// checking both .entire/settings.json and .entire/settings.local.json. +// checking both the settings file and the local settings override (canonical +// .trace paths plus their legacy .entire counterparts). // Use this to detect any prior setup, even if only local settings exist. func IsSetUpAny(ctx context.Context) bool { if IsSetUp(ctx) { @@ -1334,8 +1418,13 @@ func IsSetUpAny(ctx context.Context) bool { if err != nil { return false } - _, err = os.Lstat(localFileAbs) - return err == nil + if pathExists(localFileAbs) { + return true + } + if legacy := legacySettingsPath(localFileAbs); legacy != "" && pathExists(legacy) { + return true + } + return false } // IsSetUpAndEnabled returns true if Entire is both set up and enabled. @@ -1566,17 +1655,21 @@ func IsSignCheckpointCommitsEnabled(ctx context.Context) bool { return s.IsSignCheckpointCommitsEnabled() } -// Save saves the settings to .entire/settings.json. +// Save saves the settings to .trace/settings.json (migrating a legacy +// .entire/settings.json on the first save when .trace/settings.json is absent). func Save(ctx context.Context, settings *EntireSettings) error { return saveToFile(ctx, settings, EntireSettingsFile) } -// SaveLocal saves the settings to .entire/settings.local.json. +// SaveLocal saves the settings to .trace/settings.local.json (with the same +// legacy lazy-migration as Save). func SaveLocal(ctx context.Context, settings *EntireSettings) error { return saveToFile(ctx, settings, EntireSettingsLocalFile) } -// saveToFile saves settings to the specified file path. +// saveToFile saves settings to the specified file path. Saves always target +// the canonical .trace path; a legacy .entire file is copied there first when +// the canonical file does not exist yet (lazy migration). func saveToFile(ctx context.Context, settings *EntireSettings, filePath string) error { // Get absolute path for the file filePathAbs, err := paths.AbsPath(ctx, filePath) @@ -1590,6 +1683,8 @@ func saveToFile(ctx context.Context, settings *EntireSettings, filePath string) return fmt.Errorf("creating settings directory: %w", err) } + migrateLegacySettingsFile(filePathAbs) + data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ") if err != nil { return fmt.Errorf("marshaling settings: %w", err) diff --git a/cli/settings/settings_path_test.go b/cli/settings/settings_path_test.go new file mode 100644 index 0000000..9ee395e --- /dev/null +++ b/cli/settings/settings_path_test.go @@ -0,0 +1,180 @@ +package settings + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/trace/cli/paths" + "github.com/GrayCodeAI/trace/cli/testutil" +) + +// setupPathTestRepo creates a real git repo and chdirs into it, with a clean +// worktree-root cache so paths.AbsPath resolves inside the repo. +func setupPathTestRepo(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + t.Cleanup(paths.ClearWorktreeRootCache) + return tmpDir +} + +func writeLegacySettings(t *testing.T, repoDir, name, content string) { + t.Helper() + legacyDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(legacyDir, 0o755); err != nil { + t.Fatalf("failed to create legacy .entire dir: %v", err) + } + if err := os.WriteFile(filepath.Join(legacyDir, name), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write legacy %s: %v", name, err) + } +} + +// TestSettingsPaths_FreshInstallUsesTrace verifies a fresh repo has no legacy +// footprint: reads see defaults and saves land in .trace/. +func TestSettingsPaths_FreshInstallUsesTrace(t *testing.T) { + repoDir := setupPathTestRepo(t) + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() on fresh repo: %v", err) + } + if !s.Enabled { + t.Fatal("fresh repo should default to enabled") + } + + s.LogLevel = "debug" + if err := Save(context.Background(), s); err != nil { + t.Fatalf("Save() on fresh repo: %v", err) + } + + if _, err := os.Stat(filepath.Join(repoDir, ".trace", "settings.json")); err != nil { + t.Fatalf("save should write .trace/settings.json: %v", err) + } + if _, err := os.Stat(filepath.Join(repoDir, ".entire", "settings.json")); err == nil { + t.Fatal("fresh install must not create a legacy .entire/settings.json") + } +} + +// TestSettingsPaths_LegacyEntireIsRead verifies a pre-rebrand .entire +// settings file is still loaded (read compatibility, no migration on read). +func TestSettingsPaths_LegacyEntireIsRead(t *testing.T) { + repoDir := setupPathTestRepo(t) + writeLegacySettings(t, repoDir, "settings.json", `{"enabled": false, "log_level": "warn"}`) + writeLegacySettings(t, repoDir, "settings.local.json", `{"log_level": "error"}`) + + if !IsSetUp(context.Background()) { + t.Fatal("IsSetUp() should be true when only legacy .entire/settings.json exists") + } + if !IsSetUpAny(context.Background()) { + t.Fatal("IsSetUpAny() should be true when only legacy settings files exist") + } + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() with legacy settings: %v", err) + } + if s.Enabled { + t.Fatal("legacy enabled=false should be honored") + } + if s.LogLevel != "error" { + t.Fatalf("legacy local override should win, got log_level=%q", s.LogLevel) + } + + // Reading must not migrate anything. + if _, err := os.Stat(filepath.Join(repoDir, ".trace", "settings.json")); err == nil { + t.Fatal("Load() must not create .trace/settings.json") + } +} + +// TestSettingsPaths_LegacyLocalOnlyIsSetUp verifies a local-only legacy setup +// (enable --local wrote only settings.local.json) is still detected. +func TestSettingsPaths_LegacyLocalOnlyIsSetUp(t *testing.T) { + repoDir := setupPathTestRepo(t) + writeLegacySettings(t, repoDir, "settings.local.json", `{"enabled": true}`) + + if IsSetUp(context.Background()) { + t.Fatal("IsSetUp() should be false when no base settings file exists") + } + if !IsSetUpAny(context.Background()) { + t.Fatal("IsSetUpAny() should be true for a legacy local-only setup") + } +} + +// TestSettingsPaths_FirstSaveMigrates verifies the lazy migration: the first +// save copies the legacy content to the canonical .trace location (and then +// writes the saved settings there), while the legacy file is left untouched. +func TestSettingsPaths_FirstSaveMigrates(t *testing.T) { + repoDir := setupPathTestRepo(t) + const legacyContent = `{"enabled": true, "log_level": "warn"}` + writeLegacySettings(t, repoDir, "settings.json", legacyContent) + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load(): %v", err) + } + s.LogLevel = "debug" + if err := Save(context.Background(), s); err != nil { + t.Fatalf("Save(): %v", err) + } + + // Canonical file now exists and carries the saved (migrated) settings. + canonical := filepath.Join(repoDir, ".trace", "settings.json") + data, err := os.ReadFile(canonical) + if err != nil { + t.Fatalf("first save should create %s: %v", canonical, err) + } + var saved struct { + Enabled bool `json:"enabled"` + LogLevel string `json:"log_level"` + } + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("parsing saved settings: %v", err) + } + if !saved.Enabled || saved.LogLevel != "debug" { + t.Fatalf("saved settings should carry migrated content, got %s", data) + } + + // The legacy file is preserved verbatim. + legacyData, err := os.ReadFile(filepath.Join(repoDir, ".entire", "settings.json")) + if err != nil { + t.Fatalf("legacy file must be preserved: %v", err) + } + if string(legacyData) != legacyContent { + t.Fatalf("legacy file must be untouched, got %s", legacyData) + } + + // Subsequent loads prefer the canonical file. + s2, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() after migration: %v", err) + } + if s2.LogLevel != "debug" { + t.Fatalf("post-migration load should read canonical file, got log_level=%q", s2.LogLevel) + } +} + +// TestSettingsPaths_LoadProjectRawReturnsCanonicalPath verifies the raw +// read-modify-write pair reads legacy content but reports (and therefore +// saves to) the canonical path. +func TestSettingsPaths_LoadProjectRawReturnsCanonicalPath(t *testing.T) { + repoDir := setupPathTestRepo(t) + writeLegacySettings(t, repoDir, "settings.json", `{"enabled": true, "log_level": "warn"}`) + + path, raw, exists, err := LoadProjectRaw(context.Background()) + if err != nil || !exists { + t.Fatalf("LoadProjectRaw() = (%q, %v, %v, %v), want exists", path, raw, exists, err) + } + // Compare suffixes: git may resolve the macOS /var symlink to /private/var. + if !strings.HasSuffix(path, filepath.Join(".trace", "settings.json")) { + t.Fatalf("LoadProjectRaw() path = %q, want canonical .trace/settings.json under %q", path, repoDir) + } + if string(raw["log_level"]) != `"warn"` { + t.Fatalf("LoadProjectRaw() should read legacy content, got %v", raw) + } +} diff --git a/cli/setup.go b/cli/setup.go index cc07409..f9cee1f 100644 --- a/cli/setup.go +++ b/cli/setup.go @@ -30,8 +30,8 @@ import ( // Config path display strings const ( - configDisplayProject = ".entire/settings.json" - configDisplayLocal = ".entire/settings.local.json" + configDisplayProject = ".trace/settings.json" + configDisplayLocal = ".trace/settings.local.json" ) // Flag names used across setup commands. @@ -780,8 +780,8 @@ Examples: cmd.Flags().BoolVar(&opts.LocalDev, flagLocalDev, false, "Use go run instead of entire binary for hooks") cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above - cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .entire/settings.local.json instead of .entire/settings.json") - cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") + cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json") + cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists") cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Reinstall the Entire git hook") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)") @@ -925,8 +925,8 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above cmd.Flags().BoolVar(&ignoreUntracked, "ignore-untracked", false, "Commit all new files without tracking pre-existing untracked files") cmd.Flags().MarkHidden("ignore-untracked") //nolint:errcheck,gosec // flag is defined above - cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .entire/settings.local.json instead of .entire/settings.json") - cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") + cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json") + cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists") cmd.Flags().StringVar(&agentName, agentFlagName, "", "Agent to set up hooks for (e.g., "+strings.Join(agent.StringList(), ", ")+"; external agents on $PATH are also available). Enables non-interactive mode.") cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Force reinstall hooks (removes existing Entire hooks first)") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") @@ -1080,8 +1080,8 @@ To completely remove Entire integrations from this repository, use --uninstall: }, } - cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Update .entire/settings.local.json (the default) instead of .entire/settings.json") - cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .entire/settings.json instead of .entire/settings.local.json") + cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Update .trace/settings.local.json (the default) instead of .trace/settings.json") + cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .trace/settings.json instead of .trace/settings.local.json") cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Completely remove Entire from this repository") cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt (use with --uninstall)") @@ -1250,7 +1250,8 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent if err != nil { entireDirAbs = paths.EntireDir // Fallback to relative } - shouldUseLocal, showNotification := determineSettingsTarget(entireDirAbs, opts.UseLocalSettings, opts.UseProjectSettings) + shouldUseLocal, showNotification := determineSettingsTarget( + existingProjectSettingsPath(ctx, entireDirAbs), opts.UseLocalSettings, opts.UseProjectSettings) if showNotification { fmt.Fprintln(w, "Info: Project settings exist. Saving to settings.local.json instead.") @@ -1994,10 +1995,12 @@ func validateSetupFlags(useLocal, useProject bool) error { } // determineSettingsTarget decides whether to write to settings.local.json based on: -// - Whether settings.json already exists +// - Whether the project settings file already exists // - The --local and --project flags -// Returns (useLocal, showNotification). -func determineSettingsTarget(entireDir string, useLocal, useProject bool) (bool, bool) { +// Returns (useLocal, showNotification). projectSettingsPath is the path the +// caller resolved for the project settings file (canonical .trace location, +// or the legacy .entire location when only that exists). +func determineSettingsTarget(projectSettingsPath string, useLocal, useProject bool) (bool, bool) { // Explicit --local flag always uses local settings if useLocal { return true, false @@ -2009,8 +2012,7 @@ func determineSettingsTarget(entireDir string, useLocal, useProject bool) (bool, } // No flags specified - check if settings file exists - settingsPath := filepath.Join(entireDir, paths.SettingsFileName) - if _, err := os.Lstat(settingsPath); err == nil { + if _, err := os.Lstat(projectSettingsPath); err == nil { // Settings file exists - auto-redirect to local with notification return true, true } @@ -2019,6 +2021,25 @@ func determineSettingsTarget(entireDir string, useLocal, useProject bool) (bool, return false, false } +// existingProjectSettingsPath returns the absolute path of the project +// settings file when it exists — the canonical .trace/settings.json, or the +// legacy .entire/settings.json when only that exists — defaulting to the +// canonical path when neither exists. +func existingProjectSettingsPath(ctx context.Context, entireDirAbs string) string { + projectSettingsAbs, err := paths.AbsPath(ctx, settings.EntireSettingsFile) + if err != nil { + projectSettingsAbs = settings.EntireSettingsFile // Fallback to relative + } + if _, statErr := os.Lstat(projectSettingsAbs); statErr == nil { + return projectSettingsAbs + } + legacy := filepath.Join(entireDirAbs, paths.SettingsFileName) + if _, statErr := os.Lstat(legacy); statErr == nil { + return legacy + } + return projectSettingsAbs +} + // setupEntireDirectory creates the .entire directory and gitignore. // Returns true if the directory was created, false if it already existed. func setupEntireDirectory(ctx context.Context) (bool, error) { //nolint:unparam // already present in codebase @@ -2455,14 +2476,18 @@ func countShadowBranches(ctx context.Context) int { return len(branches) } -// checkEntireDirExists checks if the .entire directory exists. +// checkEntireDirExists checks if the .entire or .trace directory exists. func checkEntireDirExists(ctx context.Context) bool { - entireDirAbs, err := paths.AbsPath(ctx, paths.EntireDir) - if err != nil { - entireDirAbs = paths.EntireDir + for _, dir := range []string{paths.EntireDir, paths.TraceDir} { + dirAbs, err := paths.AbsPath(ctx, dir) + if err != nil { + dirAbs = dir + } + if _, err := os.Lstat(dirAbs); err == nil { + return true + } } - _, err = os.Lstat(entireDirAbs) - return err == nil + return false } // removeAgentHooks removes hooks from all agents that support hooks. @@ -2517,14 +2542,16 @@ func removeAllSessionStates(ctx context.Context) (int, error) { return count, nil } -// removeEntireDirectory removes the .entire directory. +// removeEntireDirectory removes the .entire and .trace directories. func removeEntireDirectory(ctx context.Context) error { - entireDirAbs, err := paths.AbsPath(ctx, paths.EntireDir) - if err != nil { - entireDirAbs = paths.EntireDir - } - if err := os.RemoveAll(entireDirAbs); err != nil { - return fmt.Errorf("failed to remove .entire directory: %w", err) + for _, dir := range []string{paths.EntireDir, paths.TraceDir} { + dirAbs, err := paths.AbsPath(ctx, dir) + if err != nil { + dirAbs = dir + } + if err := os.RemoveAll(dirAbs); err != nil { + return fmt.Errorf("failed to remove %s directory: %w", dir, err) + } } return nil } diff --git a/cli/setup_test.go b/cli/setup_test.go index e529668..a2d8e44 100644 --- a/cli/setup_test.go +++ b/cli/setup_test.go @@ -1137,7 +1137,7 @@ func TestDetermineSettingsTarget_ExplicitLocalFlag(t *testing.T) { } // With --local flag, should always use local - useLocal, showNotification := determineSettingsTarget(tmpDir, true, false) + useLocal, showNotification := determineSettingsTarget(settingsPath, true, false) if !useLocal { t.Error("determineSettingsTarget() should return useLocal=true with --local flag") } @@ -1156,7 +1156,7 @@ func TestDetermineSettingsTarget_ExplicitProjectFlag(t *testing.T) { } // With --project flag, should always use project - useLocal, showNotification := determineSettingsTarget(tmpDir, false, true) + useLocal, showNotification := determineSettingsTarget(settingsPath, false, true) if useLocal { t.Error("determineSettingsTarget() should return useLocal=false with --project flag") } @@ -1175,7 +1175,7 @@ func TestDetermineSettingsTarget_SettingsExists_NoFlags(t *testing.T) { } // Without flags, should auto-redirect to local with notification - useLocal, showNotification := determineSettingsTarget(tmpDir, false, false) + useLocal, showNotification := determineSettingsTarget(settingsPath, false, false) if !useLocal { t.Error("determineSettingsTarget() should return useLocal=true when settings.json exists") } @@ -1190,7 +1190,8 @@ func TestDetermineSettingsTarget_SettingsNotExists_NoFlags(t *testing.T) { // No settings.json exists // Should use project settings (create new) - useLocal, showNotification := determineSettingsTarget(tmpDir, false, false) + missingPath := filepath.Join(tmpDir, paths.SettingsFileName) + useLocal, showNotification := determineSettingsTarget(missingPath, false, false) if useLocal { t.Error("determineSettingsTarget() should return useLocal=false when settings.json doesn't exist") } @@ -1219,23 +1220,33 @@ func TestRunUninstall_Force_NothingInstalled(t *testing.T) { func TestRunUninstall_Force_RemovesEntireDirectory(t *testing.T) { setupTestRepo(t) - // Create .entire directory with settings + // Create the settings directory (.trace) plus the infrastructure + // directory (.entire), mirroring what enable provisions. writeSettings(t, testSettingsEnabled) + if err := os.MkdirAll(paths.EntireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } - // Verify directory exists - entireDir := paths.EntireDir - if _, err := os.Stat(entireDir); os.IsNotExist(err) { + // Verify both directories exist + traceDir := paths.TraceDir + if _, err := os.Stat(traceDir); os.IsNotExist(err) { + t.Fatal(".trace directory should exist before uninstall") + } + if _, err := os.Stat(paths.EntireDir); os.IsNotExist(err) { t.Fatal(".entire directory should exist before uninstall") } var stdout, stderr bytes.Buffer err := runUninstall(context.Background(), &stdout, &stderr, true) if err != nil { - t.Fatalf("runUninstall() error = %v", err) + t.Fatalf("runUninstall() error: %v", err) } - // Verify directory is removed - if _, err := os.Stat(entireDir); !os.IsNotExist(err) { + // Verify both directories are removed + if _, err := os.Stat(traceDir); !os.IsNotExist(err) { + t.Error(".trace directory should be removed after uninstall") + } + if _, err := os.Stat(paths.EntireDir); !os.IsNotExist(err) { t.Error(".entire directory should be removed after uninstall") } @@ -2539,7 +2550,7 @@ func TestManageAgents_NoChanges_StillPersistsVercelSetting(t *testing.T) { if strings.Contains(buf.String(), "No changes made.") { t.Fatalf("did not expect no-op output when settings changed, got: %s", buf.String()) } - if !strings.Contains(buf.String(), ".entire/settings.json") { + if !strings.Contains(buf.String(), ".trace/settings.json") { t.Fatalf("expected settings update output, got: %s", buf.String()) } @@ -2757,7 +2768,7 @@ func TestMaybePromptVercelDeploymentDisable_SkipsPromptWhenAlreadyDisabledInVerc if promptCalled { t.Fatal("expected Vercel prompt to be skipped when already configured") } - if !strings.Contains(buf.String(), ".entire/settings.json") { + if !strings.Contains(buf.String(), ".trace/settings.json") { t.Fatalf("expected settings update output, got %q", buf.String()) } diff --git a/cli/strategy/common.go b/cli/strategy/common.go index 9cd9370..951464e 100644 --- a/cli/strategy/common.go +++ b/cli/strategy/common.go @@ -334,6 +334,7 @@ func checkpointInfosFromCommitted(committed []checkpoint.CheckpointInfo) []Check const ( entireGitignore = ".entire/.gitignore" entireDir = ".entire" + traceGitignore = ".trace/.gitignore" gitDir = ".git" shadowBranchPrefix = "entire/" ) @@ -1128,12 +1129,34 @@ func GetGitCommonDir(ctx context.Context) (string, error) { } // EnsureEntireGitignore ensures all required entries are in .entire/.gitignore -// Works correctly from any subdirectory within the repository. +// and .trace/.gitignore (the canonical settings dir's local override must stay +// untracked, mirroring the legacy layout). Works correctly from any +// subdirectory within the repository. func EnsureEntireGitignore(ctx context.Context) error { + if err := ensureGitignoreEntries(ctx, entireGitignore, entireDir, []string{ + "tmp/", + "settings.local.json", + "metadata/", + "logs/", + redact.RedactorsDirName + "/local/", + }); err != nil { + return err + } + // The .trace dir holds only the settings files; its local override is the + // sole entry that must be ignored there. + return ensureGitignoreEntries(ctx, traceGitignore, paths.TraceDir, []string{ + "settings.local.json", + }) +} + +// ensureGitignoreEntries appends any missing entries to the gitignore file at +// gitignorePath (resolved relative to the worktree root), creating its parent +// directory when needed. dirLabel names the directory in error messages. +func ensureGitignoreEntries(ctx context.Context, gitignorePath, dirLabel string, requiredEntries []string) error { // Get absolute path for the gitignore file - gitignoreAbs, err := paths.AbsPath(ctx, entireGitignore) + gitignoreAbs, err := paths.AbsPath(ctx, gitignorePath) if err != nil { - gitignoreAbs = entireGitignore // Fallback to relative + gitignoreAbs = gitignorePath // Fallback to relative } // Read existing content @@ -1142,15 +1165,6 @@ func EnsureEntireGitignore(ctx context.Context) error { content = string(data) } - // All entries that should be in .entire/.gitignore - requiredEntries := []string{ - "tmp/", - "settings.local.json", - "metadata/", - "logs/", - redact.RedactorsDirName + "/local/", - } - // Track what needs to be added var toAdd []string for _, entry := range requiredEntries { @@ -1164,9 +1178,9 @@ func EnsureEntireGitignore(ctx context.Context) error { return nil } - // Ensure .entire directory exists + // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(gitignoreAbs), 0o750); err != nil { - return fmt.Errorf("failed to create .entire directory: %w", err) + return fmt.Errorf("failed to create %s directory: %w", dirLabel, err) } // Append missing entries to gitignore diff --git a/cli/strategy/manual_commit_opf_prompt_test.go b/cli/strategy/manual_commit_opf_prompt_test.go index 50ae938..800136d 100644 --- a/cli/strategy/manual_commit_opf_prompt_test.go +++ b/cli/strategy/manual_commit_opf_prompt_test.go @@ -87,13 +87,13 @@ func TestResolveOPFDecision_Precedence(t *testing.T) { // TestPersistOPFPromptDefaultAlways_WritesNestedField verifies that the // "Always" branch updates redaction.openai_privacy_filter.prompt_default -// in .entire/settings.local.json without disturbing other fields. +// in .trace/settings.local.json without disturbing other fields. // // Modifies process cwd (no t.Parallel), but uses t.Chdir so subsequent // tests see the reverted cwd. func TestPersistOPFPromptDefaultAlways_WritesNestedField(t *testing.T) { tempDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.EntireDir), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.TraceDir), 0o755)) // Seed an existing settings.local.json with some unrelated content // so we can verify it survives the write. existing := `{ @@ -104,7 +104,7 @@ func TestPersistOPFPromptDefaultAlways_WritesNestedField(t *testing.T) { } } }` - localPath := filepath.Join(tempDir, paths.EntireDir, "settings.local.json") + localPath := filepath.Join(tempDir, paths.TraceDir, "settings.local.json") require.NoError(t, os.WriteFile(localPath, []byte(existing), 0o644)) t.Chdir(tempDir) @@ -131,15 +131,15 @@ func TestPersistOPFPromptDefaultAlways_WritesNestedField(t *testing.T) { } // TestPersistOPFPromptDefaultAlways_CreatesFileFromScratch covers the -// fresh-install path where .entire/settings.local.json doesn't exist yet. +// fresh-install path where .trace/settings.local.json doesn't exist yet. func TestPersistOPFPromptDefaultAlways_CreatesFileFromScratch(t *testing.T) { tempDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.EntireDir), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, paths.TraceDir), 0o755)) t.Chdir(tempDir) require.NoError(t, persistOPFPromptDefaultAlways(context.Background())) - localPath := filepath.Join(tempDir, paths.EntireDir, "settings.local.json") + localPath := filepath.Join(tempDir, paths.TraceDir, "settings.local.json") got, err := os.ReadFile(localPath) require.NoError(t, err, "settings.local.json should be created") diff --git a/cli/trace.go b/cli/trace.go index 483c715..8d52ca8 100644 --- a/cli/trace.go +++ b/cli/trace.go @@ -250,7 +250,7 @@ func renderTraceEntries(w io.Writer, entries []traceEntry) { if len(entries) == 0 { fmt.Fprintln(w, "No trace entries found.") fmt.Fprintln(w, `Traces are logged at DEBUG level. Make sure ENTIRE_LOG_LEVEL=DEBUG is set`) - fmt.Fprintln(w, `in your shell profile, or set log_level to "DEBUG" in .entire/settings.json.`) + fmt.Fprintln(w, `in your shell profile, or set log_level to "DEBUG" in .trace/settings.json.`) return } diff --git a/cli/trace_cmd.go b/cli/trace_cmd.go index 05364ba..45a03f0 100644 --- a/cli/trace_cmd.go +++ b/cli/trace_cmd.go @@ -20,7 +20,7 @@ func newTraceCmd() *cobra.Command { Traces are emitted at DEBUG log level. To enable them, either: - Set ENTIRE_LOG_LEVEL=DEBUG in your shell profile - - Add "log_level": "DEBUG" to .entire/settings.json + - Add "log_level": "DEBUG" to .trace/settings.json Examples: entire doctor trace Show the most recent hook trace From d918760f2bd05f1bfcd3df678b89210ff4a11e96 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:46:21 +0530 Subject: [PATCH 5/8] docs(readme): fix stale claims about the binary, examples, and docs pointers - Quick Start claimed 'no separate trace binary to install' while cmd/trace/main.go provides a standalone entrypoint; the text now says Trace is a library first (embedded as 'hawk trace ...'), with a buildable-but-unreleased cmd/trace entrypoint for contributors. - The Typical Workflow / Commands examples use 'trace ...'; a note clarifies the same commands run as 'hawk trace ...' under Hawk. - Configuration documents the legacy .entire/ read compat. - 'See CLAUDE.md for architecture details' pointed at GitNexus boilerplate; now points at docs/architecture.md. --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 809a6e2..64f7304 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ individual strategy, checkpoint, agent, storage, or UI packages. Changes to ## Quick Start -Trace is a **library**, not a standalone binary. Its full command tree is built by -`cli.NewRootCmd()` and surfaced inside the **Hawk** CLI as `hawk trace ...` (Hawk is in -development — no public install yet). There is no separate `trace` binary to install. +Trace is a **library first**: its full command tree is built by `cli.NewRootCmd()` +and surfaced inside the **Hawk** CLI as `hawk trace ...` (Hawk is in development — +no public install yet). A minimal standalone entrypoint also exists at +`cmd/trace/main.go`; it is not built or released by the Makefile, but contributors +can produce a local `trace` binary with `go build ./cmd/trace`. **Contributors — build/test the library from source:** @@ -118,6 +120,9 @@ Your Branch trace/checkpoints/v1 ## Typical Workflow +The examples below use the standalone `trace` binary; under Hawk the same +commands run as `hawk trace ...`. + ### 1. Enable ```bash @@ -205,7 +210,10 @@ Run `trace --help` for detailed usage. ## Configuration -Trace stores config in `.trace/` at the repo root. +Trace stores config in `.trace/` at the repo root. Repositories set up before +the rebrand keep working: a legacy `.entire/settings.json` (or +`.entire/settings.local.json`) is still read when the `.trace/` file is absent, +and its content is copied to the new location on the first save. ### Project settings (`.trace/settings.json`) @@ -315,7 +323,7 @@ mise run test:ci mise run fmt && mise run lint ``` -See [CLAUDE.md](CLAUDE.md) for architecture details. +See [docs/architecture.md](docs/architecture.md) for architecture details. --- From 583d7fa9f9efa3d88b8cb53f8d5f268598ffe93c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:46:21 +0530 Subject: [PATCH 6/8] chore(coreapi): pin ogen generator directive to v1.23.0 The //go:generate directive pinned ogen@v1.20.3 while go.mod requires v1.23.0, so a bare 'go generate' would regenerate with a different generator than the module builds against. Pin the directive to v1.23.0 and note that regenerating the oas_*.go files (large diff) is pending. --- internal/coreapi/gen.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/coreapi/gen.go b/internal/coreapi/gen.go index e7acaf6..c87a3a8 100644 --- a/internal/coreapi/gen.go +++ b/internal/coreapi/gen.go @@ -21,5 +21,7 @@ package coreapi //go:generate go run spec/normalize.go -//go:generate go run github.com/ogen-go/ogen/cmd/ogen@v1.20.3 --target . --package coreapi --config ogen.yml --clean spec/core.gen.json +// NOTE: pinned to v1.23.0 to match go.mod; regenerating the oas_*.go files +// with it is still pending (large diff, tracked separately). +//go:generate go run github.com/ogen-go/ogen/cmd/ogen@v1.23.0 --target . --package coreapi --config ogen.yml --clean spec/core.gen.json //go:generate gofmt -s -w . From deb02419ea053faaa4f2a61bd9a179593293b5d8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:46:26 +0530 Subject: [PATCH 7/8] docs(changelog): record README truth fixes and ogen pin alignment --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfe52a..63e5c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). This project ad - asciinema export — `trace session export --format asciinema` renders a transcript as a playable v2 cast. ### Changed +- **README truth fixes** — Quick Start now describes both composition + surfaces accurately (library embedded as `hawk trace ...`, plus the + buildable-but-unreleased `cmd/trace` standalone entrypoint), workflow + examples note the `hawk trace` prefix, the Configuration section documents + the legacy `.entire/` read compat, and the Development section points at + `docs/architecture.md` instead of the GitNexus-boilerplate CLAUDE.md. +- **ogen generator pin aligned with go.mod** — the `//go:generate` directive + in `internal/coreapi/gen.go` now pins ogen@v1.23.0 (go.mod requires + v1.23.0; the directive previously pinned v1.20.3). Regenerating the + oas_*.go files is still pending (large diff, tracked separately). - **Settings moved to `.trace/` with a read-compat shim** — the canonical settings files are now `.trace/settings.json` and `.trace/settings.local.json` (matching the README), replacing the From 365881aac9538aaa0607cf060b8b81909d691eb9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 08:30:33 +0530 Subject: [PATCH 8/8] fix: gofumpt formatting, bump Go to 1.26.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli/agent/hook_command.go, cli/setup.go: gofumpt reformat (CI fmt gate) - go.mod + CI: Go 1.26.6 — 1.26.5 stdlib has reachable vulns that fail govulncheck --- .github/workflows/ci.yml | 2 +- cli/agent/hook_command.go | 3 ++- cli/setup.go | 3 ++- go.mod | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 171599f..b4c16dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.5" + GO_VERSION: "1.26.6" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: diff --git a/cli/agent/hook_command.go b/cli/agent/hook_command.go index 786fcc9..f8a02dc 100644 --- a/cli/agent/hook_command.go +++ b/cli/agent/hook_command.go @@ -164,7 +164,8 @@ func windowsProductionHookWrapperPrefixes() []string { names := hookBinaryProbeNames() prefixes := make([]string, 0, 2*len(names)) for _, name := range names { - prefixes = append(prefixes, + prefixes = append( + prefixes, fmt.Sprintf(`where.exe %s >nul 2>nul & if errorlevel 1 `, name), fmt.Sprintf(`cmd.exe /d /s /c "where.exe %s >nul 2>nul & if errorlevel 1 `, name), ) diff --git a/cli/setup.go b/cli/setup.go index f9cee1f..eac424d 100644 --- a/cli/setup.go +++ b/cli/setup.go @@ -1251,7 +1251,8 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent entireDirAbs = paths.EntireDir // Fallback to relative } shouldUseLocal, showNotification := determineSettingsTarget( - existingProjectSettingsPath(ctx, entireDirAbs), opts.UseLocalSettings, opts.UseProjectSettings) + existingProjectSettingsPath(ctx, entireDirAbs), opts.UseLocalSettings, opts.UseProjectSettings, + ) if showNotification { fmt.Fprintln(w, "Info: Project settings exist. Saving to settings.local.json instead.") diff --git a/go.mod b/go.mod index 6ea7451..194d2eb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/GrayCodeAI/trace -go 1.26.5 +go 1.26.6 require ( charm.land/bubbles/v2 v2.1.1