Merge branch 'main' into fix/1743-defer-checkpoint-push-empty-remote · Entire

Home

Log in

Merge branch 'main' into fix/1743-defer-checkpoint-push-empty-remote

aba87cf→main·

karthik-rameshkumar·3d ago·19 files·+1,249 added/-96 removed

Changes

19

25 unmodified lines

26
27
28
29
30
31
32
33
34
35
36

25 unmodified lines

- -X github.com/entireio/cli/cmd/entire/cli/versioninfo.Commit={{.ShortCommit}}
      - -X github.com/entireio/cli/cmd/entire/cli/telemetry.PostHogAPIKey={{.Env.POSTHOG_API_KEY}}
      - -X github.com/entireio/cli/cmd/entire/cli/telemetry.PostHogEndpoint={{.Env.POSTHOG_ENDPOINT}}
      # Experimental-command visibility: hide in stable releases, keep visible
      # in nightly (prerelease) builds. .Prerelease is empty for a stable tag
      # (vX.Y.Z) and non-empty for a nightly tag (vX.Y.Z-nightly.*). Local
      # builds carry no stamp and use the package default ("true" = visible).
      - -X github.com/entireio/cli/cmd/entire/cli/experimental.Visible={{ if .Prerelease }}true{{ else }}false{{ end }}

# git-remote-entire is the git remote helper for entire:// URLs (see
  # cmd/git-remote-entire). A small, dedicated binary shipped alongside

M.goreleaser.yaml+5

25 unmodified lines

26
27
28
29
30
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
32 unmodified lines

76
77
78
69
70
71
79
80
81
82
83
84
85
86
87
16 unmodified lines

104
105
106
94
107
108
109
110
111
112
113
9 unmodified lines

123
124
125
110
126
127
128
129
130
131
490 unmodified lines

622
623
624
625
626
627
628

25 unmodified lines

top-level verbs. The groups are the canonical home for each verb; legacy
top-level shortcuts remain functional but hidden, and emit a deprecation hint
pointing at the canonical group form. Newer experimental command families are
discoverable through `entire labs` and may remain hidden from root help while
their canonical paths are still runnable.
discoverable through `entire labs` and their canonical paths are always
runnable.

Experimental commands are gated by a build-time visibility flag (the
`cmd/entire/cli/experimental` package): they are shown — grouped under an
"Experimental commands:" help section — in developer and nightly builds, and
hidden in stable release builds. Visibility is toggled by `experimental.Visible`
(default `"true"`), which GoReleaser stamps `"false"` only on stable tags
(`.Prerelease` empty); nightly (`vX.Y.Z-nightly.*`) and local builds leave it at
the default. Register a command as experimental with `experimental.Register(parent,
child)` instead of `parent.AddCommand(child)`. Gating only controls visibility —
the commands are always runnable in every build.

- `session` (alias: `sessions`): `list`, `info`, `tokens`, `stop`, `attach`, `adopt`, `resume`, `current`.
  `resume` with a branch arg switches to it and resumes its session; with no arg
32 unmodified lines

- `grant`: manage access grants and org membership — `org`, `project`, and `repo`
  each support `add` / `list` / `remove`

Experimental command families advertised through `entire labs`:

- `tokens`: `profile` (hidden from root help while token diagnostics mature)
Experimental commands (gated by the build-time visibility flag above — visible
and grouped under "Experimental commands:" in developer/nightly builds, hidden
in stable releases, always runnable): `tokens`, `import`, `review`,
`investigate`, `blame`, `why`, the top-level `search` shortcut, `experts`,
`runner`, and `checkpoint policy`. `tokens` is also advertised through `entire
labs`. The canonical `checkpoint search` is not gated and stays visible.

Top-level lifecycle and standalone commands: `enable`, `disable`, `status`,
`login`, `logout`, `clean`, `version`, `dispatch`, `activity`, `help`,
16 unmodified lines

source of truth the first-turn context injection and the `--agent-help-skill`
skill point agents at, instead of enumerating a surface that goes stale.
Hidden commands opt into being advertised here by setting
`Annotations[agentHelpAnnotation] = "true"` (e.g. `trail`).
`Annotations[agentHelpAnnotation] = "true"` (e.g. `trail`). Because `agent-help`
renders live and lists non-hidden commands, the experimental commands appear in
`agent-help` in developer/nightly builds and are absent in stable releases — the
advertised surface is build-dependent, matching what `entire help` shows.
No-channel agents (Cursor, Copilot CLI, Factory Droid, MCP hosts — no
context-injection channel and no agent-help skill template) reach it without an
active push. All of them can discover it passively: it is visible in `entire
9 unmodified lines

`resume` → `session resume`, `attach` → `session attach`, `explain` →
`checkpoint explain`, `trace` → `doctor trace`.
Cobra-native aliases (no hint): `sessions` → `session`, `cp`/`checkpoints` →
`checkpoint`. The `search` top-level remains hidden without a hint.
`checkpoint`. The `search` top-level is experimental (see the visibility gate
above), so it follows the build-dependent visibility rather than being
unconditionally hidden.

Deprecated top-level commands (functional, print a cobra deprecation message):
`reset` → `clean`, and `rewind` (no replacement, announces removal — same
490 unmodified lines

- [Sessions and Checkpoints](docs/architecture/sessions-and-checkpoints.md) - domain model, storage layout, checkpoint ID linking, commit trailers, package structure
- [Checkpoint Scenarios](docs/architecture/checkpoint-scenarios.md) - phase state machine and worked condensation scenarios
- [Ref-Based Checkpoint Backend](docs/architecture/ref-checkpoint-backend.md) - git-refs backend: primary/mirror taxonomy, ref layout + sharding, push-discovery queue, read routing, config + rollout

#### When Modifying the Strategy

MCLAUDE.md+26/-7

2 unmodified lines

3
4
5
6
7
8
9
30 unmodified lines

40
41
42
42
43
44
45
46

2 unmodified lines

import (
    "errors"

"github.com/entireio/cli/cmd/entire/cli/experimental"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/spf13/cobra"
)
30 unmodified lines

cmd.AddCommand(newCheckpointResumeCmd())
    cmd.AddCommand(newExplainCmd())
    cmd.AddCommand(newCheckpointTokensCmd())
    cmd.AddCommand(newCheckpointPolicyCmd())
    experimental.Register(cmd, newCheckpointPolicyCmd()) // 'checkpoint policy' (experimental)
    cmd.AddCommand(newRewindCmd())
    cmd.AddCommand(newCheckpointSearchCmd())

Mcmd/entire/cli/checkpoint_group.go+2/-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

// Package experimental gates the visibility of experimental CLI commands.
//
// Experimental commands stay fully runnable in every build; this package only
// controls whether they appear in `entire help`. Developer builds (go build,
// go run, mise) show them, grouped under an "Experimental commands:" help
// section. Release builds (GoReleaser) hide them.
package experimental

import "github.com/spf13/cobra"

// Visible controls whether experimental commands are shown in help. It is
// stamped by GoReleaser via ldflags
// (-X github.com/entireio/cli/cmd/entire/cli/experimental.Visible=false)
// to hide them in shipped binaries. It defaults to "true", so every
// non-release build (go build, go run, mise) shows them. The commands remain
// experimental and fully runnable regardless of this flag — it only toggles
// visibility.
var Visible = "true"

// IsVisible reports whether experimental commands are shown in help.
func IsVisible() bool { return Visible != "false" }

// GroupID is the cobra group experimental commands are filed under.
const GroupID = "experimental"

const groupTitle = "Experimental commands:"

// Register adds child under parent as an experimental command.
//
// When experimental commands are visible, child is filed under parent's
// "Experimental commands:" help group (registering the group on parent once).
// When hidden, child is marked Hidden and left ungrouped — so release help
// never carries an empty group header, and cobra never references a group ID
// that was not registered.
//
// Register overrides any Hidden value the child's constructor set, so callers
// do not need to touch the constructors (including ones in other packages).
func Register(parent, child *cobra.Command) {
    if IsVisible() {
        if !parent.ContainsGroup(GroupID) {
            parent.AddGroup(&cobra.Group{ID: GroupID, Title: groupTitle})
        }
        child.Hidden = false
        child.GroupID = GroupID
    } else {
        child.Hidden = true
        child.GroupID = ""
    }
    parent.AddCommand(child)
}

Acmd/entire/cli/experimental/experimental.go+50

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100

package experimental

import (
    "testing"

"github.com/spf13/cobra"
)

// setVisible sets the package-global Visible for the duration of the test and
// restores it afterward. Mutating a global means these tests cannot run in
// parallel.
func setVisible(t *testing.T, v string) {
    t.Helper()
    prev := Visible
    Visible = v
    t.Cleanup(func() { Visible = prev })
}

func TestIsVisible(t *testing.T) {
    tests := []struct {
        value string
        want  bool
    }{
        {"true", true},
        {"false", false},
        {"", true},         // only the literal "false" hides
        {"anything", true}, // any non-"false" stamp is treated as visible
    }
    for _, tt := range tests {
        t.Run(tt.value, func(t *testing.T) {
            setVisible(t, tt.value)
            if got := IsVisible(); got != tt.want {
                t.Fatalf("IsVisible() with Visible=%q = %v, want %v", tt.value, got, tt.want)
            }
        })
    }
}

func TestRegister_Visible(t *testing.T) {
    setVisible(t, "true")

parent := &cobra.Command{Use: "parent"}
    child := &cobra.Command{Use: "child", Hidden: true} // constructor-set Hidden must be overridden
    Register(parent, child)

if child.Hidden {
        t.Error("child should be visible when experimental commands are visible")
    }
    if child.GroupID != GroupID {
        t.Errorf("child.GroupID = %q, want %q", child.GroupID, GroupID)
    }
    if !parent.ContainsGroup(GroupID) {
        t.Error("parent should have the experimental group registered")
    }
    if len(parent.Commands()) != 1 || parent.Commands()[0] != child {
        t.Error("child should be added to parent")
    }
}

func TestRegister_Hidden(t *testing.T) {
    setVisible(t, "false")

parent := &cobra.Command{Use: "parent"}
    child := &cobra.Command{Use: "child"}
    Register(parent, child)

if !child.Hidden {
        t.Error("child should be hidden when experimental commands are hidden")
    }
    if child.GroupID != "" {
        t.Errorf("child.GroupID = %q, want empty (no group referenced in release)", child.GroupID)
    }
    if parent.ContainsGroup(GroupID) {
        t.Error("parent should not register the experimental group in release builds")
    }
    if len(parent.Commands()) != 1 || parent.Commands()[0] != child {
        t.Error("child should still be added to parent")
    }
}

// TestRegister_MultipleShareOneGroup verifies the group is registered once even
// when several experimental commands are registered under the same parent.
func TestRegister_MultipleShareOneGroup(t *testing.T) {
    setVisible(t, "true")

parent := &cobra.Command{Use: "parent"}
    Register(parent, &cobra.Command{Use: "a"})
    Register(parent, &cobra.Command{Use: "b"})

groups := parent.Groups()
    count := 0
    for _, g := range groups {
        if g.ID == GroupID {
            count++
        }
    }
    if count != 1 {
        t.Errorf("experimental group registered %d times, want 1", count)
    }
}

Acmd/entire/cli/experimental/experimental_test.go+100

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

package cli

import (
    "testing"

"github.com/entireio/cli/cmd/entire/cli/experimental"
    "github.com/spf13/cobra"
)

// experimentalRootCommands are the top-level commands gated behind the
// experimental visibility flag. Names match cobra's Command.Name() (the first
// token of Use).
var experimentalRootCommands = []string{
    "tokens", "import", "review", "investigate",
    "blame", "why", "search", "experts", "runner",
}

// withVisible sets the experimental visibility flag for the test and restores
// it afterward. Because it mutates a package global, callers must not run in
// parallel.
func withVisible(t *testing.T, v string) {
    t.Helper()
    prev := experimental.Visible
    experimental.Visible = v
    t.Cleanup(func() { experimental.Visible = prev })
}

func findCommand(parent *cobra.Command, name string) *cobra.Command {
    for _, c := range parent.Commands() {
        if c.Name() == name {
            return c
        }
    }
    return nil
}

// checkpointPolicy returns the `checkpoint policy` command.
func checkpointPolicy(t *testing.T, root *cobra.Command) *cobra.Command {
    t.Helper()
    cp := findCommand(root, "checkpoint")
    if cp == nil {
        t.Fatal("checkpoint command not found on root")
    }
    return findCommand(cp, "policy")
}

// TestExperimental_VisibleInDevBuild verifies that, in a developer build
// (Visible defaults to "true"), the experimental commands are shown and filed
// under the experimental group. Cannot use t.Parallel — mutates a global.
func TestExperimental_VisibleInDevBuild(t *testing.T) {
    withVisible(t, "true")

root := NewRootCmd()

if !root.ContainsGroup(experimental.GroupID) {
        t.Fatal("root should register the experimental group in a dev build")
    }
    for _, name := range experimentalRootCommands {
        cmd := findCommand(root, name)
        if cmd == nil {
            t.Errorf("%q not found on root", name)
            continue
        }
        if cmd.Hidden {
            t.Errorf("%q should be visible in a dev build", name)
        }
        if cmd.GroupID != experimental.GroupID {
            t.Errorf("%q GroupID = %q, want %q", name, cmd.GroupID, experimental.GroupID)
        }
    }

policy := checkpointPolicy(t, root)
    if policy == nil {
        t.Fatal("checkpoint policy not found")
    }
    if policy.Hidden {
        t.Error("checkpoint policy should be visible in a dev build")
    }
    if policy.GroupID != experimental.GroupID {
        t.Errorf("checkpoint policy GroupID = %q, want %q", policy.GroupID, experimental.GroupID)
    }
}

// TestExperimental_HiddenInReleaseBuild verifies that, when GoReleaser stamps
// Visible=false, the experimental commands are hidden, carry no group, and the
// empty experimental group is never registered (so release help is unchanged).
// Cannot use t.Parallel — mutates a global.
func TestExperimental_HiddenInReleaseBuild(t *testing.T) {
    withVisible(t, "false")

root := NewRootCmd()

if root.ContainsGroup(experimental.GroupID) {
        t.Error("root should not register the experimental group in a release build")
    }
    for _, name := range experimentalRootCommands {
        cmd := findCommand(root, name)
        if cmd == nil {
            t.Errorf("%q not found on root", name)
            continue
        }
        if !cmd.Hidden {
            t.Errorf("%q should be hidden in a release build", name)
        }
        if cmd.GroupID != "" {
            t.Errorf("%q GroupID = %q, want empty in a release build", name, cmd.GroupID)
        }
    }

policy := checkpointPolicy(t, root)
    if policy == nil {
        t.Fatal("checkpoint policy not found")
    }
    if !policy.Hidden {
        t.Error("checkpoint policy should be hidden in a release build")
    }
}

Acmd/entire/cli/experimental_wiring_test.go+117

11 unmodified lines

12
13
14
15
16
17
18
19
98 unmodified lines

118
119
120
119
121
122
123
124
2 unmodified lines

127
128
129
128
129
130
131
132
133
134
135
136

11 unmodified lines

"strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/experimental"

"charm.land/lipgloss/v2"
    "github.com/entireio/cli/cmd/entire/cli/palette"
    "github.com/entireio/cli/cmd/entire/cli/paths"
98 unmodified lines

}`
}

