Skip to content
23 changes: 23 additions & 0 deletions cmd/entire/cli/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ func claudeHookConfig(ctx context.Context) (*agent.HookConfigFile, error) {
return agent.OpenHookConfig(repoRoot, (&ClaudeCodeAgent{}).HookConfigRelPath()) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error
}

func claudeHookConfigForWorktreeRoot(worktreeRoot string) (*agent.HookConfigFile, error) {
return agent.OpenHookConfig(worktreeRoot, (&ClaudeCodeAgent{}).HookConfigRelPath()) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error
}

// resolveInstallRepoRoot locates the repo root InstallHooks writes under,
// falling back to CWD when not in a git repo (e.g. during tests).
func resolveInstallRepoRoot(ctx context.Context) (string, error) {
Expand Down Expand Up @@ -430,6 +434,10 @@ func loadClaudeSettings(ctx context.Context) (ClaudeSettings, error) {
if err != nil {
return ClaudeSettings{}, err
}
return loadClaudeSettingsFromConfig(ctx, cfg)
}

func loadClaudeSettingsFromConfig(ctx context.Context, cfg *agent.HookConfigFile) (ClaudeSettings, error) {
data, err := cfg.Read()
// No settings file means no hooks, which is an answer; anything else means we
// could not read the answer.
Expand Down Expand Up @@ -463,6 +471,21 @@ func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) (bool, error) {
return hasEntireHook(settings.Hooks.Stop), nil
}

// AreProjectHooksInstalledInWorktree checks an explicit worktree's shared
// .claude/settings.json for Entire hooks. Claude user and local settings are
// outside this portability check because they do not travel with the checkout.
func AreProjectHooksInstalledInWorktree(ctx context.Context, worktreeRoot string) (bool, error) {
cfg, err := claudeHookConfigForWorktreeRoot(worktreeRoot)
if err != nil {
return false, err
}
settings, err := loadClaudeSettingsFromConfig(ctx, cfg)
if err != nil {
return false, err
}
return hasEntireHook(settings.Hooks.Stop), nil
}

// HookConfigState describes how Entire's Claude Code hooks compare to what
// InstallHooks would write today. Aliased to the shared agent-package type so
// `entire status` and `entire doctor` can treat every agent's drift check
Expand Down
23 changes: 23 additions & 0 deletions cmd/entire/cli/agent/claudecode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,29 @@ func writeSettingsFile(t *testing.T, tempDir, content string) {
}
}

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

configured := t.TempDir()
writeSettingsFile(t, configured, `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"entire hooks claude-code stop"}]}]}}`)

installed, err := AreProjectHooksInstalledInWorktree(t.Context(), configured)
if err != nil {
t.Fatalf("AreProjectHooksInstalledInWorktree() error = %v", err)
}
if !installed {
t.Error("AreProjectHooksInstalledInWorktree() = false, want true")
}

installed, err = AreProjectHooksInstalledInWorktree(t.Context(), t.TempDir())
if err != nil {
t.Fatalf("AreProjectHooksInstalledInWorktree() for empty worktree error = %v", err)
}
if installed {
t.Error("AreProjectHooksInstalledInWorktree() = true for empty worktree")
}
}