func TestExpertsCommandIsHiddenAndListedInLabs(t *testing.T) {
func TestExpertsCommandIsExperimentalAndListedInLabs(t *testing.T) {
    root := NewRootCmd()
    cmd, _, err := root.Find([]string{"experts"})
    if err != nil {
2 unmodified lines

if cmd.Name() != "experts" {
        t.Fatalf("found command %q, want experts", cmd.Name())
    }
    if !cmd.Hidden {
        t.Fatal("experts command should be hidden while in labs")
    // Gated as experimental: visible and grouped in developer builds
    // (the default test build), hidden in shipped releases.
    if cmd.GroupID != experimental.GroupID {
        t.Fatalf("experts GroupID = %q, want %q (experimental)", cmd.GroupID, experimental.GroupID)
    }
    if !strings.Contains(labsOverview(), "entire experts") {
        t.Fatalf("labs overview missing experts:\n%s", labsOverview())

Mcmd/entire/cli/experts_test.go+7/-3

18 unmodified lines

19
20
21
22
23
24
25
82 unmodified lines

108
109
110
110
111
112
111
112
113
114
115
116
117
118
119
120
121
122
123
124

18 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/gitrepo"
    "github.com/entireio/cli/cmd/entire/cli/logging"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/entireio/cli/cmd/entire/cli/settings"
    "github.com/entireio/cli/cmd/entire/cli/strategy"
    "github.com/entireio/cli/cmd/entire/cli/telemetry"
    "github.com/entireio/cli/cmd/entire/cli/versioncheck"
82 unmodified lines

return nil
    }

// Skip if Entire is not enabled
    enabled, err := IsEnabled(cmd.Context())
    if err == nil && !enabled {
    // Skip if Entire is not set up and enabled. This must fail closed: any
    // settings read error (missing file, corrupted JSON, transient I/O
    // failure) is treated as disabled so a hook never silently falls through
    // to full lifecycle work just because settings couldn't be read. Using
    // IsEnabled here previously failed OPEN on error (`err == nil && !enabled`
    // only short-circuits when the read succeeded), which meant a corrupted
    // or unreadable settings file made every hook invocation pay the full
    // dispatch cost instead of exiting fast (#524).
    // settings.IsSetUpAndEnabled is the same fail-closed gate the git hooks
    // use (see PersistentPreRunE in hooks_git_cmd.go).
    if !settings.IsSetUpAndEnabled(cmd.Context()) {
        return nil
    }

Mcmd/entire/cli/hook_registry.go+12/-3

230 unmodified lines

231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406

230 unmodified lines

require.True(t, os.IsNotExist(statErr), "session-start must not claim the session when checkpoint policy is unreadable")
}

// TestExecuteAgentHookShortCircuitsWhenDisabled is a regression test for #524:
// a hook must not perform any dispatch/strategy work when Entire is
// disabled. Asserted via the same "session was never claimed" signal the
// checkpoint-policy tests above use, rather than a timing assertion.
func TestExecuteAgentHookShortCircuitsWhenDisabled(t *testing.T) {
    setupStopTestRepo(t)
    repoRoot := mustGetwd(t)

entireDir := filepath.Join(repoRoot, ".entire")
    require.NoError(t, os.MkdirAll(entireDir, 0o750))
    require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled":false}`), 0o600))

sessionID := "disabled-session-start"
    payload, err := json.Marshal(map[string]string{
        "session_id":      sessionID,
        "transcript_path": filepath.Join(repoRoot, "transcript.jsonl"),
    })
    require.NoError(t, err)

cmd := &cobra.Command{}
    cmd.SetIn(bytes.NewReader(payload))
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetContext(context.Background())

require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))

hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
    _, statErr := os.Stat(hintPath)
    require.True(t, os.IsNotExist(statErr), "disabled hook must not dispatch or claim the session")
}

// TestExecuteAgentHookShortCircuitsWhenSettingsMissing is a regression test
// for #524: a repo that was never `entire enable`d (no .entire/settings.json)
// must short-circuit rather than falling through to full lifecycle dispatch.
func TestExecuteAgentHookShortCircuitsWhenSettingsMissing(t *testing.T) {
    setupStopTestRepo(t)
    repoRoot := mustGetwd(t)
    // Deliberately do NOT create .entire/settings.json.

sessionID := "missing-settings-session-start"
    payload, err := json.Marshal(map[string]string{
        "session_id":      sessionID,
        "transcript_path": filepath.Join(repoRoot, "transcript.jsonl"),
    })
    require.NoError(t, err)

cmd := &cobra.Command{}
    cmd.SetIn(bytes.NewReader(payload))
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetContext(context.Background())

require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))

hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
    _, statErr := os.Stat(hintPath)
    require.True(t, os.IsNotExist(statErr), "hook must not dispatch when Entire was never enabled in this repo")
}

// TestExecuteAgentHookShortCircuitsWhenSettingsCorrupted is a regression test
// for #524. Before this fix, IsEnabled() failed OPEN on a settings.Load()
// error (the caller's `err == nil && !enabled` check only short-circuited
// when the read succeeded), so a corrupted settings file made every hook
// invocation pay the full dispatch cost — including, for Stop hooks, a
// multi-second wait on the transcript-flush sentinel (see
// ClaudeCodeAgent.ParseHookEvent) — instead of exiting fast. The gate must
// fail closed on any settings read error.
func TestExecuteAgentHookShortCircuitsWhenSettingsCorrupted(t *testing.T) {
    setupStopTestRepo(t)
    repoRoot := mustGetwd(t)

entireDir := filepath.Join(repoRoot, ".entire")
    require.NoError(t, os.MkdirAll(entireDir, 0o750))
    require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{ enabled: false, not valid json`), 0o600))

sessionID := "corrupted-settings-session-start"
    payload, err := json.Marshal(map[string]string{
        "session_id":      sessionID,
        "transcript_path": filepath.Join(repoRoot, "transcript.jsonl"),
    })
    require.NoError(t, err)

cmd := &cobra.Command{}
    cmd.SetIn(bytes.NewReader(payload))
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetContext(context.Background())

require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))

hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
    _, statErr := os.Stat(hintPath)
    require.True(t, os.IsNotExist(statErr), "hook must fail closed (not dispatch) when settings are unreadable")
}

// TestExecuteAgentHookStopReturnsFastWhenSettingsCorrupted directly
// regression-tests the reported symptom: `entire hooks claude-code stop`
// against a corrupted settings file must return in well under the
// multi-second transcript-flush-sentinel timeout it used to hit, not just
// skip dispatch. The bound is intentionally generous (this repo has no
// other timing-based tests to match precedent against) — it only needs to
// distinguish "short-circuited" from "waited on the sentinel timeout".
func TestExecuteAgentHookStopReturnsFastWhenSettingsCorrupted(t *testing.T) {
    setupStopTestRepo(t)
    repoRoot := mustGetwd(t)

transcriptPath := filepath.Join(repoRoot, "transcript.jsonl")
    require.NoError(t, os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"content":"hi"}}`+"\n"), 0o600))

payload, err := json.Marshal(map[string]string{
        "session_id":      "corrupted-settings-stop",
        "transcript_path": transcriptPath,
    })
    require.NoError(t, err)

cmd := &cobra.Command{}
    cmd.SetIn(bytes.NewReader(payload))
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetContext(context.Background())

start := time.Now()
    require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameStop, false))
    elapsed := time.Since(start)

require.Lessf(t, elapsed, 1*time.Second,
        "stop hook took %s against a corrupted settings file; want a fast short-circuit, not the transcript-flush-sentinel timeout path", elapsed)
}

// TestExecuteAgentHookCapturesWhenEnabledViaLocalSettingsOnly guards against a
// regression in the #524 fix: `entire enable --local` writes only
// .entire/settings.local.json and never creates the base .entire/settings.json
// (see determineSettingsTarget in setup.go). The disabled-hook gate must
// recognize that local-only enablement — gating on the base file alone
// (settings.IsSetUp) would silently no-op every agent hook for that repo and
// drop all checkpoint capture. Asserted via the same "session was claimed"
// signal (the .agent hint StoreAgentTypeHint writes during SessionStart
// dispatch) the short-circuit tests above assert the *absence* of.
func TestExecuteAgentHookCapturesWhenEnabledViaLocalSettingsOnly(t *testing.T) {
    setupStopTestRepo(t)
    repoRoot := mustGetwd(t)

entireDir := filepath.Join(repoRoot, ".entire")
    require.NoError(t, os.MkdirAll(entireDir, 0o750))
    // Local-only enablement: settings.local.json present, base settings.json absent.
    require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(`{"enabled":true}`), 0o600))
    require.NoFileExists(t, filepath.Join(entireDir, "settings.json"))

sessionID := "local-only-session-start"
    payload, err := json.Marshal(map[string]string{
        "session_id":      sessionID,
        "transcript_path": transcriptPath,
    })
    require.NoError(t, err)

cmd := &cobra.Command{}
    cmd.SetIn(bytes.NewReader(payload))
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetContext(context.Background())

require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))

hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
    require.FileExists(t, hintPath, "SessionStart must dispatch and claim the session when Entire is enabled via settings.local.json only")
}

func TestAgentHookPolicyFailsWhenRepoCannotOpen(t *testing.T) {
    _, err := agentHookPolicy(context.Background(), filepath.Join(t.TempDir(), "missing"))

Mcmd/entire/cli/hook_registry_test.go+170

3 unmodified lines

4
5
6
7
8
9
10
11
58 unmodified lines

70
71
72
71
72
73
74
75
76
77
78
79
80
81
8 unmodified lines

90
91
92
87
88
93
94
95
96
97

3 unmodified lines

"bytes"
    "strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/experimental"
)

// TestBuildInvestigateDeps_HasRequiredFields asserts that the bridge
58 unmodified lines

}

// TestRootCommand_HasInvestigate confirms `entire investigate` is wired
// into the root command tree. It also checks that the command is
// Hidden (the experimental discovery happens via `entire labs`).
// into the root command tree as an experimental command. Experimental
// commands are gated by the build-time visibility flag (see the
// experimental package): shown and grouped in developer builds, hidden
// in shipped releases. This test runs with the default (developer)
// visibility, so it asserts the command is visible and filed under the
// experimental group.
func TestRootCommand_HasInvestigate(t *testing.T) {
    t.Parallel()

8 unmodified lines

if cmd.Name() != "investigate" {
        t.Fatalf("resolved command name = %q, want %q", cmd.Name(), "investigate")
    }
    if !cmd.Hidden {
        t.Fatal("investigate should be Hidden during maturation")
    if cmd.GroupID != experimental.GroupID {
        t.Fatalf("investigate GroupID = %q, want %q (experimental)", cmd.GroupID, experimental.GroupID)
    }
}

Mcmd/entire/cli/investigate_bridge_test.go+10/-4

4 unmodified lines

5
6
7
8
9
10
11
12
71 unmodified lines

84
85
86
85
86
87
87
88
89
90
91
92
93
94
93
95
96
97
97
98
99
100
101
102
103
104
105
106
107
108
109
110
101
102
103
104
105
106
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151

4 unmodified lines

"strings"
    "testing"
    "unicode/utf8"

"github.com/entireio/cli/cmd/entire/cli/experimental"
)

func TestLabsCmd_PrintsExperimentalCommandList(t *testing.T) {
71 unmodified lines

}
}

func TestRootHelp_ShowsLabsButHidesReview(t *testing.T) {
    t.Parallel()

// rootHelp renders `entire --help` and returns its stdout.
func rootHelp(t *testing.T) string {
    t.Helper()
    root := NewRootCmd()
    var out bytes.Buffer
    root.SetOut(&out)
    root.SetErr(&bytes.Buffer{})
    root.SetArgs([]string{"--help"})

if err := root.Execute(); err != nil {
        t.Fatalf("entire --help failed: %v", err)
    }
    got := out.String()
    return out.String()
}

// TestRootHelp_AlwaysShowsLabs confirms the labs command is present in root
// help regardless of the experimental visibility gate — labs is the always-on
// discovery entry point for experimental workflows.
func TestRootHelp_AlwaysShowsLabs(t *testing.T) {
    t.Parallel()

got := rootHelp(t)
    if !strings.Contains(got, "labs") || !strings.Contains(got, "Explore experimental Entire workflows") {
        t.Fatalf("root help should include labs command, got:\n%s", got)
    }
    for _, hiddenExperimentalCommand := range []string{
        "review",
        "tokens                 Analyze token usage across sessions and checkpoints",
    } {
        if strings.Contains(got, hiddenExperimentalCommand) {
            t.Fatalf("root help should not include %q while it is listed in labs, got:\n%s", hiddenExperimentalCommand, got)
}

// experimentalCommandMarkers are substrings that only appear in root help when
// experimental commands are visible.
var experimentalCommandMarkers = []string{
    "Experimental commands:",
    "review",
    "tokens                 Analyze token usage across sessions and checkpoints",
}

// TestRootHelp_ReleaseHidesExperimental verifies a shipped build
// (experimental.Visible="false") omits experimental commands and the group
// header from root help. Mutates the global gate, so it cannot run in parallel.
func TestRootHelp_ReleaseHidesExperimental(t *testing.T) {
    withVisible(t, "false")

got := rootHelp(t)
    for _, marker := range experimentalCommandMarkers {
        if strings.Contains(got, marker) {
            t.Fatalf("release root help should not include %q, got:\n%s", marker, got)
        }
    }
}

// TestRootHelp_DevShowsExperimentalGroup verifies a developer build
// (experimental.Visible="true") shows experimental commands under the
// "Experimental commands:" group in root help. Mutates the global gate, so it
// cannot run in parallel.
func TestRootHelp_DevShowsExperimentalGroup(t *testing.T) {
    withVisible(t, "true")

got := rootHelp(t)
    if !strings.Contains(got, experimental.GroupID) && !strings.Contains(got, "Experimental commands:") {
        t.Fatalf("dev root help should include the experimental group header, got:\n%s", got)
    }
    for _, marker := range experimentalCommandMarkers {
        if !strings.Contains(got, marker) {
            t.Fatalf("dev root help should include %q, got:\n%s", marker, got)
        }
    }
}

Mcmd/entire/cli/labs_test.go+53/-11

8 unmodified lines

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
23
24
25
26
43
44
45
46
47
48
49
3 unmodified lines

53
54
55
36
56
57
58
59
60
61
62
42
63
64
65
66
67
68
27 unmodified lines

96
97
98
99
100
101
102
103
104
105
77
78
106
107
108
109
110
111
112
36 unmodified lines

149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
123
124
172
173
174
175
176
177
178
179
180
181
182
1 unmodified line

184
185
186
132
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
135
136
137
138
139
140
203
204
205
206
207
208
209
1 unmodified line

211
212
213
148
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
10 unmodified lines

258
259
260
165
261
262
263
264
5 unmodified lines

270
271
272
177
273
274
275
276
8 unmodified lines

285
286
287
192
288
289
290
291
6 unmodified lines

298
299
300
205
206
207
208
209
210
211
212
301
302
303
304
305
306
307
308
216
217
309
310
311
3 unmodified lines

315
316
317
318
319
320
321
322
323
324
325
326

8 unmodified lines

import (
    "context"
    "io"
    "log/slog"
    "sync"
    "time"

tea "charm.land/bubbletea/v2"
    "golang.org/x/term"

"github.com/entireio/cli/cmd/entire/cli/logging"
    reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
)

// teaRunner is the slice of *tea.Program the sink depends on, extracted so
// tests can substitute a program with a deterministically stalled event loop.
type teaRunner interface {
    Run() (tea.Model, error)
    Send(msg tea.Msg)
    Kill()
}

// tuiSinkQueueCap bounds the sink's internal dispatch queue. Program.Send is
// an unbuffered BLOCKING send: if the Bubble Tea Update/render pipeline ever
// stalls, a direct Send from the orchestrator's dispatch goroutine parks
// forever — freezing sink dispatch, the fanIn drain loop, the parsers, and
// reviewer-timeout handling with it (observed live: the 2026-07-07 run-6
// wedge, where the TUI froze mid-run and a 20m --timeout never surfaced).
// The queue absorbs bursts; overflow beyond the cap is dropped and counted —
// a display that can lag must never backpressure the data plane.
const tuiSinkQueueCap = 4096

// TUISink is a Sink that renders a Bubble Tea dashboard. The orchestrator
// calls AgentEvent/RunFinished from a single goroutine (CU4 serial-dispatch
// contract); the sink translates each event into a tea.Msg and sends it via
// Program.Send. Bubble Tea's Send is thread-safe, but we never rely on that
// property — the serial-dispatch promise means Send is only called from the
// orchestrator's dispatch goroutine.
// contract); the sink translates each event into a tea.Msg, enqueues it on a
// bounded internal queue, and a pump goroutine forwards it via Program.Send —
// so only the pump can ever block on a stalled Bubble Tea loop, never the
// orchestrator.
//
// Cancellation: cancel is the same context.CancelFunc that controls the
// orchestrator's run context. The first KeyCtrlC in the dashboard fires this
3 unmodified lines

// root's context, which cancels the same function — no parallel signal.Notify
// goroutine is needed here.
type TUISink struct {
    program *tea.Program
    program teaRunner

mu       sync.Mutex
    started  bool
    finished bool
    dropped  int

done chan struct{} // closed when the tea.Program exits
    msgs     chan tea.Msg  // bounded dispatch queue drained by the pump
    done     chan struct{} // closed when the tea.Program exits
    pumpDone chan struct{} // closed when the pump goroutine exits
}

// Compile-time interface check.
27 unmodified lines

tea.WithInput(input),
        tea.WithoutSignalHandler(), // SIGINT handled by cobra root; KeyCtrlC calls cancel directly
    )
    return newTUISinkWithProgram(prog)
}

// newTUISinkWithProgram wires a TUISink around any teaRunner; tests inject
// fakes with stalled or recording Send implementations.
func newTUISinkWithProgram(prog teaRunner) *TUISink {
    return &TUISink{
        program: prog,
        done:    make(chan struct{}),
        program:  prog,
        msgs:     make(chan tea.Msg, tuiSinkQueueCap),
        done:     make(chan struct{}),
        pumpDone: make(chan struct{}),
    }
}

36 unmodified lines

_ = err
        }
    }()