func containsRule(rules []string, rule string) bool {
return slices.Contains(rules, rule)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/checkpoint/fsstore/fsstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ func metadataFromWriteOptions(opts cp.WriteOptions) cp.Metadata {
TranscriptLinesAtStart: opts.CheckpointTranscriptStart, //nolint:staticcheck // deliberate: git writes both so older CLIs can still read the metadata
TokenUsage: opts.TokenUsage,
SkillEvents: opts.SkillEvents,
PromptAttributions: opts.PromptAttributionsJSON,
PromptAttributions: checkpoint.CapPromptAttributions(context.Background(), opts.PromptAttributionsJSON, opts.SessionID),
SessionMetrics: opts.SessionMetrics,
Summary: checkpoint.RedactSummary(opts.Summary),
Attribution: opts.Attribution,
Expand Down
6 changes: 5 additions & 1 deletion cmd/entire/cli/checkpoint/persistent.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,7 @@ func (s *treeWriter) writeSessionToSubdirectory(ctx context.Context, opts WriteO
SkillEvents: opts.SkillEvents,
SessionMetrics: opts.SessionMetrics,
Attribution: opts.Attribution,
PromptAttributions: opts.PromptAttributionsJSON,
PromptAttributions: CapPromptAttributions(ctx, opts.PromptAttributionsJSON, opts.SessionID),
Summary: RedactSummary(opts.Summary),
CLIVersion: versioninfo.Version,
Kind: opts.Kind,
Expand Down Expand Up @@ -1789,6 +1789,10 @@ func (s *treeWriter) updateSessionMetadata(sessionDir string, entries map[string
return fmt.Errorf("read session metadata: %w", err)
}
mutate(metadata)
// This path re-marshals metadata an earlier CLI wrote, so the write-time cap
// applies here too: otherwise a pre-v0.10.1 oversized prompt_attributions
// would be copied verbatim into every finalize or backfill rewrite.
metadata.PromptAttributions = CapPromptAttributions(context.Background(), metadata.PromptAttributions, metadata.SessionID)

metadataJSON, err := jsonutil.MarshalIndentWithNewline(metadata, "", " ")
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions cmd/entire/cli/checkpoint/prompt_attributions_cap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package checkpoint

import (
"context"
"encoding/json"
"log/slog"

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

// MaxPromptAttributionsBytes bounds the prompt_attributions field a per-session
// metadata.json may carry. The field is a diagnostic record of the per-prompt
// line counts that fed the attribution summary: nothing in the CLI reads it back,
// and entire-api's ingest explicitly salvages a metadata.json whose tail it
// occupies. It is also the one field whose size scales with the working tree
// rather than with the session — CLI versions before v0.10.1 recorded every file
// of every nested git checkout on every prompt, and produced 70MB and 106MB
// metadata.json blobs that made entire/checkpoints/v1 unpushable to GitHub
// (100 MiB blob limit). A healthy session's record is tens of kilobytes, so
// 4 MiB is two orders of magnitude of headroom before the field is dropped.
const MaxPromptAttributionsBytes = 4 << 20

// CapPromptAttributions returns raw unchanged when it fits under
// MaxPromptAttributionsBytes, and nil — dropping the field from the written
// metadata — when it does not. The attribution summary itself is computed before
// this point and is unaffected; only the diagnostic input is withheld, and the
// drop is logged so the omission is explainable from .entire/logs.
func CapPromptAttributions(ctx context.Context, raw json.RawMessage, sessionID string) json.RawMessage {
if len(raw) <= MaxPromptAttributionsBytes {
return raw
}
logging.Warn(logging.WithComponent(ctx, "checkpoint"),
"dropping oversized prompt_attributions from session metadata",
slog.Int("bytes", len(raw)),
slog.Int("cap_bytes", MaxPromptAttributionsBytes),
slog.String("session_id", sessionID))
return nil
}
147 changes: 147 additions & 0 deletions cmd/entire/cli/checkpoint/prompt_attributions_cap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package checkpoint

import (
"bytes"
"context"
"encoding/json"
"fmt"
"testing"

"github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/gitrepo"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/redact"
)

func TestCapPromptAttributions_KeepsFittingPayload(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`[{"checkpoint_number":1,"user_lines_added":3}]`)
assert.Equal(t, raw, CapPromptAttributions(context.Background(), raw, "s1"))
assert.Nil(t, CapPromptAttributions(context.Background(), nil, "s1"))
}

func TestCapPromptAttributions_DropsOversizedPayload(t *testing.T) {
t.Parallel()
// One byte over the cap: the field is dropped rather than truncated, since a
// clipped JSON array is worse than an absent diagnostic.
raw := json.RawMessage(bytes.Repeat([]byte("x"), MaxPromptAttributionsBytes+1))
assert.Nil(t, CapPromptAttributions(context.Background(), raw, "s1"))

exact := json.RawMessage(bytes.Repeat([]byte("x"), MaxPromptAttributionsBytes))
assert.Equal(t, exact, CapPromptAttributions(context.Background(), exact, "s1"), "the cap is inclusive")
}

// oversizedPromptAttributions is a syntactically valid prompt_attributions
// payload just over the cap, the shape a pre-v0.10.1 nested-checkout walk left
// behind.
func oversizedPromptAttributions(t *testing.T) json.RawMessage {
t.Helper()
var b bytes.Buffer
b.WriteString(`[{"checkpoint_number":1,"user_added_per_file":{`)
for i := 0; b.Len() < MaxPromptAttributionsBytes+1; i++ {
if i > 0 {
b.WriteByte(',')
}
fmt.Fprintf(&b, `".claude/worktrees/agent/pkg/file%d.go":3`, i)
}
b.WriteString(`}}]`)
raw := json.RawMessage(b.Bytes())
require.True(t, json.Valid(raw))
return raw
}

// sessionMetadataAt reads <checkpoint>/<session index>/metadata.json from the
// branch tip as a generic document, so a test can assert on key presence.
func sessionMetadataAt(t *testing.T, store *GitStore, cpID id.CheckpointID, sessionIndex int) map[string]json.RawMessage {
t.Helper()
ref, err := store.repo.Reference(store.refs.Primary, true)
require.NoError(t, err)
commit, err := store.repo.CommitObject(ref.Hash())
require.NoError(t, err)
f, err := commit.File(fmt.Sprintf("%s/%d/%s", cpID.Path(), sessionIndex, paths.MetadataFileName))
require.NoError(t, err)
content, err := f.Contents()
require.NoError(t, err)
var doc map[string]json.RawMessage
require.NoError(t, json.Unmarshal([]byte(content), &doc))
return doc
}

// The cap is applied at the writer boundary, not only in the helper: a
// session written with an oversized diagnostic lands without the field, and a
// session with a small one keeps it.
func TestGitStoreWrite_CapsPromptAttributionsInStoredMetadata(t *testing.T) {
t.Parallel()
dir := t.TempDir()
testutil.InitRepo(t, dir)
repo, err := gitrepo.OpenPath(dir)
require.NoError(t, err)
store := NewGitStore(repo, DefaultV1Refs())
ctx := context.Background()

write := func(cpID string, attrs json.RawMessage) id.CheckpointID {
checkpointID := id.MustCheckpointID(cpID)
require.NoError(t, store.Write(ctx, Session{
CheckpointID: checkpointID,
SessionID: "session-" + cpID,
Strategy: "manual-commit",
Transcript: redact.AlreadyRedacted([]byte(`{"role":"user","content":"hi"}` + "\n")),
Prompts: []string{"hi"},
AuthorName: "Test",
AuthorEmail: "test@test.com",
PromptAttributionsJSON: attrs,
}))
return checkpointID
}

big := write("aaaaaaaaaaaa", oversizedPromptAttributions(t))
doc := sessionMetadataAt(t, store, big, 0)
assert.NotContains(t, doc, "prompt_attributions", "an oversized diagnostic is dropped at the write boundary")
assert.Contains(t, doc, "session_id", "the rest of the metadata is intact")

small := write("bbbbbbbbbbbb", json.RawMessage(`[{"checkpoint_number":1,"user_lines_added":2}]`))
doc = sessionMetadataAt(t, store, small, 0)
assert.JSONEq(t, `[{"checkpoint_number":1,"user_lines_added":2}]`, string(doc["prompt_attributions"]), "a fitting diagnostic is kept verbatim")
}

// The finalize/backfill path re-marshals metadata an older CLI wrote. Without
// the cap there, a pre-v0.10.1 oversized field would be copied into every later
// rewrite of that checkpoint.
func TestUpdateSessionMetadata_CapsPromptAttributionsFromOlderWriters(t *testing.T) {
t.Parallel()
dir := t.TempDir()
testutil.InitRepo(t, dir)
repo, err := gitrepo.OpenPath(dir)
require.NoError(t, err)
w := &treeWriter{repo: repo}

existing, err := json.Marshal(map[string]any{
"session_id": "legacy",
"prompt_attributions": oversizedPromptAttributions(t),
})
require.NoError(t, err)
blob, err := CreateBlobFromContent(repo, existing)
require.NoError(t, err)
const sessionDir = "ab/cdef000001/0"
metadataPath := checkpointSubtreePath(sessionDir, paths.MetadataFileName)
entries := map[string]object.TreeEntry{
metadataPath: {Name: metadataPath, Mode: filemode.Regular, Hash: blob},
}

require.NoError(t, w.updateSessionMetadata(sessionDir, entries, func(m *Metadata) {
m.CompactTranscriptStart = new(int)
}))

assert.NotEqual(t, blob, entries[metadataPath].Hash, "the blob was rewritten")
updated, err := readJSONFromBlob[map[string]json.RawMessage](repo, entries[metadataPath].Hash)
require.NoError(t, err)
assert.NotContains(t, *updated, "prompt_attributions")
assert.Contains(t, *updated, "session_id")
assert.Contains(t, *updated, "compact_transcript_start", "the caller's mutation is applied")
}
46 changes: 39 additions & 7 deletions cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,29 +47,43 @@ Checks performed:
entire/checkpoints/v1 branches share no common ancestor (caused by a
previous bug). Fixes by cherry-picking local checkpoints onto remote tip.

2. Operational logs: warn when .entire/logs cannot be written. Every other
2. Oversized checkpoint metadata: detects metadata.json blobs on
entire/checkpoints/v1 over 50 MiB (GitHub refuses blobs over 100 MiB, so
the branch cannot be pushed or mirrored there). Caused by CLI versions
before v0.10.1 recording nested git checkouts in the prompt_attributions
diagnostic field. Interactively offers to rewrite the branch without that
field and update the checkpoint sync remote (lease-guarded); --force does
NOT apply this one, because it rewrites history and pushes. Use
'entire doctor shrink-checkpoint-metadata' to apply it non-interactively.

3. Linked-worktree portability: warn when Entire settings and the shared
Claude project hook config are present in another worktree, but this
worktree is missing either part. The check is read-only and explains how
to make the setup available to future worktrees and clones.

4. Operational logs: warn when .entire/logs cannot be written. Every other
diagnostic is delivered by writing there, and that write is silent about
its own failure, so an unwritable log directory looks exactly like a repo
where nothing ran.

When Codex hooks are installed:
3. Codex hook trust: warn when hooks declared in .codex/hooks.json
5. Codex hook trust: warn when hooks declared in .codex/hooks.json
lack a trusted_hash entry in the user's Codex config (i.e. /hooks
review hasn't run yet on this machine, or a newer entire release
added a hook the user hasn't approved yet).

For each installed agent that reports hook-config drift:
4. Hook config: warn when the installed hooks no longer match what this
6. Hook config: warn when the installed hooks no longer match what this
CLI writes (e.g. an older release wrote Claude Code tool matchers that
no longer fire, or a committed Pi/OpenCode extension has gone stale).
Fix by re-running 'entire enable --force'.

5. Summary provider: warn when summary_generation.provider names a registered
7. Summary provider: warn when summary_generation.provider names a registered
agent that cannot generate text (e.g. factoryai-droid), which makes
'entire checkpoint explain --generate', 'entire dispatch' and
'entire runner setup' fail. Reports the file to change; does not rewrite it.

6. Stuck sessions: sessions stuck in ACTIVE or ENDED phase that need cleanup.
8. Stuck sessions: sessions stuck in ACTIVE or ENDED phase that need cleanup.

A session is considered stuck if:
- It is in ACTIVE phase with no interaction for over 1 hour
Expand Down Expand Up @@ -115,6 +129,7 @@ points at --force instead of prompting.`,
cmd.AddCommand(newDoctorLogsCmd())
cmd.AddCommand(newDoctorBundleCmd())
cmd.AddCommand(newDoctorMigrateCheckpointsCmd())
cmd.AddCommand(newDoctorShrinkCheckpointMetadataCmd())

return cmd
}
Expand All @@ -139,9 +154,20 @@ func runSessionsFix(cmd *cobra.Command, force bool) error {
finalErr = NewSilentError(fmt.Errorf("metadata check failed: %w", metadataErr))
}

// Check 2: metadata.json blobs GitHub refuses. After the disconnection
// check, which may have advanced the local branch from the remote, so the
// scan sees the reconciled history. Deliberately not given `force`: the
// fix rewrites history and pushes, and only an interactive yes or the
// dedicated subcommand may trigger that.
if sizeErr := checkOversizedCheckpointMetadata(cmd); sizeErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: checkpoint metadata size check failed: %v\n", sizeErr)
finalErr = NewSilentError(fmt.Errorf("checkpoint metadata size check failed: %w", sizeErr))
}

fmt.Fprintln(cmd.OutOrStdout())

ctx := cmd.Context()
setupIssue := inspectWorktreeSetup(ctx)

// Ahead of checkGitHooks, which is the check a symlinked hooks directory
// makes fail: the cause should be on screen before the failure it explains.
Expand All @@ -150,10 +176,11 @@ func runSessionsFix(cmd *cobra.Command, force bool) error {
// The git hook surface. Checked before the agent hook checks because it is
// the more fundamental one: if git hooks are broken, commits are not captured
// at all and agent-config drift is noise by comparison.
if hooksErr := checkGitHooks(cmd, force); hooksErr != nil {
if hooksErr := checkGitHooksWithWorktreeSetup(cmd, force, setupIssue); hooksErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: git hook check failed: %v\n", hooksErr)
finalErr = NewSilentError(fmt.Errorf("git hook check failed: %w", hooksErr))
}
writeWorktreeSetupIssue(cmd.OutOrStdout(), setupIssue)

// Before checkLogSink, because a symlinked .entire/logs is one of the reasons
// that check fires and this one names the cause.
Expand Down Expand Up @@ -628,12 +655,17 @@ func confirmDoctorFix(ctx context.Context, w io.Writer, title string) (bool, err
// writes exactly what the next turn-start would write anyway. Someone reaching
// for doctor after a rejected push wants to be unblocked, not handed a second
// command to run.
func checkGitHooks(cmd *cobra.Command, force bool) error {
func checkGitHooksWithWorktreeSetup(cmd *cobra.Command, force bool, setupIssue *worktreeSetupIssue) error {
ctx := cmd.Context()
w := cmd.OutOrStdout()

switch strategy.CheckGitHookState(ctx) {
case strategy.GitHooksCurrent:
if setupIssue != nil && setupIssue.CurrentCaptureInactive {
fmt.Fprintln(w, "Git hooks: INSTALLED BUT INACTIVE")
fmt.Fprintln(w, " This worktree has no Entire settings, so its hooks skip checkpoint capture.")
return nil
}
fmt.Fprintln(w, "✓ Git hooks: OK")
return nil

Expand Down
Loading
Loading