// Pump: the only goroutine allowed to block on Program.Send. When the
    // program exits (done closes), a blocked Send unblocks via the program's
    // context and the pump drains out. A Send that races program exit (done
    // closes while a queued msg is in hand) is equally safe: Bubble Tea's
    // Send is a context-guarded select and the msgs channel is never closed,
    // so a post-exit Send is an immediate no-op — not a panic, not a block.
    go func() {
        defer close(s.pumpDone)
        for {
            select {
            case <-s.done:
                return
            case msg := <-s.msgs:
                s.program.Send(msg)
            }
        }
    }()
}

// Wait blocks until the Bubble Tea program exits. Safe to call after Start.
// If Start was never called, Wait returns immediately.
// Wait blocks until the Bubble Tea program exits, with a bounded escalation
// so teardown can never hang: in the normal flow PostRunComplete has already
// quit the program and Wait returns immediately; otherwise (early-error
// return paths, or a wedged loop that survived Kill) Wait gives the program
// one grace period, Kills it, gives it one more, and then abandons the
// goroutine — a stuck display must not hold command exit hostage. Joins the
// pump goroutine whenever the program actually exited. Safe to call after
// Start; if Start was never called, returns immediately.
func (s *TUISink) Wait() {
    s.mu.Lock()
    started := s.started
1 unmodified line

if !started {
        return
    }
    <-s.done
    select {
    case <-s.done:
        <-s.pumpDone
        return
    case <-time.After(tuiPostRunCompleteGrace):
    }
    s.program.Kill()
    select {
    case <-s.done:
        <-s.pumpDone
    case <-time.After(tuiPostRunCompleteGrace):
        // Bubble Tea never returned from Run despite Kill. Abandon the
        // program and pump goroutines rather than hanging teardown.
    }
}

// AgentEvent (Sink interface): translate ev into a tea.Msg and Send it to the
// Bubble Tea program. Implements the serial-dispatch contract: the orchestrator
// calls this from a single goroutine.
//
// Note: Send is safe to call from goroutines other than the TUI's update loop;
// Bubble Tea's implementation queues the message internally.
// AgentEvent (Sink interface): translate ev into a tea.Msg and enqueue it for
// the pump. NEVER blocks: display events beyond the queue cap are dropped and
// counted rather than backpressuring the orchestrator's dispatch goroutine —
// see tuiSinkQueueCap for the incident this guards against.
func (s *TUISink) AgentEvent(agent string, ev reviewtypes.Event) {
    s.mu.Lock()
    ok := s.started && !s.finished
1 unmodified line

if !ok {
        return
    }
    s.program.Send(agentEventMsg{agent: agent, ev: ev})
    select {
    case s.msgs <- agentEventMsg{agent: agent, ev: ev}:
    default:
        s.mu.Lock()
        s.dropped++
        s.mu.Unlock()
    }
}

// enqueueControl enqueues a rare, must-not-be-lost-lightly message (run
// summary, phase transitions, quit) with a bounded wait: worth briefly
// waiting out a transient jam, but a wedged TUI must not hold the run
// hostage — callers all have degradation paths (PostRunComplete falls back
// to Kill; a lost summary leaves the footer stale until quit).
func (s *TUISink) enqueueControl(msg tea.Msg) {
    select {
    case s.msgs <- msg:
    case <-s.done:
    case <-time.After(tuiPostRunCompleteGrace):
        s.mu.Lock()
        s.dropped++
        s.mu.Unlock()
    }
}

// droppedCount reports how many messages were discarded due to a jammed
// queue. Zero in any healthy run.
func (s *TUISink) droppedCount() int {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.dropped
}

// RunFinished (Sink interface): mark reviewer execution complete and send the
10 unmodified lines

s.finished = true
    s.mu.Unlock()

s.program.Send(runFinishedMsg{summary: summary})
    s.enqueueControl(runFinishedMsg{summary: summary})
}

// FinalPhaseStarted updates the TUI with a visible post-run phase such as the
5 unmodified lines

if !ok {
        return
    }
    s.program.Send(finalPhaseStartedMsg{name: name})
    s.enqueueControl(finalPhaseStartedMsg{name: name})
}

// FinalPhaseFinished marks the visible post-run phase complete.
8 unmodified lines

if err != nil {
        msg.err = err.Error()
    }
    s.program.Send(msg)
    s.enqueueControl(msg)
}

// PostRunComplete exits the TUI and waits for the Bubble Tea program to finish.
6 unmodified lines

return
    }

// Program.Send can block if Bubble Tea has not entered its event loop yet.
    // Send from a goroutine and fall back to Kill so a lost post-run quit cannot
    // leave the CLI stuck on "Finalizing output..." forever.
    sent := make(chan struct{})
    go func() {
        s.program.Send(postRunCompleteMsg{})
        close(sent)
    }()
    // enqueueControl is bounded, so this cannot park forever even when the
    // Bubble Tea loop is stalled or never entered; the Kill fallback below
    // guarantees a lost post-run quit cannot leave the CLI stuck on
    // "Finalizing output..." forever.
    s.enqueueControl(postRunCompleteMsg{})

select {
    case <-s.done:
        return
    case <-sent:
    case <-time.After(tuiPostRunCompleteGrace):
        s.program.Kill()
    }
3 unmodified lines

case <-time.After(tuiPostRunCompleteGrace):
        s.program.Kill()
    }

// Surface silent loss: a healthy run never drops. A non-zero count means
    // the TUI loop stalled or lagged badly enough to jam the queue — exactly
    // the diagnostic a future wedge investigation needs first.
    if n := s.droppedCount(); n > 0 {
        logging.Debug(context.Background(), "tui sink dropped messages under backpressure",
            slog.Int("dropped", n))
    }
}

Mcmd/entire/cli/review/tui_sink.go+130/-31

1 unmodified line

2
3
4
5
6
7
8
225 unmodified lines

234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468

1 unmodified line

import (
    "bytes"
    "sync"
    "testing"
    "time"

225 unmodified lines

t.Errorf("invalid fd should yield zero dims, got width=%d height=%d", width, height)
    }
}

// --- Non-blocking dispatch (wedge hardening) ---

// wedgedProgram is a teaRunner whose event loop never consumes messages:
// Send blocks until Kill, modeling a Bubble Tea program whose Update/render
// pipeline has stalled (the 2026-07-07 run-6 incident shape). Run blocks
// until Kill so the sink's done channel behaves like a live program's.
type wedgedProgram struct {
    killed chan struct{}
}

func newWedgedProgram() *wedgedProgram {
    return &wedgedProgram{killed: make(chan struct{})}
}

func (w *wedgedProgram) Run() (tea.Model, error) {
    <-w.killed
    return nil, nil //nolint:nilnil // mirrors tea.Program.Run's exit shape; callers ignore both values
}

func (w *wedgedProgram) Send(tea.Msg) { <-w.killed }

func (w *wedgedProgram) Kill() {
    select {
    case <-w.killed:
    default:
        close(w.killed)
    }
}

// recordingProgram is a teaRunner that records every message it receives.
type recordingProgram struct {
    killed chan struct{}
    mu     sync.Mutex
    msgs   []tea.Msg
}

func newRecordingProgram() *recordingProgram {
    return &recordingProgram{killed: make(chan struct{})}
}

func (r *recordingProgram) Run() (tea.Model, error) {
    <-r.killed
    return nil, nil //nolint:nilnil // mirrors tea.Program.Run's exit shape; callers ignore both values
}

func (r *recordingProgram) Send(msg tea.Msg) {
    r.mu.Lock()
    r.msgs = append(r.msgs, msg)
    r.mu.Unlock()
}

func (r *recordingProgram) Kill() {
    select {
    case <-r.killed:
    default:
        close(r.killed)
    }
}

func (r *recordingProgram) recorded() []tea.Msg {
    r.mu.Lock()
    defer r.mu.Unlock()
    return append([]tea.Msg(nil), r.msgs...)
}

// TestTUISink_AgentEventNeverBlocksWhenProgramLoopIsWedged pins the wedge
// hardening: a stalled Bubble Tea loop must never backpressure the
// orchestrator. Before the fix, the first AgentEvent after the stall parked
// forever inside Program.Send, freezing sink dispatch, the fanIn drain loop,
// the parsers, and reviewer-timeout handling with them.
func TestTUISink_AgentEventNeverBlocksWhenProgramLoopIsWedged(t *testing.T) {
    t.Parallel()
    prog := newWedgedProgram()
    sink := newTUISinkWithProgram(prog)
    sink.Start()
    defer func() {
        prog.Kill()
        sink.Wait()
    }()

finished := make(chan struct{})
    go func() {
        for range 3 * tuiSinkQueueCap {
            sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: "x"})
        }
        close(finished)
    }()

select {
    case <-finished:
    case <-time.After(5 * time.Second):
        t.Fatal("AgentEvent blocked on a wedged TUI loop — orchestrator freeze")
    }

if got := sink.droppedCount(); got == 0 {
        t.Error("expected overflow drops to be counted when the queue jams")
    }
}

// TestTUISink_EventsReachProgramInOrder pins that the async pump preserves
// dispatch order for a healthy program.
func TestTUISink_EventsReachProgramInOrder(t *testing.T) {
    t.Parallel()
    prog := newRecordingProgram()
    sink := newTUISinkWithProgram(prog)
    sink.Start()
    defer func() {
        prog.Kill()
        sink.Wait()
    }()

for i := range 50 {
        sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: string(rune('a' + i%26))})
    }
    sink.RunFinished(reviewtypes.RunSummary{})

deadline := time.After(5 * time.Second)
    for {
        msgs := prog.recorded()
        if len(msgs) >= 51 {
            for i := range 50 {
                if _, ok := msgs[i].(agentEventMsg); !ok {
                    t.Fatalf("msgs[%d] = %T, want agentEventMsg", i, msgs[i])
                }
            }
            if _, ok := msgs[50].(runFinishedMsg); !ok {
                t.Fatalf("msgs[50] = %T, want runFinishedMsg (order violated)", msgs[50])
            }
            return
        }
        select {
        case <-deadline:
            t.Fatalf("only %d/51 messages reached the program", len(msgs))
        case <-time.After(10 * time.Millisecond):
        }
    }
}

// TestTUISink_RunFinishedBoundedWhenWedged pins that control messages use a
// bounded wait rather than blocking forever when the queue is jammed.
func TestTUISink_RunFinishedBoundedWhenWedged(t *testing.T) {
    t.Parallel()
    prog := newWedgedProgram()
    sink := newTUISinkWithProgram(prog)
    sink.Start()
    defer func() {
        prog.Kill()
        sink.Wait()
    }()

// Jam the queue.
    for range 2 * tuiSinkQueueCap {
        sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: "x"})
    }

finished := make(chan struct{})
    go func() {
        sink.RunFinished(reviewtypes.RunSummary{})
        close(finished)
    }()
    select {
    case <-finished:
    case <-time.After(tuiPostRunCompleteGrace + 3*time.Second):
        t.Fatal("RunFinished blocked past its bounded wait on a wedged TUI")
    }
}

// stubbornProgram is a teaRunner whose Run NEVER returns, even after Kill —
// modeling a Bubble Tea teardown stuck restoring a blocked terminal. Send
// unblocks on Kill so the pump can drain, but done never closes.
type stubbornProgram struct {
    killed chan struct{}
    block  chan struct{}
}

func newStubbornProgram() *stubbornProgram {
    return &stubbornProgram{killed: make(chan struct{}), block: make(chan struct{})}
}

func (p *stubbornProgram) Run() (tea.Model, error) {
    <-p.block       // never closed — Run never returns
    return nil, nil //nolint:nilnil // unreachable; mirrors tea.Program.Run's shape
}

func (p *stubbornProgram) Send(tea.Msg) { <-p.killed }

func (p *stubbornProgram) Kill() {
    select {
    case <-p.killed:
    default:
        close(p.killed)
    }
}

// TestTUISink_WaitIsBoundedWhenProgramNeverExits pins the teardown guarantee:
// `defer tuiSink.Wait()` must not hang the command forever when the Bubble
// Tea program never returns from Run, even after Kill. Wait escalates
// (grace → Kill → grace) and then abandons the goroutine.
func TestTUISink_WaitIsBoundedWhenProgramNeverExits(t *testing.T) {
    t.Parallel()
    prog := newStubbornProgram()
    sink := newTUISinkWithProgram(prog)
    sink.Start()

finished := make(chan struct{})
    go func() {
        sink.Wait()
        close(finished)
    }()
    select {
    case <-finished:
    case <-time.After(2*tuiPostRunCompleteGrace + 3*time.Second):
        t.Fatal("Wait hung on a program that never exits — teardown wedge")
    }
}

// TestTUISink_WaitJoinsPump pins that a normal Wait joins the pump goroutine
// (no leak between done closing and the pump observing it).
func TestTUISink_WaitJoinsPump(t *testing.T) {
    t.Parallel()
    prog := newRecordingProgram()
    sink := newTUISinkWithProgram(prog)
    sink.Start()
    prog.Kill()
    sink.Wait()
    select {
    case <-sink.pumpDone:
    case <-time.After(2 * time.Second):
        t.Fatal("Wait returned before the pump goroutine exited")
    }
}

Mcmd/entire/cli/review/tui_sink_test.go+233

3 unmodified lines

4
5
6
7
8
9
10
75 unmodified lines

86
87
88
88
89
90
91
92
93
94
95
96
97
98
99
100
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
103
104
104
105
106
107
108
109
110
110
111
111
112
113
114
115
8 unmodified lines

124
125
126
126
127
128
128
129
129
130
131
132
133
4 unmodified lines

138
139
140
140
141
142
143
144
145
146
147
148
149

3 unmodified lines

"fmt"
    "runtime"

"github.com/entireio/cli/cmd/entire/cli/experimental"
    "github.com/entireio/cli/cmd/entire/cli/investigate"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    cliReview "github.com/entireio/cli/cmd/entire/cli/review"
75 unmodified lines

}

// Noun groups (canonical homes for subcommands).
    cmd.AddCommand(newSessionsCmd())        // 'session' (with 'sessions' as Cobra alias)
    cmd.AddCommand(newCheckpointGroupCmd()) // 'checkpoint' / 'cp' / 'checkpoints'
    cmd.AddCommand(newTokensGroupCmd())     // 'tokens'
    cmd.AddCommand(newAgentGroupCmd())      // 'agent'
    cmd.AddCommand(newAuthCmd())            // 'auth'
    cmd.AddCommand(newDoctorCmd())          // 'doctor' (group: trace/logs/bundle)
    cmd.AddCommand(newLabsCmd())            // 'labs' (experimental workflow discovery)
    cmd.AddCommand(newPluginGroupCmd())     // 'plugin' (managed install/list/remove)
    cmd.AddCommand(newImportCmd())          // 'import' (hidden; import pre-existing agent history)
    cmd.AddCommand(newOrgCmd())             // 'org' — control-plane org management
    cmd.AddCommand(newProjectCmd())         // 'project' — control-plane project management
    cmd.AddCommand(newRepoCmd())            // 'repo' — control-plane repo lifecycle
    cmd.AddCommand(newGrantCmd())           // 'grant' — control-plane access grants
    cmd.AddCommand(newSessionsCmd())                // 'session' (with 'sessions' as Cobra alias)
    cmd.AddCommand(newCheckpointGroupCmd())         // 'checkpoint' / 'cp' / 'checkpoints'
    experimental.Register(cmd, newTokensGroupCmd()) // 'tokens' (experimental)
    cmd.AddCommand(newAgentGroupCmd())              // 'agent'
    cmd.AddCommand(newAuthCmd())                    // 'auth'
    cmd.AddCommand(newDoctorCmd())                  // 'doctor' (group: trace/logs/bundle)
    cmd.AddCommand(newLabsCmd())                    // 'labs' (experimental workflow discovery)
    cmd.AddCommand(newPluginGroupCmd())             // 'plugin' (managed install/list/remove)
    experimental.Register(cmd, newImportCmd())      // 'import' (experimental; import pre-existing agent history)
    cmd.AddCommand(newOrgCmd())                     // 'org' — control-plane org management
    cmd.AddCommand(newProjectCmd())                 // 'project' — control-plane project management
    cmd.AddCommand(newRepoCmd())                    // 'repo' — control-plane repo lifecycle
    cmd.AddCommand(newGrantCmd())                   // 'grant' — control-plane access grants

// Top-level lifecycle and standalone commands.
    cmd.AddCommand(cliReview.NewCommand(buildReviewDeps()))        // `review`; hidden during maturation
    cmd.AddCommand(investigate.NewCommand(buildInvestigateDeps())) // hidden during maturation; runs a multi-agent investigation
    experimental.Register(cmd, cliReview.NewCommand(buildReviewDeps()))        // `review` (experimental)
    experimental.Register(cmd, investigate.NewCommand(buildInvestigateDeps())) // `investigate` (experimental); multi-agent investigation
    cmd.AddCommand(newCleanCmd())
    cmd.AddCommand(newSetupCmd()) // 'configure' — non-agent settings; agent CRUD lives under 'agent'
    cmd.AddCommand(newEnableCmd())
    cmd.AddCommand(newDisableCmd())
    cmd.AddCommand(newStatusCmd())
    cmd.AddCommand(newBlameCmd())
    cmd.AddCommand(newWhyCmd())
    experimental.Register(cmd, newBlameCmd()) // 'blame' (experimental)
    experimental.Register(cmd, newWhyCmd())   // 'why' (experimental)
    cmd.AddCommand(newLoginCmd())
    cmd.AddCommand(newLogoutCmd())
    cmd.AddCommand(newVersionCmd())
8 unmodified lines

cmd.AddCommand(hideAsAlias(newAttachCmd(), "entire session attach"))
    cmd.AddCommand(hideAsAlias(newExplainCmd(), "entire checkpoint explain"))
    cmd.AddCommand(hideAsAlias(newTraceCmd(), "entire doctor trace"))
    cmd.AddCommand(newSearchCmd()) // 'entire search' = 'checkpoint search' (hidden, no hint)
    experimental.Register(cmd, newSearchCmd()) // 'entire search' = 'checkpoint search' (experimental)

// Hidden labs commands (listed via `entire labs`; not deprecation shortcuts).
    cmd.AddCommand(newExpertsCmd()) // agent/workflow provenance
    // Experimental labs commands (listed via `entire labs`; not deprecation shortcuts).
    experimental.Register(cmd, newExpertsCmd()) // 'experts' (experimental); agent/workflow provenance

// Deprecated top-level commands (functional; the constructors mark them
    // Deprecated, which also excludes them from help and completion).
4 unmodified lines

cmd.AddCommand(newMCPCmd(cmd)) // MCP stdio server for MCP-host agents
    cmd.AddCommand(newHooksCmd())
    cmd.AddCommand(newTrailCmd())
    cmd.AddCommand(newRunnerCmd()) // 'runner' (setup/tune runners); hidden during maturation
    cmd.AddCommand(newSendAnalyticsCmd())
    cmd.AddCommand(newCurlBashPostInstallCmd())

// Experimental command (developer-only visibility; setup/tune runners).
    experimental.Register(cmd, newRunnerCmd()) // 'runner' (experimental)

cmd.SetVersionTemplate(versionString())

// Replace default help command with custom one that supports -t flag

Mcmd/entire/cli/root.go+24/-21

5 unmodified lines

6
7
8
9
10
11
12
200 unmodified lines

213
214
215
215
216
217
218
219
6 unmodified lines

226
227
228
229
230
231
232
233
234
235
232
233
236
237
238
239
240
237
241
242
243
244
5 unmodified lines

250
251
252
249
250
253
254
255
256
257
258
259

5 unmodified lines

"strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/experimental"
    "github.com/entireio/cli/cmd/entire/cli/versioninfo"
    "github.com/spf13/cobra"
)
200 unmodified lines

}
}

func TestCheckpointSearchIsVisibleButTopLevelSearchIsHidden(t *testing.T) {
func TestCheckpointSearchIsVisibleButTopLevelSearchIsExperimental(t *testing.T) {
    t.Parallel()

root := NewRootCmd()
6 unmodified lines

t.Fatal("checkpoint search should be visible in checkpoint help")
    }

// The top-level `entire search` shortcut is gated as experimental:
    // visible and grouped in developer builds (the default test build),
    // hidden in shipped releases.
    topLevelSearch, _, err := root.Find([]string{"search"})
    if err != nil {
        t.Fatalf("find top-level search: %v", err)
    }
    if !topLevelSearch.Hidden {
        t.Fatal("top-level search should remain hidden as a compatibility alias")
    if topLevelSearch.GroupID != experimental.GroupID {
        t.Fatalf("top-level search GroupID = %q, want %q (experimental)", topLevelSearch.GroupID, experimental.GroupID)
    }
}

func TestCheckpointPolicyCommandIsHiddenDuringDevelopment(t *testing.T) {
func TestCheckpointPolicyCommandIsExperimental(t *testing.T) {
    t.Parallel()

root := NewRootCmd()
5 unmodified lines

if len(remaining) != 0 || checkpointPolicy.Use != "policy" {
        t.Fatalf("checkpoint policy resolved to %q with remaining args %v", checkpointPolicy.Use, remaining)
    }
    if !checkpointPolicy.Hidden {
        t.Fatal("checkpoint policy should be hidden while it is in active development")
    // Gated as experimental: visible and grouped in developer builds
    // (the default test build), hidden in shipped releases.
    if checkpointPolicy.GroupID != experimental.GroupID {
        t.Fatalf("checkpoint policy GroupID = %q, want %q (experimental)", checkpointPolicy.GroupID, experimental.GroupID)
    }

topLevelPolicy, remaining, err := root.Find([]string{"policy"})

Mcmd/entire/cli/root_test.go+12/-6

1309 unmodified lines

1310
1311
1312
1313
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1316
1324
1325
1326
1327

1309 unmodified lines

}

// IsSetUpAndEnabled returns true if Entire is both set up and enabled.
// This checks if .entire/settings.json exists AND has enabled: true.
// "Set up" spans either scope — .entire/settings.json OR
// .entire/settings.local.json — so it must check IsSetUpAny, not IsSetUp.
// `entire enable --local` writes only settings.local.json and never creates the
// base file; gating on the base file alone would treat such a local-only repo
// as inactive and make every hook a silent no-op, dropping all checkpoint
// capture for that documented workflow. The IsSetUpAny guard is still required
// so a never-enabled repo (no settings file in any scope) is not treated as
// enabled by Load's default Enabled: true. Any settings read error is treated
// as disabled (fail closed).
// Use this for hooks that should be no-ops when Entire is not active.
func IsSetUpAndEnabled(ctx context.Context) bool {
    if !IsSetUp(ctx) {
    if !IsSetUpAny(ctx) {
        return false
    }
    s, err := Load(ctx)

Mcmd/entire/cli/settings/settings.go+10/-2

500 unmodified lines

501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
5 unmodified lines

562
563
564
565
566
567
568

500 unmodified lines

---

## Scenario 8: git-refs Backend — Condensation and Push

All scenarios above describe the default **git-branch** backend, which condenses to the single `entire/checkpoints/v1` branch. When the primary backend is **git-refs**, the session/timing/overlap logic is **identical** — the only differences are *where* condensation writes and *how* the result is pushed. Everything about when a checkpoint is created, what it contains, and content-aware carry-forward is unchanged.

Two differences:

1. **Condensation target.** Instead of splicing the checkpoint subtree under `<id[:2]>/<id[2:]>/` on the `v1` branch, git-refs commits that same subtree as the tree root of a per-checkpoint ref, `refs/entire/checkpoints/<shard>/<id>` (orphan commit on first write, parented on later backfills). The ref is then recorded in a **push-discovery queue** rather than advancing a shared branch tip.
2. **Push mechanism.** Pre-push drains the queue and pushes exactly the changed refs, fast-forward-only, instead of pushing one branch.

```mermaid
sequenceDiagram
    participant U as User
    participant G as Git Hooks
    participant SB as Shadow Branch
    participant R as refs/entire/checkpoints/*
    participant PQ as Push Queue
    participant Rem as Remote

U->>G: git commit -a
    Note over G: PrepareCommitMsg (adds Entire-Checkpoint trailer)
    Note over G: PostCommit hook
    G->>SB: Read accumulated shadow state
    G->>R: Commit checkpoint subtree at refs/.../<shard>/<id>
    G->>PQ: Enqueue the ref (best-effort)
    G->>SB: Delete shadow branch

Note over U: Later...
    U->>G: git push
    Note over G: PrePush hook (PrimaryIsRefs → refs path)
    G->>PQ: Drain queued refs
    G->>Rem: Batch-push refs (fast-forward-only)
    alt push accepted
        G->>PQ: Remove pushed refs
    else non-fast-forward (diverged)
        G->>Rem: Fetch ref + replay local commits, retry (still non-force)
        G->>PQ: Remove only refs that landed
    end
```

### Key Points
- Condensation writes one commit per checkpoint under `refs/entire/checkpoints/<shard>/<id>`; there is no shared branch tip to serialize on.
- Enqueue is best-effort — a checkpoint that lands locally but fails to enqueue is still correct locally and re-enqueues on its next write.
- Pushes are never forced; a diverged ref is recovered by fetch + replay so the remote commit is preserved as an ancestor.
- Failed or interrupted pushes leave refs queued for the next pre-push — the queue degrades toward "will retry", never toward silent loss.
- Reads route by ID kind across both backends, so a repo mid-migration reads hex (branch) and ULID (refs) checkpoints transparently.

See [Ref-Based Checkpoint Backend](ref-checkpoint-backend.md) for the full backend design (sharding, read routing, configuration, and rollout).

---

## Summary Table

| Scenario | When Checkpoint Created | Checkpoint Contains | Key Mechanism |
5 unmodified lines

| 5. Partial commit + stash + new prompt + commit new | PostCommit (IDLE) | Full transcript (both prompts) | FilesTouched accumulation, stashed files "fall out" |
| 6. Stash + new prompt + unstash + commit all | PostCommit (IDLE) | All files + full transcript | Shadow branch accumulation |
| 7. Partial staging with `git add -p` | Each PostCommit (IDLE) | Full transcript per checkpoint | Content-aware carry-forward (hash comparison) |
| 8. git-refs backend | Same timing as 1–7 (backend-orthogonal) | Same as 1–7 | Condense to `refs/entire/checkpoints/<shard>/<id>` + push-queue drain at pre-push |

---

Mdocs/architecture/checkpoint-scenarios.md+51

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216

# Ref-Based Checkpoint Backend (git-refs)

This document explains the **git-refs** checkpoint backend as a system: how it stores checkpoints, how it pushes and fetches them, how it coexists with the legacy **git-branch** backend, and how it is selected through configuration.

It is the companion to [Sessions and Checkpoints](sessions-and-checkpoints.md), which covers the domain model (sessions, checkpoints, IDs) shared by both backends. Read that first for the checkpoint tree layout, checkpoint-ID linking, and the compact-transcript format; this doc focuses on what is specific to the ref-based store.

## Why a second backend

The original backend stores every committed checkpoint as a subtree of a single long-lived branch, `entire/checkpoints/v1` (the **git-branch** backend). That branch is a serialization point: every condensation rewrites its tip, every push races on one ref, and the whole history travels together.

The **git-refs** backend instead keeps **one git ref per checkpoint**:

```
refs/entire/checkpoints/<shard>/<id>
```

Each ref points at a commit whose **tree root is that checkpoint's contents** (`metadata.json`, `0/`, `1/`, `tasks/…`) — the same subtree the git-branch backend splices *under* `<id[:2]>/<id[2:]>/` in the v1 tree. Independent refs mean checkpoints are written, pushed, and fetched independently: no shared tip to contend on, and a reader can fetch exactly the one checkpoint it needs instead of the whole branch.

Both backends are **git-backed** — they store the committed record in the repo's own object store — and never touch the working branch's history.

## Backend taxonomy: primary and mirrors

Checkpoint storage is pluggable. The topology is a single **primary** plus zero or more **mirrors**:

- **Primary** — the source of truth. It serves all reads and writes, and the full checkpoint lifecycle (resume bootstrap, `doctor` reconcile, `explain` tree reads, push, cleanup, pre-push OPF) drives *its* record.
- **Mirror** — an independent backend that receives best-effort **write fan-out** only. Reads never come from a mirror.

Backends register in `checkpoint/registry.go`. Each carries a `gitBacked` capability:

| Capability | Meaning | Can be primary? | Can be mirror? |
|------------|---------|-----------------|----------------|
| `gitBacked: true` | Stores the committed record in this repo's git object store | Yes | Yes |
| `gitBacked: false` | Stores elsewhere (e.g. a filesystem store) | No — mirror-only | Yes |

Only a git-backed backend can be the primary, because the lifecycle paths above operate through the repo and its refs; a non-git-backed backend has no such ref to drive them. The two built-in backends — `git-branch` and `git-refs` — are **both** git-backed and are registered directly in the built-in registry map. The `Register()` entry point is for non-git-backed (mirror-only) backends and is used in practice only by test-only backends, so a production binary can never select an unregistered one.

A **one-of-each-type** rule permits two distinct git-backed backends in the same topology. Note, though, that the branch→refs migration deliberately does **not** run `git-branch` as a mirror of `git-refs`. Cross-format compatibility comes from read routing plus the version policy — every reader (CLI, entire.io, entire-api) reads refs first and falls back to the branch — not from dual-writing the same checkpoint into both backends (see [Migration and coexistence](#migration-and-coexistence)). Mirroring stays available as a general mechanism, primarily for non-git-backed targets (e.g. a filesystem store).

## Ref layout and sharding

```
refs/entire/checkpoints/<shard>/<id>
```

- `<id>` is the full checkpoint ID (12-hex or ULID) and is always the leaf, so the ref round-trips: `RefName(id)` builds it and `ParseRef(name)` recovers the ID (`checkpoint/refs_naming.go`).
- `<shard>` is `id.ShardFor()` — the **last two characters** of the ID, for **both** formats.

A single positional rule (independent of ID kind) keeps ref naming impossible to compute inconsistently between callers, and the suffix distributes checkpoints evenly for either format:

- A **legacy hex** ID is random throughout, so its last two chars are as good as any.
- A **ULID**'s leading chars encode a millisecond timestamp (barely varying between nearby checkpoints) while its trailing chars are random — so sharding on the suffix keeps buckets even *and* keeps the ID itself lexicographically time-sortable.

`ParseRef` validates that the shard in a ref name matches the ID's own `ShardFor()` and that the tail is exactly `<shard>/<id>` (no extra path segments), so a malformed or foreign ref is rejected rather than resolved to the wrong bucket. `RefName` errors on an empty or unrecognized ID rather than emitting `refs/entire/checkpoints//`.

> **Note:** this is the git-refs namespace only. The git-branch backend keeps its own independent **first-two-chars** tree layout (`<id[:2]>/<id[2:]>/`) inside the v1 branch. The two sharding schemes are deliberately different and do not interact.

## ID formats

Checkpoint IDs come in two shapes; the store determines which is minted:

- **git-branch primary** → 12-char hex IDs.
- **git-refs primary** → 26-char ULIDs (Crockford base32, lexicographically time-sortable).

IDs are minted by `checkpoint.GenerateCheckpointID`, which picks the format from the configured primary. Never call `id.Generate()` / `id.GenerateULID()` directly from a write path. The full rationale for the two formats, `DisplayShort`, and `id.MaxIDLength` lives in [Sessions and Checkpoints → Checkpoint ID Linking](sessions-and-checkpoints.md#checkpoint-id-linking).

## Write path

The git-refs store (`gitRefsStore`, `checkpoint/refs_store.go`) shares the checkpoint-subtree machinery with the git-branch store via an embedded `*treeWriter`. Both build the exact same checkpoint subtree; they differ only in the **base path** they write it at and in **where the result is committed**. The git-branch store writes each checkpoint under its shard prefix `<id[:2]>/<id[2:]>/` inside the single `v1` tree, so many checkpoints share one tree. The git-refs store writes with **no prefix** (an empty base path), so the checkpoint subtree *is* the root of that checkpoint's own commit tree, and the commit is the tip of a per-checkpoint ref rather than a subtree of the v1 branch.

Every persistent write (`WriteSession`, and the `Backfill*` operations for transcript / summary / attribution) follows the same shape:

1. **Resolve the ref's current tip** (`refBase`). A missing ref → `(ZeroHash, nil)`, so the first write to a checkpoint becomes an **orphan commit**. A real lookup failure (IO/corruption) is surfaced, never silently treated as "new checkpoint".
2. **Build the updated checkpoint subtree** from the existing tree plus the new content (shared `treeWriter` logic).
3. **Create a commit** with the current tip as parent (orphan on first write, parented thereafter), so each checkpoint accretes its **own per-checkpoint history**.
4. **Point the ref at the new commit** (`setRef`) and **enqueue it for push**.

Enqueue is best-effort: a write that lands locally but fails to enqueue must not fail condensation. The ref is still local and correct; only its remote sync is deferred until the next write to the same checkpoint re-enqueues it (see the push queue below).

## Push and fetch

Because reads can *fetch* refs on demand and there is no single branch tip to push, the git-refs backend cannot simply "push everything" at pre-push time. Deleting local refs after pushing would also hurt local workflows. Instead it tracks exactly which checkpoints changed, in a **push-discovery queue**, and pushes those.

### Push-discovery queue

`checkpoint/pushqueue.go` — a flock-protected JSONL file in the git **common dir** (so every worktree sharing the object store enqueues into one queue):

- `entire-checkpoint-push-queue.jsonl` — one `{"ref": …}` record per queued ref.
- `entire-checkpoint-push-queue.lock` — the flock.

Semantics:

- **Enqueue** appends under the lock; enqueuing an already-present or already-pushed ref is safe (drain de-dups, the push is idempotent).
- **Drain** returns the de-duplicated queued refs *without* removing them, and compacts the file in place when it held redundant lines (so a long-lived session that keeps re-enqueuing the same ref cannot grow the file unboundedly).
- **Remove** deletes refs only **after a confirmed push**, preserving any entry appended during the push. An interrupted or failed push therefore leaves its refs queued for the next pre-push — the queue degrades toward "will retry", never toward silent loss.

Rewrites are atomic (temp file + rename under the lock) so a concurrent reader never sees a half-written queue.

### Pre-push flow

`ManualCommitStrategy.PrePush` (`strategy/manual_commit_push.go`) branches on `checkpoint.PrimaryIsRefs(cfg)`. When the primary is git-refs it:

1. **Drains** the queue.
2. **Partitions** the drained refs into those that still exist locally and stale ones (dropped from the queue).
3. **Batch-pushes** the existing refs in one network round-trip (`batchPushRefs`, `strategy/push_common.go`).
4. On success, **removes** the pushed refs from the queue and runs shadow-branch cleanup.
5. On a batch failure (typically a non-fast-forward rejection), **falls back to per-ref recovery** (`pushCheckpointRefWithRecovery`) and removes from the queue only the refs that land.

### Non-force, fast-forward-only

All checkpoint-ref pushes are **fast-forward-only — never a force push.** There is no server-side ref protection, so a force push risks silently clobbering a checkpoint written elsewhere. Per-checkpoint refs normally advance by fast-forward (append-only per-checkpoint history), so this is the common case.

When a push *is* rejected as non-fast-forward — genuine divergence, e.g. the same checkpoint was written on two machines — recovery **fetches the remote ref and replays the local-only commits on top** (`fetchAndRebaseRefCommon`), then retries. After the replay the local ref is a fast-forward over the remote, so the retry is *still* non-force and the remote commit is preserved as an ancestor rather than overwritten. A genuine cherry-pick conflict (both sides rewrote the same file, e.g. root `metadata.json`) leaves the ref queued — degrading to the safe state, never forcing.

### On-demand fetch for reads

A checkpoint written on another machine has no local ref. When a read misses locally and a **ref fetcher** is configured, `resolveRefMaybeFetch` fetches that one ref from the remote and retries once. It carefully distinguishes:

- **genuinely absent** (remote has no such checkpoint) → maps to `ErrCheckpointNotFound`;
- **a real failure** (IO, network, context cancellation) → returned as-is, never swallowed as "not found".

`List` at the storage level is **local-refs-only** — it enumerates local refs and reads each root summary. There is no remote enumeration.

## Read routing and coexistence

`checkpoint.Open` returns a `kindRoutingStore` (`checkpoint/routing_store.go`) that resolves id-keyed reads across **both** git backends by the checkpoint's ID kind, so a repo running git-refs and git-branch side by side (or mid-migration) reads either format without reconfiguring:

| ID kind | Read from | Rationale |
|---------|-----------|-----------|
| **ULID** | git-refs only, never the branch | ULIDs are only ever minted under git-refs |
| **hex**, git-branch primary | branch only | branch is authoritative for hex |
| **hex**, git-refs primary | refs first, then git-branch fallback | a hex checkpoint may still sit on the pre-migration v1 branch, or have been migrated into refs |

- `List` **unions both** backends and de-dups by ID (the same checkpoint can appear in both during coexistence), keeping the most recent.
- The `firstResolved` helper tries stores in priority order; a non-final store that reports absent *or* errors falls through to the next, so a transient git-refs fetch error cannot hide a checkpoint that resolves on the branch. The final store's result (hit, absent, or error) is returned verbatim.
- The optional `AuthorReader` capability (`explain` relies on it) is preserved and routed by the same rules when both read stores provide it.
- **Writes are not kind-routed.** They target the configured primary (+ mirrors); the minted ID already matches the primary's format.

All general read paths — resume, explain, attribution, blame, tokens, attach — inherit this routing for free through `checkpoint.Open`; there is no per-command config knob.

## Configuration and rollout

Backend selection lives in the `checkpoints` block of settings (`settings/checkpoints.go`):

```json
{
"checkpoints": {
    "primary": { "type": "git-refs" }
}
}
```

- `primary.type` is required. When the whole block is absent, the layer defaults to the **git-branch** backend with no mirrors — so existing repos are unchanged.
- `settings.local.json`'s `checkpoints` block **replaces** the one in `settings.json` wholesale (this is a selection config, not a deep-merged document).
- Config loading is **fail-soft**: a missing file, a whole-file JSON syntax error, or unrelated invalid fields all resolve to "no config" → default git-branch. It errors *only* when a present `checkpoints` block is itself invalid.
- Unknown fields are rejected (`DisallowUnknownFields`) to surface typos. The trade-off: adding a `checkpoints` field is a coordinated rollout — ship the reader before any writer emits the field.

### Environment override

`ENTIRE_CHECKPOINTS_PRIMARY` (and the optional comma-separated `ENTIRE_CHECKPOINTS_MIRRORS`) **fully replace** any settings block — env wins over file, matching other `ENTIRE_*` overrides. This is how e2e/CI and rollout drive a specific backend without editing settings; the CI test-canary job runs a matrix over `[git-branch, git-refs]` via this variable. The env override is selection-only (no per-backend config blocks).

### Rollout states

The switch is a **primary flip**, not a dual-write phase. There is no "run both backends in parallel" step — see [Migration and coexistence](#migration-and-coexistence) for why read routing makes it unnecessary.

| State | `primary` | Behavior |
|-------|-----------|----------|
| **Default** (today) | `git-branch` | Hex checkpoints on the `v1` branch; unchanged legacy behavior |
| **Refs-only** | `git-refs` | New checkpoints are ULIDs written as per-checkpoint refs; pre-existing hex/`v1` checkpoints stay readable via the read-routing fallback |

## Checkpoint version and policy

Checkpoint formats are named `<family>-v<major>` and validated in `checkpointpolicy/format.go`:

| Format | Family | Written by |
|--------|--------|------------|
| `branch-v1` | `branch` | git-branch backend |
| `refs-v1` | `refs` | git-refs backend |

Both are in the CLI's read **and** write sets. The repo-wide checkpoint policy (`refs/entire/policies/checkpoint`, `checkpoint_version` / `checkpoint_min_version`) gates which formats a client may write and nudges upgrades; see [Sessions and Checkpoints → Checkpoint Policy](sessions-and-checkpoints.md#checkpoint-policy).

## Migration and coexistence

The read-routing rules above are what make a hex-on-branch repo and a ULID-in-refs repo the same repo: nothing needs to move for both formats to be readable, so the branch→refs switch is a primary flip with **no dual-write step**.

Concretely, flipping the primary to git-refs means new checkpoints are ULIDs stored as per-checkpoint refs, while every checkpoint already written to the `v1` branch stays exactly where it is and keeps resolving through the branch fallback. This works because **every reader routes the same way — refs first (for both ID formats), branch fallback for the legacy format** — not just the CLI but also entire.io and entire-api. So a repo can move to refs-only on the remote without keeping the `v1` branch alive for any reader's benefit.

A mixed fleet is fine and needs no special handling:

- A **modern** CLI (or the server) on git-refs primary reads everything: ULID/refs checkpoints directly, and older hex/`v1` checkpoints via the fallback.
- An **old** CLI keeps writing hex checkpoints to the `v1` branch, and everyone modern still reads those. It simply **cannot read** newer ULID/refs checkpoints — which is the intended behavior: it fails closed, and the [version policy](#checkpoint-version-and-policy) (`checkpoint_min_version`) turns that into an explicit "upgrade" nudge rather than a silent half-working state.

This is why running `git-branch` as a *mirror* of git-refs is **not** part of the migration: it would dual-write every checkpoint into both backends to keep `v1` populated, but no reader needs that — read routing already covers both formats, and the "old client can't read the new format" case is a feature, not something to paper over.

When checkpoints *are* actively migrated from the branch into refs (a path that is tooling-only today, not an official flow), they are written under `RefName(hexID)` — i.e. **hex-named refs** — which is why a hex ID under a git-refs primary is looked up in refs first and only then falls back to the branch.

## Key files

| File | Responsibility |
|------|----------------|
| `checkpoint/registry.go` | Backend registry, `gitBacked` capability, built-in `git-branch`/`git-refs` |
| `checkpoint/open.go` | `Open` topology resolution, `PrimaryIsRefs`, `kindRoutingStore` wiring |
| `checkpoint/refs_naming.go` | `RefName` / `ParseRef`, `CheckpointRefPrefix` |
| `checkpoint/refs_store.go` | `gitRefsStore` — per-checkpoint write/read, on-demand fetch |
| `checkpoint/pushqueue.go` | Flock JSONL push-discovery queue |
| `checkpoint/routing_store.go` | `kindRoutingStore` — id-kind read routing across both backends |
| `checkpoint/id/id.go` | `ShardFor`, `Kind`/`KindOf`, ID generation |
| `checkpointpolicy/format.go` | `branch-v1` / `refs-v1` format families and read/write sets |
| `settings/checkpoints.go` | `checkpoints` block parsing + env override |
| `strategy/manual_commit_push.go` | Pre-push: drain queue, batch push, per-ref recovery |
| `strategy/push_common.go` | `batchPushRefs`, `pushCheckpointRefWithRecovery`, fetch+replay |

## Known limitations and deferred work

- **Storage-level `List` is local-only** — no remote enumeration of checkpoint refs. `List` at the routing layer still unions the two local stores.
- **OPF (OpenAI Privacy Filter) at pre-push is git-branch-only for now.** The per-ref push does not run OPF re-redaction; that is deferred until after the store lands. See `strategy/manual_commit_opf_rewrite.go` and [security-and-privacy.md](../security-and-privacy.md).
- **The "ULIDs never land on the branch" invariant is not yet enforced at write time.** A config flip or a missing `ENTIRE_CHECKPOINTS_PRIMARY` in an amending environment could, in principle, condense a ULID checkpoint onto the `v1` branch, which readers (routing ULIDs to refs only) would then fail to find. Because git-branch is *not* a mirror of git-refs (see [Migration and coexistence](#migration-and-coexistence)), a ULID reaching the git-branch write path is unambiguously a bug — so enforcing this is a straightforward reject at that write path, not a topology-role-aware check.

Adocs/architecture/ref-checkpoint-backend.md+216

3 unmodified lines

4
5
6
7
8
9
10
11
134 unmodified lines

146
147
148
147
149
150
151
152
153
154
155
323 unmodified lines

479
480
481
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
485
499
500
501
502

3 unmodified lines

Entire CLI creates checkpoints for AI coding sessions. The system is agent-agnostic - it works with Claude Code, Codex, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, or any tool that triggers Entire hooks.

This document covers the domain model shared by both checkpoint storage backends. For how the **git-refs** backend stores checkpoints as one ref per checkpoint — its layout, push/fetch model, read routing, and configuration — see [Ref-Based Checkpoint Backend](ref-checkpoint-backend.md).

## Domain Model

### Session
134 unmodified lines

|------|----------|----------|
| Session State | `.git/entire-sessions/<id>.json` | Active session tracking |
| Ephemeral | `entire/<commit[:7]>-<worktreeHash[:6]>` branch | Full state (code + metadata) |
| Persistent | `entire/checkpoints/v1` branch (sharded) | Metadata + commit reference |
| Persistent (git-branch) | `entire/checkpoints/v1` branch, sharded `<id[:2]>/<id[2:]>/` | Metadata + commit reference |
| Persistent (git-refs) | `refs/entire/checkpoints/<shard>/<id>`, one ref per checkpoint | Metadata + commit reference |

The persistent store is pluggable: `git-branch` (the default) stores every committed checkpoint as a subtree of a single `entire/checkpoints/v1` branch, while `git-refs` stores one ref per checkpoint. Both are git-backed and share the same checkpoint tree layout; they differ only in where that tree is committed. This document describes the git-branch layout; for the ref-based backend — its ref naming, sharding, push/fetch model, read routing, and configuration — see [Ref-Based Checkpoint Backend](ref-checkpoint-backend.md).

### Session State

323 unmodified lines

├── phase.go             # Session phase state machine (ACTIVE, IDLE, ENDED, etc.)

checkpoint/
├── checkpoint.go        # checkpoint.Type, checkpoint.Store interface, CheckpointSummary, etc.
├── store.go             # GitStore implementation
├── temporary.go         # Shadow branch storage
├── committed.go         # Metadata branch storage
├── id/                  # CheckpointID type and generation
├── checkpoint.go        # checkpoint.Type, store interfaces, CheckpointSummary, etc.
├── open.go              # Open() facade: resolves topology, wires stores + fetchers
├── registry.go          # Backend registry + gitBacked capability (git-branch, git-refs)
├── routing_store.go     # kindRoutingStore: id-kind read routing across both backends
├── fanout.go            # Mirror write fan-out (primary + best-effort mirrors)
├── generate.go          # GenerateCheckpointID (format follows the configured primary)
├── persistent.go        # git-branch persistent store (entire/checkpoints/v1)
├── persistent_write.go  # git-branch write path (treeWriter, subtree splicing)
├── refs_store.go        # git-refs persistent store (one ref per checkpoint)
├── refs_naming.go       # RefName / ParseRef, CheckpointRefPrefix, sharding
├── pushqueue.go         # git-refs push-discovery queue (flock JSONL)
├── ephemeral.go         # Shadow-branch (ephemeral) store
├── fsstore/             # Filesystem mirror backend (non-git-backed, mirror-only)
├── id/                  # CheckpointID type, Kind/KindOf, ShardFor, generation
│   └── id.go
```

Strategies use `checkpoint.Store` primitives - storage details are encapsulated.
Strategies use the `checkpoint.Open` facade and store primitives - backend and storage details are encapsulated.

## Strategy Role

Mdocs/architecture/sessions-and-checkpoints.md+21/-7